Merge branch 'main' into feat/replayable-stream

This commit is contained in:
Yunlu Wen 2026-04-21 18:27:03 +08:00 committed by GitHub
commit 26c00ffd85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 2392 additions and 1354 deletions

View File

@ -114,10 +114,10 @@ override-dependencies = [
dev = [
"coverage>=7.13.4",
"dotenv-linter>=0.7.0",
"faker>=20.1.0",
"faker>=40.15.0",
"lxml-stubs>=0.5.1",
"basedpyright>=1.39.0",
"ruff>=0.15.10",
"basedpyright>=1.39.3",
"ruff>=0.15.11",
"pytest>=9.0.3",
"pytest-benchmark>=5.2.3",
"pytest-cov>=7.1.0",
@ -157,14 +157,14 @@ dev = [
"types-tensorflow>=2.18.0.20260408",
"types-tqdm>=4.67.3.20260408",
"types-ujson>=5.10.0",
"boto3-stubs>=1.42.88",
"boto3-stubs>=1.42.92",
"types-jmespath>=1.1.0.20260408",
"hypothesis>=6.151.12",
"hypothesis>=6.152.1",
"types_pyOpenSSL>=24.1.0",
"types_cffi>=2.0.0.20260408",
"types_setuptools>=82.0.0.20260408",
"pandas-stubs>=3.0.0",
"scipy-stubs>=1.15.3.0",
"scipy-stubs>=1.17.1.4",
"types-python-http-client>=3.3.7.20260408",
"import-linter>=2.3",
"types-redis>=4.6.0.20241004",

View File

@ -23,7 +23,7 @@ class PluginAutoUpgradeService:
exclude_plugins: list[str],
include_plugins: list[str],
) -> bool:
with session_factory.create_session() as session:
with session_factory.create_session() as session, session.begin():
exist_strategy = session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
@ -50,7 +50,7 @@ class PluginAutoUpgradeService:
@staticmethod
def exclude_plugin(tenant_id: str, plugin_id: str) -> bool:
with session_factory.create_session() as session:
with session_factory.create_session() as session, session.begin():
exist_strategy = session.scalar(
select(TenantPluginAutoUpgradeStrategy)
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)

80
api/uv.lock generated
View File

@ -469,14 +469,14 @@ wheels = [
[[package]]
name = "basedpyright"
version = "1.39.0"
version = "1.39.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/f4/4a77cc1ffb3dab7391642cde30163961d8ee973e9e6b6740c7d15aa3d3ba/basedpyright-1.39.0.tar.gz", hash = "sha256:6666f51c378c7ac45877c4c1c7041ee0b5b83d755ebc82f898f47b6fafe0cc4f", size = 25357403, upload-time = "2026-04-01T12:27:41.92Z" }
sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/47/08145d1bcc3083ed20059bdecbde404bd767f91b91e2764ec01cffec9f4b/basedpyright-1.39.0-py3-none-any.whl", hash = "sha256:91b8ad50bc85ee4a985b928f9368c35c99eee5a56c44e99b2442fa12ecc3d670", size = 12353868, upload-time = "2026-04-01T12:27:38.495Z" },
{ url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" },
]
[[package]]
@ -618,15 +618,15 @@ wheels = [
[[package]]
name = "boto3-stubs"
version = "1.42.88"
version = "1.42.92"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore-stubs" },
{ name = "types-s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c7/d4dfbb4757cd72fd350ba666902ec3ac19e04d6be639e96cdad4543d4726/boto3_stubs-1.42.88.tar.gz", hash = "sha256:85215fb4938a94d1cf83cd8632f46ae7728b5ec88187d83468f393bbe64236d6", size = 102495, upload-time = "2026-04-10T19:55:57.526Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/b4/7f472d64a89f6aa6b8e8eeadc876667b7e4edfb526c6118efe2b2c98ba17/boto3_stubs-1.42.92.tar.gz", hash = "sha256:4bc934069c5e8c7b3cdd2442569dae14e8272fe207d445bd38aa578b8463638f", size = 102696, upload-time = "2026-04-20T19:55:19.858Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/6f/3befd72080aedbb4ad26b353a6e364645668664930ce49668fd0bab8f2b5/boto3_stubs-1.42.88-py3-none-any.whl", hash = "sha256:9e74350715ca8ccd63fc250f8eca9fa3161b3d1704339554344d72e4e21c5ed1", size = 70603, upload-time = "2026-04-10T19:55:49.921Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ce/2fe2c6456f8dc0b8bb8d80e05e154c7975ec058991bedf54f3aeed634b79/boto3_stubs-1.42.92-py3-none-any.whl", hash = "sha256:b3994e60f0133b2dd3d9a88ceaeef48fa6367d9a9429426e919575768a1ad9c6", size = 70666, upload-time = "2026-04-20T19:55:16.398Z" },
]
[package.optional-dependencies]
@ -1616,13 +1616,13 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.39.0" },
{ name = "boto3-stubs", specifier = ">=1.42.88" },
{ name = "basedpyright", specifier = ">=1.39.3" },
{ name = "boto3-stubs", specifier = ">=1.42.92" },
{ name = "celery-types", specifier = ">=0.23.0" },
{ name = "coverage", specifier = ">=7.13.4" },
{ name = "dotenv-linter", specifier = ">=0.7.0" },
{ name = "faker", specifier = ">=20.1.0" },
{ name = "hypothesis", specifier = ">=6.151.12" },
{ name = "faker", specifier = ">=40.15.0" },
{ name = "hypothesis", specifier = ">=6.152.1" },
{ name = "import-linter", specifier = ">=2.3" },
{ name = "lxml-stubs", specifier = ">=0.5.1" },
{ name = "mypy", specifier = ">=1.20.1" },
@ -1635,8 +1635,8 @@ dev = [
{ name = "pytest-mock", specifier = ">=3.15.1" },
{ name = "pytest-timeout", specifier = ">=2.4.0" },
{ name = "pytest-xdist", specifier = ">=3.8.0" },
{ name = "ruff", specifier = ">=0.15.10" },
{ name = "scipy-stubs", specifier = ">=1.15.3.0" },
{ name = "ruff", specifier = ">=0.15.11" },
{ name = "scipy-stubs", specifier = ">=1.17.1.4" },
{ name = "testcontainers", specifier = ">=4.14.2" },
{ name = "types-aiofiles", specifier = ">=25.1.0" },
{ name = "types-beautifulsoup4", specifier = ">=4.12.0" },
@ -2351,14 +2351,14 @@ wheels = [
[[package]]
name = "faker"
version = "40.13.0"
version = "40.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/95/4822ffe94723553789aef783104f4f18fc20d7c4c68e1bbd633e11d09758/faker-40.13.0.tar.gz", hash = "sha256:a0751c84c3abac17327d7bb4c98e8afe70ebf7821e01dd7d0b15cd8856415525", size = 1962043, upload-time = "2026-04-06T16:44:55.68Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/13/6741787bd91c4109c7bed047d68273965cd52ce8a5f773c471b949334b6d/faker-40.15.0.tar.gz", hash = "sha256:20f3a6ec8c266b74d4c554e34118b21c3c2056c0b4a519d15c8decb3a4e6e795", size = 1967447, upload-time = "2026-04-17T20:05:27.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/8a/708103325edff16a0b0e004de0d37db8ba216a32713948c64d71f6d4a4c2/faker-40.13.0-py3-none-any.whl", hash = "sha256:c1298fd0d819b3688fb5fd358c4ba8f56c7c8c740b411fd3dbd8e30bf2c05019", size = 1994597, upload-time = "2026-04-06T16:44:53.698Z" },
{ url = "https://files.pythonhosted.org/packages/a7/a7/a600f8f30d4505e89166de51dd121bd540ab8e560e8cf0901de00a81de8c/faker-40.15.0-py3-none-any.whl", hash = "sha256:71ab3c3370da9d2205ab74ffb0fd51273063ad562b3a3bb69d0026a20923e318", size = 2004447, upload-time = "2026-04-17T20:05:25.437Z" },
]
[[package]]
@ -3317,14 +3317,14 @@ wheels = [
[[package]]
name = "hypothesis"
version = "6.151.12"
version = "6.152.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/ab/67ca321d1ab96fd3828b12142f1c258e2d4a668a025d06cd50ab3409787f/hypothesis-6.151.12.tar.gz", hash = "sha256:be485f503979af4c3dfa19e3fc2b967d0458e7f8c4e28128d7e215e0a55102e0", size = 463900, upload-time = "2026-04-08T19:40:06.205Z" }
sdist = { url = "https://files.pythonhosted.org/packages/64/b1/c32bcddb9aab9e3abc700f1f56faf14e7655c64a16ca47701a57362276ea/hypothesis-6.152.1.tar.gz", hash = "sha256:4f4ed934eee295dd84ee97592477d23e8dc03e9f12ae0ee30a4e7c9ef3fca3b0", size = 465029, upload-time = "2026-04-14T22:29:24.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/5a/6cecf134b631050a1f8605096adbe812483b60790d951470989d39b56860/hypothesis-6.151.12-py3-none-any.whl", hash = "sha256:37d4f3a768365c30571b11dfd7a6857a12173d933010b2c4ab65619f1b5952c5", size = 529656, upload-time = "2026-04-08T19:40:03.126Z" },
{ url = "https://files.pythonhosted.org/packages/5d/83/860fb3075e00b0fc19a22a2301bc3c96f00437558c3911bdd0a3573a4a53/hypothesis-6.152.1-py3-none-any.whl", hash = "sha256:40a3619d9e0cb97b018857c7986f75cf5de2e5ec0fa8a0b172d00747758f749e", size = 530752, upload-time = "2026-04-14T22:29:20.893Z" },
]
[[package]]
@ -5887,27 +5887,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.10"
version = "0.15.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
]
[[package]]
@ -5946,14 +5946,14 @@ wheels = [
[[package]]
name = "scipy-stubs"
version = "1.17.1.3"
version = "1.17.1.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "optype", extra = ["numpy"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/59/59c6cc3f9970154b9ed6b1aff42a0185cdd60cef54adc0404b9e77972221/scipy_stubs-1.17.1.3.tar.gz", hash = "sha256:5eb87a8d23d726706259b012ebe76a4a96a9ae9e141fc59bf55fc8eac2ed9e0f", size = 392185, upload-time = "2026-03-22T22:11:58.34Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/75/d944a11fca64aa84fbb4bfcf613b758319c6103cb30a304a0e9727009d62/scipy_stubs-1.17.1.4.tar.gz", hash = "sha256:cae00c5207aa62ceb4bcadea202d9fbbf002e958f9e4de981720436b8d5c1802", size = 396980, upload-time = "2026-04-13T11:46:54.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/d4/94304532c0a75a55526119043dd44a9bd1541a21e14483cbb54261c527d2/scipy_stubs-1.17.1.3-py3-none-any.whl", hash = "sha256:7b91d3f05aa47da06fbca14eb6c5bb4c28994e9245fd250cc847e375bab31297", size = 597933, upload-time = "2026-03-22T22:11:56.525Z" },
{ url = "https://files.pythonhosted.org/packages/92/f8/334aa5a7a482ea89cb14d92f6a4d9ffa1e193e733144d4d14c7ffcb33583/scipy_stubs-1.17.1.4-py3-none-any.whl", hash = "sha256:e6e5c390fb864745bc3d5f591de81f5cb4f84403857d4f660acb5b6339956f5b", size = 604752, upload-time = "2026-04-13T11:46:53.135Z" },
]
[[package]]

View File

@ -488,11 +488,6 @@
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3060,14 +3055,6 @@
"count": 3
}
},
"web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/header/account-setting/model-provider-page/declarations.ts": {
"erasable-syntax-only/enums": {
"count": 11
@ -3554,11 +3541,6 @@
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx": {
"no-restricted-imports": {
"count": 1
@ -3672,11 +3654,6 @@
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -3933,11 +3910,6 @@
"count": 1
}
},
"web/app/components/tools/labels/selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/mcp/create-card.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -4123,11 +4095,6 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/tool-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -4313,14 +4280,6 @@
"count": 2
}
},
"web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx": {
"no-restricted-imports": {
"count": 3
},
"ts/no-explicit-any": {
"count": 4
}
},
"web/app/components/workflow/nodes/_base/components/agent-strategy.tsx": {
"ts/no-empty-object-type": {
"count": 1
@ -4547,22 +4506,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
},
"web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/var-type-picker.tsx": {
"no-restricted-imports": {
"count": 1
@ -4742,11 +4685,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/code/dependency-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/code/types.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -4897,16 +4835,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx": {
"no-restricted-imports": {
"count": 1
@ -4957,11 +4885,6 @@
"count": 2
}
},
"web/app/components/workflow/nodes/if-else/components/condition-add.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-input.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -4977,11 +4900,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx": {
"no-restricted-imports": {
"count": 1
@ -5085,16 +5003,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/add-condition.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-common-variable-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -5110,11 +5018,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -5294,11 +5197,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-add.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -5314,11 +5212,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-number-input.tsx": {
"no-restricted-imports": {
"count": 1
@ -6095,14 +5988,6 @@
"count": 5
}
},
"web/app/education-apply/search-input.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/education-apply/verify-state-modal.tsx": {
"react/set-state-in-effect": {
"count": 1

410
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -42,15 +42,15 @@ overrides:
svgo@>=3.0.0 <3.3.3: 3.3.3
tar@<=7.5.10: 7.5.11
undici@>=7.0.0 <7.24.0: 7.24.0
vite: npm:@voidzero-dev/vite-plus-core@0.1.18
vitest: npm:@voidzero-dev/vite-plus-test@0.1.18
vite: npm:@voidzero-dev/vite-plus-core@0.1.19
vitest: npm:@voidzero-dev/vite-plus-test@0.1.19
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
catalog:
'@amplitude/analytics-browser': 2.39.0
'@amplitude/plugin-session-replay-browser': 1.27.7
'@antfu/eslint-config': 8.2.0
'@base-ui/react': 1.4.0
'@base-ui/react': 1.4.1
'@chromatic-com/storybook': 5.1.2
'@cucumber/cucumber': 12.8.0
'@egoist/tailwindcss-icons': 1.9.2
@ -222,10 +222,10 @@ catalog:
use-context-selector: 2.0.0
uuid: 13.0.0
vinext: 0.0.41
vite: npm:@voidzero-dev/vite-plus-core@0.1.18
vite: npm:@voidzero-dev/vite-plus-core@0.1.19
vite-plugin-inspect: 12.0.0-beta.1
vite-plus: 0.1.18
vitest: npm:@voidzero-dev/vite-plus-test@0.1.18
vite-plus: 0.1.19
vitest: npm:@voidzero-dev/vite-plus-test@0.1.19
vitest-browser-react: 2.2.0
vitest-canvas-mock: 1.1.4
zod: 4.3.6

View File

@ -4,6 +4,7 @@ export default defineConfig({
pack: {
entry: ["src/index.ts"],
format: ["esm"],
platform: "node",
dts: true,
clean: true,
sourcemap: true,

View File

@ -10,6 +10,72 @@ vi.mock('@/next/navigation', () => ({
usePathname: () => '/test',
}))
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div
data-testid="popover-trigger"
onClick={() => setOpen(!open)}
>
{render}
</div>
)
}
const PopoverContent = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => {
const { open } = React.useContext(PopoverContext)
if (!open)
return null
return (
<div data-testid="popover-content" {...props}>
{children}
</div>
)
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
type PortalToFollowElemProps = {
children: React.ReactNode
open?: boolean
@ -209,20 +275,17 @@ describe('ContextVar', () => {
// Act
render(<ContextVar {...props} />)
const triggers = screen.getAllByTestId('portal-trigger')
const varPickerTrigger = triggers[triggers.length - 1]
const varPickerTrigger = screen.getByTestId('popover-trigger')
await user.click(varPickerTrigger!)
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
// Select a different option
const options = screen.getAllByText('var2')
expect(options.length).toBeGreaterThan(0)
await user.click(options[0]!)
await user.click(screen.getByText('var2'))
// Assert
expect(onChange).toHaveBeenCalledWith('var2')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
it('should toggle dropdown when clicking the trigger button', async () => {
@ -233,16 +296,15 @@ describe('ContextVar', () => {
// Act
render(<ContextVar {...props} />)
const triggers = screen.getAllByTestId('portal-trigger')
const varPickerTrigger = triggers[triggers.length - 1]
const varPickerTrigger = screen.getByTestId('popover-trigger')
// Open dropdown
await user.click(varPickerTrigger!)
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
// Close dropdown
await user.click(varPickerTrigger!)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
})

View File

@ -18,18 +18,21 @@ type PortalToFollowElemProps = {
type PortalToFollowElemTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode, asChild?: boolean }
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const PortalContext = React.createContext({ open: false })
vi.mock('@langgenius/dify-ui/popover', () => {
const PortalContext = React.createContext({
open: false,
onOpenChange: undefined as ((open: boolean) => void) | undefined,
})
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
const Popover = ({ children, open, onOpenChange }: PortalToFollowElemProps) => {
return (
<PortalContext.Provider value={{ open: !!open }}>
<PortalContext.Provider value={{ open: !!open, onOpenChange }}>
<div data-testid="portal">{children}</div>
</PortalContext.Provider>
)
}
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
const PopoverContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
const { open } = React.useContext(PortalContext)
if (!open)
return null
@ -40,24 +43,41 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => {
)
}
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
const PopoverTrigger = ({ children, asChild, render, ...props }: PortalToFollowElemTriggerProps & { render?: React.ReactNode }) => {
const { open, onOpenChange } = React.useContext(PortalContext)
const content = render ?? children
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
props.onClick?.(e)
if (!props.onClick)
onOpenChange?.(!open)
}
if (React.isValidElement(content)) {
return React.cloneElement(content, {
...props,
'onClick': handleClick,
'data-testid': 'portal-trigger',
} as React.HTMLAttributes<HTMLElement>)
}
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
...props,
'onClick': handleClick,
'data-testid': 'portal-trigger',
} as React.HTMLAttributes<HTMLElement>)
}
return (
<div data-testid="portal-trigger" {...props}>
{children}
<div data-testid="portal-trigger" {...props} onClick={handleClick}>
{content}
</div>
)
}
return {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
Popover,
PopoverContent,
PopoverTrigger,
}
})

View File

@ -3,15 +3,15 @@ import type { FC } from 'react'
import type { IInputTypeIconProps } from '@/app/components/app/configuration/config-var/input-type-icon'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import IconTypeIcon from '@/app/components/app/configuration/config-var/input-type-icon'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type Option = { name: string, value: string, type: string }
export type Props = {
@ -33,6 +33,7 @@ const VarItem: FC<{ item: Option }> = ({ item }) => (
</div>
</div>
)
const VarPicker: FC<Props> = ({
triggerClassName,
className,
@ -45,47 +46,51 @@ const VarPicker: FC<Props> = ({
const [open, setOpen] = useState(false)
const currItem = options.find(item => item.value === value)
const notSetVar = !currItem
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 8,
}}
>
<PortalToFollowElemTrigger className={cn(triggerClassName)} onClick={() => setOpen(v => !v)}>
<div className={cn(
className,
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
`
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
font-medium shadow-xs
`,
)}
>
<div>
{value
? (
<VarItem item={currItem as Option} />
)
: (
<div>
{notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })}
</div>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
nativeButton={false}
render={(
<div className={cn(triggerClassName)}>
<div className={cn(
className,
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : 'border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
`
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
font-medium shadow-xs
`,
)}
>
<div>
{currItem
? (
<VarItem item={currItem} />
)
: (
<div>
{notSelectedVarTip || t('feature.dataSet.queryVariable.choosePlaceholder', { ns: 'appDebug' })}
</div>
)}
</div>
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'h-3.5 w-3.5')} />
</div>
</div>
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'h-3.5 w-3.5')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={8}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
{options.length > 0
? (
<div className="max-h-[50vh] w-[240px] overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
{options.map(({ name, value, type }, index) => (
{options.map(({ name, value, type }) => (
<div
key={index}
key={value}
className="flex cursor-pointer rounded-lg px-3 py-1 hover:bg-state-base-hover"
onClick={() => {
onChange(value)
@ -103,9 +108,9 @@ const VarPicker: FC<Props> = ({
<div className="text-xs leading-normal text-text-tertiary">{t('feature.dataSet.queryVariable.noVarTip', { ns: 'appDebug' })}</div>
</div>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
export default React.memo(VarPicker)

View File

@ -423,11 +423,11 @@ describe('prompt-editor/hooks', () => {
maxLength: 5,
}))
const match = result.current('prefix @..', {} as LexicalEditor)
const match = result.current('prefix @ab', {} as LexicalEditor)
expect(match).toEqual({
leadOffset: 7,
matchingString: '..',
replaceableString: '@..',
matchingString: 'ab',
replaceableString: '@ab',
})
})
@ -437,7 +437,7 @@ describe('prompt-editor/hooks', () => {
maxLength: 5,
}))
expect(result.current('prefix @.', {} as LexicalEditor)).toBeNull()
expect(result.current('prefix @a', {} as LexicalEditor)).toBeNull()
})
it('should return null when matching text exceeds maxLength', () => {
@ -445,7 +445,7 @@ describe('prompt-editor/hooks', () => {
minLength: 1,
maxLength: 2,
}))
expect(result.current('prefix @...', {} as LexicalEditor)).toBeNull()
expect(result.current('prefix @abc', {} as LexicalEditor)).toBeNull()
})
it('should return null when text has no trigger character', () => {

View File

@ -154,17 +154,18 @@ type TriggerFn = (
text: string,
editor: LexicalEditor,
) => MenuTextMatch | null
const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
const escapeForCharacterClass = (value: string) => value.replace(/[[\]\\^-]/g, '\\$&')
export function useBasicTypeaheadTriggerMatch(
trigger: string,
{ minLength = 1, maxLength = 75 }: { minLength?: number, maxLength?: number },
): TriggerFn {
return useCallback(
(text: string) => {
const validChars = `[${PUNCTUATION}\\s]`
const escapedTrigger = escapeForCharacterClass(trigger)
const validChars = `[^${escapedTrigger}\\n\\r]`
const TypeaheadTriggerRegex = new RegExp(
'(.*)('
+ `[${trigger}]`
+ `[${escapedTrigger}]`
+ `((?:${validChars}){0,${maxLength}})`
+ ')$',
)

View File

@ -521,6 +521,84 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
await waitFor(() => expect(readEditorText(editor)).not.toContain('{'))
})
it('filters workflow variables from slash input and matches child paths', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const user = userEvent.setup()
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
makeWorkflowVarNode('node-1', 'Node 1', [
makeWorkflowNodeVar('payload', VarType.object, [makeWorkflowNodeVar('child_name', VarType.string)]),
makeWorkflowNodeVar('other_value', VarType.string),
]),
])
render((
<MinimalEditor
triggerString="/"
contextBlock={makeContextBlock()}
workflowVariableBlock={workflowVariableBlock}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
const dispatchSpy = vi.spyOn(editor, 'dispatchCommand')
await setEditorText(editor, '/child', true)
await flushNextTick()
expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument()
expect(await screen.findByText('payload')).toBeInTheDocument()
expect(screen.queryByText('other_value')).not.toBeInTheDocument()
const label = document.querySelector('[title="payload"]')
expect(label).not.toBeNull()
const row = (label as HTMLElement).parentElement?.parentElement
expect(row).not.toBeNull()
await user.hover(row as HTMLElement)
const childField = await screen.findByText('child_name')
fireEvent.mouseDown(childField)
await user.unhover(row as HTMLElement)
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'payload', 'child_name'])
await waitFor(() => expect(readEditorText(editor)).not.toContain('/child'))
})
it('filters workflow variables on the first character after slash and does not highlight context by default', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
makeWorkflowVarNode('node-1', 'Node 1', [
makeWorkflowNodeVar('child_value', VarType.string),
makeWorkflowNodeVar('other_value', VarType.string),
]),
])
render((
<MinimalEditor
triggerString="/"
contextBlock={makeContextBlock()}
workflowVariableBlock={workflowVariableBlock}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '/c', true)
await flushNextTick()
expect(await screen.findByText('child_value')).toBeInTheDocument()
expect(screen.queryByText('other_value')).not.toBeInTheDocument()
const contextTitle = screen.getByText('common.promptEditor.context.item.title')
expect(contextTitle.closest('[tabindex="-1"]')).not.toHaveClass('bg-state-base-hover!')
await waitFor(() => {
expect(readEditorText(editor)).toContain('/c')
})
})
it('skips removing the trigger when selection is null (needRemove is null) and still dispatches', async () => {
const captures: Captures = { editor: null, eventEmitter: null }

View File

@ -1,5 +1,5 @@
import type { MenuRenderFn } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import type { TextNode } from 'lexical'
import type { LexicalEditor, TextNode } from 'lexical'
import type {
ContextBlockType,
CurrentBlockType,
@ -89,10 +89,16 @@ const ComponentPicker = ({
],
})
const [editor] = useLexicalComposerContext()
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
const triggerMatchRef = useRef<string | null>(null)
const baseCheckForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
minLength: 0,
maxLength: 0,
maxLength: 75,
})
const checkForTriggerMatch = useCallback((text: string, editor: LexicalEditor) => {
const match = baseCheckForTriggerMatch(text, editor)
triggerMatchRef.current = match?.matchingString ?? null
return match
}, [baseCheckForTriggerMatch])
const [queryString, setQueryString] = useState<string | null>(null)
const [blurHidden, setBlurHidden] = useState(false)
@ -155,6 +161,7 @@ const ComponentPicker = ({
currentBlock,
errorMessageBlock,
lastRunBlock,
queryString || undefined,
)
const onSelectOption = useCallback(
@ -207,6 +214,8 @@ const ComponentPicker = ({
anchorElementRef,
{ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) => {
const effectiveQueryString = triggerMatchRef.current ?? queryString
if (blurHidden)
return null
if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
@ -237,6 +246,8 @@ const ComponentPicker = ({
workflowVariableBlock?.show && (
<div className="p-1">
<VarReferenceVars
hideSearch={triggerString === '/'}
searchText={triggerString === '/' ? (effectiveQueryString || '') : undefined}
searchBoxClassName="mt-1"
vars={workflowVariableOptions}
onChange={(variables: string[]) => {
@ -270,8 +281,8 @@ const ComponentPicker = ({
)
}
{option.renderMenuOption({
queryString,
isSelected: selectedIndex === index,
queryString: effectiveQueryString,
isSelected: workflowVariableBlock?.show ? false : selectedIndex === index,
onSelect: () => {
selectOptionAndCleanUp(option)
},

View File

@ -2,16 +2,20 @@
import type { FC } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { useMembers } from '@/service/use-common'
type Props = {
value?: any
onSelect: (value: any) => void
value?: string
onSelect: (value: string) => void
exclude?: string[]
}
@ -27,12 +31,9 @@ const MemberSelector: FC<Props> = ({
const { data } = useMembers()
const currentValue = useMemo(() => {
if (!data?.accounts)
if (!data?.accounts || !value)
return null
const accounts = data.accounts || []
if (!value)
return null
return accounts.find(account => account.id === value)
return data.accounts.find(account => account.id === value) ?? null
}, [data, value])
const filteredList = useMemo(() => {
@ -47,37 +48,36 @@ const MemberSelector: FC<Props> = ({
return name.toLowerCase().includes(searchValue.toLowerCase())
|| email.toLowerCase().includes(searchValue.toLowerCase())
}).filter(account => !exclude.includes(account.id))
}, [data, searchValue, exclude])
}, [data, exclude, searchValue])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom"
offset={4}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={() => setOpen(v => !v)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<div
data-testid="member-selector-trigger"
className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}
>
{!currentValue && (
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">{t('members.transferModal.transferPlaceholder', { ns: 'common' })}</div>
)}
{currentValue && (
<>
<Avatar avatar={currentValue.avatar_url} size="sm" name={currentValue.name} />
<div className="grow truncate system-sm-medium text-text-secondary">{currentValue.name}</div>
<div className="system-xs-regular text-text-quaternary">{currentValue.email}</div>
</>
)}
<div className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
</div>
)}
/>
<PopoverContent
placement="bottom"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1002 } }}
>
<div
data-testid="member-selector-trigger"
className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}
>
{!currentValue && (
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">{t('members.transferModal.transferPlaceholder', { ns: 'common' })}</div>
)}
{currentValue && (
<>
<Avatar avatar={currentValue.avatar_url} size="sm" name={currentValue.name} />
<div className="grow truncate system-sm-medium text-text-secondary">{currentValue.name}</div>
<div className="system-xs-regular text-text-quaternary">{currentValue.email}</div>
</>
)}
<div className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className="min-w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<Input
@ -105,8 +105,9 @@ const MemberSelector: FC<Props> = ({
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
export default MemberSelector

View File

@ -4,6 +4,59 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { SubscriptionSelectorEntry } from '../selector-entry'
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
const PopoverContent = ({ children }: { children: React.ReactNode }) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="popover-content">{children}</div> : null
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
let mockSubscriptions: TriggerSubscription[] = []
const mockRefetch = vi.fn()
@ -92,6 +145,6 @@ describe('SubscriptionSelectorEntry', () => {
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'sub-1', name: 'Subscription One' }), expect.any(Function))
expect(screen.queryByText('Subscription One')).not.toBeInTheDocument()
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
})

View File

@ -1,28 +1,26 @@
'use client'
import type { SimpleSubscription } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine, RiWebhookLine } from '@remixicon/react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { SubscriptionList } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
import { SubscriptionListMode } from './types'
import { useSubscriptionList } from './use-subscription-list'
type SubscriptionTriggerButtonProps = {
selectedId?: string
onClick?: () => void
isOpen?: boolean
className?: string
}
const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({
selectedId,
onClick,
isOpen = false,
className,
}) => {
@ -44,7 +42,7 @@ const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({
}
if (subscriptions && subscriptions.length > 0) {
const selectedSubscription = subscriptions?.find(sub => sub.id === selectedId)
const selectedSubscription = subscriptions.find(sub => sub.id === selectedId)
if (!selectedSubscription) {
return {
@ -67,13 +65,13 @@ const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({
return (
<button
type="button"
className={cn(
'flex h-8 items-center gap-1 rounded-lg px-2 transition-colors',
'hover:bg-state-base-hover-alt',
isOpen && 'bg-state-base-hover-alt',
className,
)}
onClick={onClick}
>
<RiWebhookLine className={cn('h-3.5 w-3.5 shrink-0 text-text-secondary', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')} />
<span className={cn('truncate system-xs-medium text-components-button-ghost-text', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')}>
@ -97,22 +95,23 @@ export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: {
const [isOpen, setIsOpen] = useState(false)
return (
<PortalToFollowElem
placement="bottom-start"
offset={4}
open={isOpen}
onOpenChange={setIsOpen}
>
<PortalToFollowElemTrigger asChild>
<div>
<SubscriptionTriggerButton
selectedId={selectedId}
onClick={() => setIsOpen(!isOpen)}
isOpen={isOpen}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger
render={(
<div>
<SubscriptionTriggerButton
selectedId={selectedId}
isOpen={isOpen}
/>
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 11 } }}
>
<div className="rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg">
<SubscriptionList
mode={SubscriptionListMode.SELECTOR}
@ -123,7 +122,7 @@ export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: {
}}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -61,6 +61,9 @@ const setupMocks = (plugins: PluginStatus[] = []) => {
return { mockMutateAsync, mockHandleRefetch }
}
const getTaskMenuTrigger = () =>
document.getElementById('plugin-task-trigger')!.closest('[role="button"]') as HTMLElement
describe('usePluginTaskStatus Hook', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -637,7 +640,7 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
// Click to open
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
// The popover content should be visible (PluginTaskList)
// The popover content should be visible (PluginTaskList)
@ -666,7 +669,7 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
// Wait for popover content to render
await waitFor(() => {
@ -692,7 +695,7 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
@ -713,16 +716,14 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]'))!.toBeInTheDocument()
})
// Find and click the clear all button in error section
const clearButtons = screen.getAllByRole('button')
if (clearButtons.length > 0)
fireEvent.click(clearButtons[0]!)
fireEvent.click(screen.getByRole('button', { name: /task\.clearAll/i }))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
@ -741,7 +742,7 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
await waitFor(() => {
expect(document.querySelector('.w-\\[360px\\]'))!.toBeInTheDocument()
@ -813,7 +814,7 @@ describe('PluginTasks Component', () => {
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
expect(document.querySelector('.w-\\[360px\\]'))!.toBeInTheDocument()
})
@ -825,7 +826,7 @@ describe('PluginTasks Component', () => {
])
render(<PluginTasks />)
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
@ -837,7 +838,7 @@ describe('PluginTasks Component', () => {
])
render(<PluginTasks />)
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
})
@ -892,7 +893,7 @@ describe('PluginTasks Integration', () => {
render(<PluginTasks />)
// Open popover
fireEvent.click(document.getElementById('plugin-task-trigger')!)
fireEvent.click(getTaskMenuTrigger())
// All sections should be visible
const sections = document.querySelectorAll('.max-h-\\[300px\\]')

View File

@ -97,6 +97,7 @@ const PluginTasks = () => {
onOpenChange={setOpen}
>
<DropdownMenuTrigger
nativeButton={false}
render={<div />}
disabled={!canOpenMenu}
>

View File

@ -61,35 +61,78 @@ vi.mock('@/service/use-plugins', () => ({
}),
}))
// Mock portal component for ToolPicker and StrategyPicker
// Mock popover component for ToolPicker and StrategyPicker
let mockPortalOpen = false
let forcePortalContentVisible = false // Allow tests to force content visibility
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: {
let mockPortalOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({ children, open = false, onOpenChange }: {
children: React.ReactNode
open: boolean
onOpenChange: (open: boolean) => void
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
mockPortalOpen = open
mockPortalOnOpenChange = onOpenChange
return (
<div data-testid="portal-elem" data-open={open}>{children}</div>
)
},
PopoverTrigger: ({ children, render, onClick, className }: {
children?: React.ReactNode
render?: React.ReactNode
onClick?: (e: React.MouseEvent) => void
className?: string
}) => (
<div
data-testid="portal-trigger"
onClick={(e) => {
onClick?.(e)
if (!onClick)
mockPortalOnOpenChange?.(!mockPortalOpen)
}}
className={className}
>
{render ?? children}
</div>
),
PopoverContent: ({ children, className, popupClassName }: {
children: React.ReactNode
className?: string
popupClassName?: string
}) => {
if (!mockPortalOpen && !forcePortalContentVisible)
return null
return <div data-testid="portal-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div>
},
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open = false, onOpenChange }: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
mockPortalOpen = open
mockPortalOnOpenChange = onOpenChange
return <div data-testid="portal-elem" data-open={open}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick, className }: {
children: React.ReactNode
onClick: (e: React.MouseEvent) => void
children?: React.ReactNode
onClick?: (e: React.MouseEvent) => void
className?: string
}) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: {
PortalToFollowElemContent: ({ children, className, popupClassName }: {
children: React.ReactNode
className?: string
popupClassName?: string
}) => {
// Allow forcing content visibility for testing option selection
if (!mockPortalOpen && !forcePortalContentVisible)
return null
return <div data-testid="portal-content" className={className}>{children}</div>
return <div data-testid="portal-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div>
},
}))
@ -319,6 +362,7 @@ describe('auto-update-setting', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpen = false
mockPortalOnOpenChange = undefined
forcePortalContentVisible = false
mockPluginsData.plugins = []
})

View File

@ -4,8 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '@/app/components/plugins/types'
import ToolPicker from '../tool-picker'
let portalOpen = false
const mockInstalledPluginList = vi.hoisted(() => ({
data: {
plugins: [] as PluginDetail[],
@ -21,33 +19,51 @@ vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const _React = await import('react')
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => (
<PopoverContext.Provider value={{ open: !!open, setOpen: (nextOpen: boolean) => onOpenChange?.(nextOpen) }}>
{children}
</PopoverContext.Provider>
)
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
const PopoverContent = ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="popover-content" className={className}>{children}</div> : null
}
return {
PortalToFollowElem: ({
open,
children,
}: {
open: boolean
children: React.ReactNode
}) => {
portalOpen = open
return <div>{children}</div>
},
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => <button data-testid="trigger" onClick={onClick}>{children}</button>,
PortalToFollowElemContent: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => portalOpen ? <div data-testid="portal-content" className={className}>{children}</div> : null,
Popover,
PopoverTrigger,
PopoverContent,
}
})
@ -118,7 +134,6 @@ const createPlugin = (
describe('ToolPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpen = false
mockInstalledPluginList.data = {
plugins: [],
}
@ -137,7 +152,7 @@ describe('ToolPicker', () => {
/>,
)
fireEvent.click(screen.getByTestId('trigger'))
fireEvent.click(screen.getByText('trigger'))
expect(onShowChange).toHaveBeenCalledWith(true)
})

View File

@ -2,15 +2,15 @@
import type { FC } from 'react'
import type { ActivePluginType } from '../../marketplace/constants'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import { useInstalledPluginList } from '@/service/use-plugins'
import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/constants'
@ -24,7 +24,6 @@ type Props = {
onChange: (value: string[]) => void
isShow: boolean
onShowChange: (isShow: boolean) => void
}
const ToolPicker: FC<Props> = ({
@ -35,43 +34,16 @@ const ToolPicker: FC<Props> = ({
onShowChange,
}) => {
const { t } = useTranslation()
const toggleShowPopup = useCallback(() => {
onShowChange(!isShow)
}, [onShowChange, isShow])
const tabs = [
{
key: PLUGIN_TYPE_SEARCH_MAP.all,
name: t('category.all', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.model,
name: t('category.models', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.tool,
name: t('category.tools', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.agent,
name: t('category.agents', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.extension,
name: t('category.extensions', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.datasource,
name: t('category.datasources', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.trigger,
name: t('category.triggers', { ns: 'plugin' }),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.bundle,
name: t('category.bundles', { ns: 'plugin' }),
},
{ key: PLUGIN_TYPE_SEARCH_MAP.all, name: t('category.all', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.model, name: t('category.models', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.tool, name: t('category.tools', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.agent, name: t('category.agents', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.extension, name: t('category.extensions', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.datasource, name: t('category.datasources', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.trigger, name: t('category.triggers', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.bundle, name: t('category.bundles', { ns: 'plugin' }) },
]
const [pluginType, setPluginType] = useState<ActivePluginType>(PLUGIN_TYPE_SEARCH_MAP.all)
@ -89,14 +61,13 @@ const ToolPicker: FC<Props> = ({
)
})
}, [data, pluginType, query, tags])
const handleCheckChange = useCallback((pluginId: string) => {
return () => {
const newValue = value.includes(pluginId)
? value.filter(id => id !== pluginId)
: [...value, pluginId]
onChange(newValue)
}
}, [onChange, value])
const handleCheckChange = (pluginId: string) => {
const newValue = value.includes(pluginId)
? value.filter(id => id !== pluginId)
: [...value, pluginId]
onChange(newValue)
}
const listContent = (
<div className="max-h-[396px] overflow-y-auto">
@ -105,7 +76,7 @@ const ToolPicker: FC<Props> = ({
key={item.plugin_id}
payload={item}
isChecked={value.includes(item.plugin_id)}
onCheckChange={handleCheckChange(item.plugin_id)}
onCheckChange={() => handleCheckChange(item.plugin_id)}
/>
))}
</div>
@ -121,21 +92,18 @@ const ToolPicker: FC<Props> = ({
<NoDataPlaceholder className="h-[396px]" noPlugins={!query} />
)
const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div>
return (
<PortalToFollowElem
placement="top"
offset={0}
open={isShow}
onOpenChange={onShowChange}
>
<PortalToFollowElemTrigger
className="block w-full"
onClick={toggleShowPopup}
<Popover open={isShow} onOpenChange={onShowChange}>
<PopoverTrigger render={resolvedTrigger} />
<PopoverContent
placement="top"
sideOffset={0}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<div className={cn('relative min-h-20 w-full rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-2 shadow-lg backdrop-blur-xs')}>
<div className="relative min-h-20 w-full rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-2 shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<SearchBox
search={query}
@ -148,29 +116,27 @@ const ToolPicker: FC<Props> = ({
</div>
<div className="flex items-center justify-between border-b-[0.5px] border-divider-subtle bg-background-default-hover px-3 shadow-xs">
<div className="flex h-8 items-center space-x-1">
{
tabs.map(tab => (
<div
className={cn(
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
'text-xs font-medium text-text-secondary',
pluginType === tab.key && 'bg-state-base-hover-alt',
)}
key={tab.key}
onClick={() => setPluginType(tab.key)}
>
{tab.name}
</div>
))
}
{tabs.map(tab => (
<div
className={cn(
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
'text-xs font-medium text-text-secondary',
pluginType === tab.key && 'bg-state-base-hover-alt',
)}
key={tab.key}
onClick={() => setPluginType(tab.key)}
>
{tab.name}
</div>
))}
</div>
</div>
{!isLoading && filteredList.length > 0 && listContent}
{!isLoading && filteredList.length === 0 && noData}
{isLoading && loadingContent}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -2,6 +2,65 @@ import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LabelSelector from '../selector'
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
const PopoverContent = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => {
const { open } = React.useContext(PopoverContext)
if (!open)
return null
return <div {...props}>{children}</div>
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
// Mock useTags hook with controlled test data
const mockTags = [
{ name: 'agent', label: 'Agent' },

View File

@ -1,6 +1,11 @@
import type { FC } from 'react'
import type { Label } from '@/app/components/tools/labels/constant'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/function'
@ -9,17 +14,13 @@ import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import { Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useTags } from '@/app/components/plugins/hooks'
type LabelSelectorProps = {
value: string[]
onChange: (v: string[]) => void
}
const LabelSelector: FC<LabelSelectorProps> = ({
value,
onChange,
@ -34,6 +35,7 @@ const LabelSelector: FC<LabelSelectorProps> = ({
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
@ -55,32 +57,31 @@ const LabelSelector: FC<LabelSelectorProps> = ({
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<Popover open={open} onOpenChange={setOpen}>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 hover:bg-components-input-bg-hover',
open && '!hover:bg-components-input-bg-hover hover:bg-components-input-bg-hover',
<PopoverTrigger
render={(
<div className={cn(
'flex h-10 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3 hover:bg-components-input-bg-hover',
open && '!hover:bg-components-input-bg-hover hover:bg-components-input-bg-hover',
)}
>
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && 'text-text-quaternary!')}>
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
{!!value.length && selectedLabels}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<RiArrowDownSLine className="h-4 w-4" />
</div>
</div>
)}
>
<div title={value.length > 0 ? selectedLabels : ''} className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary', !value.length && 'text-text-quaternary!')}>
{!value.length && t('createTool.toolInput.labelPlaceholder', { ns: 'tools' })}
{!!value.length && selectedLabels}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<RiArrowDownSLine className="h-4 w-4" />
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1040">
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1040 } }}
>
<div className="relative w-[591px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="border-b-[0.5px] border-divider-regular p-2">
<Input
@ -114,9 +115,9 @@ const LabelSelector: FC<LabelSelectorProps> = ({
)}
</div>
</div>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}

View File

@ -231,5 +231,9 @@ describe('CustomEdge', () => {
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-normal)')
expect(screen.getByTestId('block-selector')).toHaveAttribute('data-trigger-class', 'hover:scale-150 transition-all')
expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({
opacity: '0',
pointerEvents: 'none',
})
})
})

View File

@ -455,12 +455,12 @@ describe('ToolPicker', () => {
it('should create a custom collection from the add button and refresh custom tools', async () => {
const user = userEvent.setup()
const { container } = renderToolPicker({
renderToolPicker({
isShow: true,
supportAddCustomTool: true,
})
const addCustomToolButton = Array.from(container.querySelectorAll('button')).find((button) => {
const addCustomToolButton = Array.from(document.querySelectorAll('button')).find((button) => {
return button.className.includes('bg-components-button-primary-bg')
})

View File

@ -8,17 +8,17 @@ import type { ToolDefaultValue, ToolValue } from './types'
import type { CustomCollectionBackend } from '@/app/components/tools/types'
import type { BlockEnum, OnSelectBlock } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import AllTools from '@/app/components/workflow/block-selector/all-tools'
@ -43,7 +43,7 @@ type Props = {
disabled: boolean
trigger: React.ReactNode
placement?: Placement
offset?: OffsetOptions
offset?: OffsetOptions | number
isShow: boolean
onShowChange: (isShow: boolean) => void
onSelect: (tool: ToolDefaultValue) => void
@ -120,12 +120,6 @@ const ToolPicker: FC<Props> = ({
const handleAddedCustomTool = invalidateCustomTools
const handleTriggerClick = () => {
if (disabled)
return
onShowChange(true)
}
const handleSelect = (_type: BlockEnum, tool?: ToolDefaultValue) => {
onSelect(tool!)
}
@ -139,6 +133,11 @@ const ToolPicker: FC<Props> = ({
setTrue: showEditCustomCollectionModal,
}] = useBoolean(false)
const handleShowAddCustomCollectionModal = useCallback(() => {
onShowChange(false)
showEditCustomCollectionModal()
}, [onShowChange, showEditCustomCollectionModal])
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
toast.success(t('api.actionSuccess', { ns: 'common' }))
@ -157,20 +156,35 @@ const ToolPicker: FC<Props> = ({
)
}
return (
<PortalToFollowElem
placement={placement}
offset={offset}
open={isShow}
onOpenChange={onShowChange}
>
<PortalToFollowElemTrigger
onClick={handleTriggerClick}
>
{trigger}
</PortalToFollowElemTrigger>
const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div>
const resolvedOffset = typeof offset === 'object' && offset !== null
? offset as { mainAxis?: number, crossAxis?: number, alignmentAxis?: number | null }
: undefined
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
<PortalToFollowElemContent className="z-1002">
return (
<Popover
open={isShow}
onOpenChange={(nextOpen) => {
if (disabled && nextOpen)
return
onShowChange(nextOpen)
}}
>
<PopoverTrigger
render={resolvedTrigger}
onClick={(e) => {
if (disabled)
e.preventDefault()
}}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className={cn('relative min-h-20 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs', panelClassName)}>
<div className="p-2 pb-1">
<SearchBox
@ -181,7 +195,7 @@ const ToolPicker: FC<Props> = ({
placeholder={t('searchTools', { ns: 'plugin' })!}
supportAddCustomTool={supportAddCustomTool}
onAddedCustomTool={handleAddedCustomTool}
onShowAddCustomCollectionModal={showEditCustomCollectionModal}
onShowAddCustomCollectionModal={handleShowAddCustomCollectionModal}
inputClassName="grow"
/>
</div>
@ -209,8 +223,8 @@ const ToolPicker: FC<Props> = ({
}}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -63,6 +63,7 @@ const CustomEdge = ({
_sourceRunningStatus,
_targetRunningStatus,
} = data
const isTriggerVisible = !!(data?._hovering || isTriggerHovered || open)
const linearGradientId = useMemo(() => {
if (
@ -144,16 +145,15 @@ const CustomEdge = ({
<div
className={cn(
'nopan nodrag',
(data?._hovering || isTriggerHovered) ? 'block' : 'hidden',
open && 'block!',
'transition-opacity duration-150',
data.isInIteration && `z-[${ITERATION_CHILDREN_Z_INDEX}]`,
data.isInLoop && `z-[${LOOP_CHILDREN_Z_INDEX}]`,
)}
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
opacity: data._waitingRun ? 0.7 : 1,
pointerEvents: isTriggerVisible ? 'all' : 'none',
opacity: isTriggerVisible ? (data._waitingRun ? 0.7 : 1) : 0,
}}
onMouseEnter={() => setIsTriggerHovered(true)}
onMouseLeave={() => setIsTriggerHovered(false)}

View File

@ -0,0 +1,446 @@
import type { ReactNode } from 'react'
import type { StrategyPluginDetail } from '@/app/components/plugins/types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { AgentStrategySelector } from '../agent-strategy-selector'
const mocks = vi.hoisted(() => ({
useSuspenseQuery: vi.fn(),
useStrategyProviders: vi.fn(),
useMarketplacePlugins: vi.fn(),
useStrategyInfo: vi.fn(),
refetchStrategyInfo: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
useSuspenseQuery: mocks.useSuspenseQuery,
}))
vi.mock('@/service/system-features', () => ({
systemFeaturesQueryOptions: () => ({}),
}))
vi.mock('@/service/use-strategy', () => ({
useStrategyProviders: mocks.useStrategyProviders,
}))
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: mocks.useMarketplacePlugins,
}))
vi.mock('@/app/components/workflow/nodes/agent/use-config', () => ({
useStrategyInfo: mocks.useStrategyInfo,
}))
vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
default: () => ({
getIconUrl: (icon: string) => `https://example.com/${icon}`,
}),
}))
vi.mock('@/app/components/base/search-input', () => ({
default: ({
value,
onChange,
placeholder,
}: {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
}) => (
<input
aria-label={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/block-selector/view-type-select', () => ({
default: ({
onChange,
}: {
viewType: string
onChange: (value: string) => void
}) => (
<button type="button" onClick={() => onChange('grid')}>
view-type
</button>
),
ViewType: {
flat: 'flat',
grid: 'grid',
},
}))
vi.mock('@/app/components/workflow/block-selector/tools', () => ({
default: ({
tools,
onSelect,
}: {
tools: Array<{
id: string
name: string
meta?: unknown
tools: Array<{
name: string
label: string | { en_US?: string }
output_schema?: Record<string, unknown>
}>
}>
onSelect: (value: unknown, tool: {
tool_name: string
provider_name: string
tool_label: string
output_schema?: Record<string, unknown>
provider_id: string
meta?: unknown
}) => void
}) => (
<div data-testid="tools-list">
{tools.map(tool => (
<div key={tool.id}>
<span>{tool.name}</span>
<button
type="button"
onClick={() => onSelect(undefined, {
tool_name: tool.tools[0]!.name,
provider_name: tool.id,
tool_label: typeof tool.tools[0]!.label === 'string'
? tool.tools[0]!.label
: tool.tools[0]!.label.en_US || '',
output_schema: tool.tools[0]!.output_schema,
provider_id: tool.id,
meta: tool.meta,
})}
>
{`select-${tool.name}`}
</button>
</div>
))}
</div>
),
}))
vi.mock('@/app/components/workflow/block-selector/market-place-plugin/list', () => ({
default: ({
list,
searchText,
}: {
list: Array<{ plugin_id: string }>
searchText: string
}) => (
<div data-testid="plugin-list">
{`${searchText}:${list.map(item => item.plugin_id).join(',')}`}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
InstallPluginButton: ({
onClick,
}: {
onClick?: (event: { stopPropagation: () => void }) => void
uniqueIdentifier: string
size: string
}) => (
<button
type="button"
data-testid="install-plugin-button"
onClick={() => onClick?.({ stopPropagation: vi.fn() })}
>
install-plugin
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
SwitchPluginVersion: ({
onChange,
}: {
onChange: () => void
uniqueIdentifier: string
tooltip: ReactNode
}) => (
<button
type="button"
data-testid="switch-plugin-version"
onClick={onChange}
>
switch-plugin-version
</button>
),
}))
vi.mock('@/next/link', () => ({
default: ({
href,
children,
className,
}: {
href: string
children: ReactNode
className?: string
}) => <a href={href} className={className}>{children}</a>,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div data-testid="agent-strategy-trigger" onClick={() => setOpen(!open)}>
{render}
</div>
)
}
const PopoverContent = ({ children }: { children: React.ReactNode }) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="agent-strategy-popover">{children}</div> : null
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ render }: { render: ReactNode }) => <div>{render}</div>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
const createStrategyDetail = (
name: string,
strategyName: string,
strategyLabel: string,
): StrategyPluginDetail => ({
plugin_unique_identifier: `provider/${name}`,
plugin_id: `plugin-${name}`,
declaration: {
identity: {
author: 'Dify',
name,
description: { en_US: `${name} description` },
icon: `${name}.png`,
label: { en_US: `${name} label` },
tags: [],
},
strategies: [{
identity: {
name: strategyName,
author: 'Dify',
label: { en_US: strategyLabel },
},
description: { en_US: `${strategyLabel} description` },
parameters: [],
output_schema: { result: { type: 'string' } },
}],
},
meta: { version: '1.0.0' },
} as unknown as StrategyPluginDetail)
describe('AgentStrategySelector', () => {
const alphaDetail = createStrategyDetail('alpha', 'alpha-strategy', 'Alpha Strategy')
const betaDetail = createStrategyDetail('beta', 'beta-strategy', 'Beta Strategy')
beforeEach(() => {
vi.clearAllMocks()
mocks.useSuspenseQuery.mockReturnValue({ data: true })
mocks.useStrategyProviders.mockReturnValue({ data: [alphaDetail, betaDetail] })
mocks.useMarketplacePlugins.mockReturnValue({
queryPluginsWithDebounced: mocks.queryPluginsWithDebounced,
plugins: [{ plugin_id: 'market-agent' }],
})
mocks.useStrategyInfo.mockReturnValue({
strategyStatus: undefined,
refetch: mocks.refetchStrategyInfo,
})
})
it('filters strategies and queries marketplace when searching', async () => {
const user = userEvent.setup()
render(
<AgentStrategySelector
onChange={vi.fn()}
/>,
)
await user.click(screen.getByTestId('agent-strategy-trigger'))
expect(screen.getByText('alpha')).toBeInTheDocument()
expect(screen.getByText('beta')).toBeInTheDocument()
expect(screen.getByTestId('plugin-list')).toHaveTextContent(':market-agent')
await user.type(
screen.getByRole('textbox', { name: 'nodes.agent.strategy.searchPlaceholder' }),
'alp',
)
await waitFor(() => {
expect(mocks.queryPluginsWithDebounced).toHaveBeenLastCalledWith({
query: 'alp',
category: PluginCategoryEnum.agent,
})
})
expect(screen.getByText('alpha')).toBeInTheDocument()
expect(screen.queryByText('beta')).not.toBeInTheDocument()
})
it('maps the selected tool and closes the popover', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<AgentStrategySelector
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('agent-strategy-trigger'))
await user.click(screen.getByRole('button', { name: 'select-alpha' }))
expect(onChange).toHaveBeenCalledWith({
agent_strategy_name: 'alpha-strategy',
agent_strategy_provider_name: 'provider/alpha',
agent_strategy_label: 'Alpha Strategy',
agent_output_schema: { result: { type: 'string' } },
plugin_unique_identifier: 'provider/alpha',
meta: { version: '1.0.0' },
})
expect(screen.queryByTestId('agent-strategy-popover')).not.toBeInTheDocument()
})
it('renders the plugin-not-installed warning for external strategies', () => {
mocks.useStrategyInfo.mockReturnValue({
strategyStatus: {
plugin: {
source: 'external',
installed: false,
},
isExistInPlugin: true,
},
refetch: mocks.refetchStrategyInfo,
})
render(
<AgentStrategySelector
value={{
agent_strategy_provider_name: 'provider/alpha',
agent_strategy_name: 'alpha-strategy',
agent_strategy_label: 'Alpha Strategy',
agent_output_schema: {},
plugin_unique_identifier: 'provider/alpha',
}}
onChange={vi.fn()}
/>,
)
expect(screen.getByText('nodes.agent.pluginNotInstalled')).toBeInTheDocument()
expect(screen.getByText('nodes.agent.pluginNotInstalledDesc')).toBeInTheDocument()
})
it('renders install and switch-version actions for marketplace strategies', async () => {
const user = userEvent.setup()
mocks.useStrategyInfo.mockReturnValueOnce({
strategyStatus: {
plugin: {
source: 'marketplace',
installed: false,
},
isExistInPlugin: false,
},
refetch: mocks.refetchStrategyInfo,
})
const { rerender } = render(
<AgentStrategySelector
value={{
agent_strategy_provider_name: 'provider/alpha',
agent_strategy_name: 'alpha-strategy',
agent_strategy_label: 'Alpha Strategy',
agent_output_schema: {},
plugin_unique_identifier: 'provider/alpha',
}}
onChange={vi.fn()}
/>,
)
expect(screen.getByTestId('install-plugin-button')).toBeInTheDocument()
mocks.useStrategyInfo.mockReturnValue({
strategyStatus: {
plugin: {
source: 'marketplace',
installed: true,
},
isExistInPlugin: false,
},
refetch: mocks.refetchStrategyInfo,
})
rerender(
<AgentStrategySelector
value={{
agent_strategy_provider_name: 'provider/alpha',
agent_strategy_name: 'alpha-strategy',
agent_strategy_label: 'Alpha Strategy',
agent_output_schema: {},
plugin_unique_identifier: 'provider/alpha',
}}
onChange={vi.fn()}
/>,
)
await user.click(screen.getByTestId('switch-plugin-version'))
expect(mocks.refetchStrategyInfo).toHaveBeenCalled()
})
})

View File

@ -4,14 +4,21 @@ import type { Strategy } from './agent-strategy'
import type { StrategyPluginDetail } from '@/app/components/plugins/types'
import type { ListProps, ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
import { RiArrowDownSLine, RiErrorWarningFill } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import SearchInput from '@/app/components/base/search-input'
import Tooltip from '@/app/components/base/tooltip'
import { ToolTipContent } from '@/app/components/base/tooltip/content'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
@ -36,8 +43,11 @@ const NotFoundWarn = (props: {
const { t } = useTranslation()
return (
<Tooltip
popupContent={(
<Tooltip>
<TooltipTrigger
render={<div><RiErrorWarningFill className="size-4 text-text-destructive" /></div>}
/>
<TooltipContent className="w-[180px]">
<div className="space-y-1 text-xs">
<h3 className="font-semibold text-text-primary">
{title}
@ -51,11 +61,7 @@ const NotFoundWarn = (props: {
</Link>
</p>
</div>
)}
>
<div>
<RiErrorWarningFill className="size-4 text-text-destructive" />
</div>
</TooltipContent>
</Tooltip>
)
}
@ -66,18 +72,18 @@ function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => s
id: item.plugin_unique_identifier,
author: item.declaration.identity.author,
name: item.declaration.identity.name,
description: item.declaration.identity.description as any,
description: item.declaration.identity.description as ToolWithProvider['description'],
plugin_id: item.plugin_id,
icon: getIcon(item.declaration.identity.icon),
label: item.declaration.identity.label as any,
label: item.declaration.identity.label as ToolWithProvider['label'],
type: CollectionType.all,
meta: item.meta,
tools: item.declaration.strategies.map(strategy => ({
name: strategy.identity.name,
author: strategy.identity.author,
label: strategy.identity.label as any,
label: strategy.identity.label as ToolWithProvider['tools'][number]['label'],
description: strategy.description,
parameters: strategy.parameters as any,
parameters: strategy.parameters as unknown as ToolWithProvider['tools'][number]['parameters'],
output_schema: strategy.output_schema,
labels: [],
})),
@ -151,76 +157,82 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
category: PluginCategoryEnum.agent,
})
}
}, [query])
}, [enable_marketplace, fetchPlugins, query])
const pluginRef = useRef<ListRef>(null)
return (
<PortalToFollowElem open={open} onOpenChange={setOpen} placement="bottom">
<PortalToFollowElemTrigger className="w-full">
<div
className="flex h-8 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 select-none hover:bg-state-base-hover-alt"
onClick={() => setOpen(o => !o)}
>
{ }
{icon && (
<div className="flex h-6 w-6 items-center justify-center">
<img
src={icon}
width={20}
height={20}
className="rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge"
alt="icon"
/>
</div>
)}
<p
className={cn(value ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', 'px-1 text-xs')}
>
{value?.agent_strategy_label || t('nodes.agent.strategy.selectTip', { ns: 'workflow' })}
</p>
<div className="ml-auto flex items-center gap-1">
{showInstallButton && value && (
<InstallPluginButton
onClick={e => e.stopPropagation()}
size="small"
uniqueIdentifier={value.plugin_unique_identifier}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<div className="flex h-8 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 select-none hover:bg-state-base-hover-alt">
{icon && (
<div className="flex h-6 w-6 items-center justify-center">
<img
src={icon}
width={20}
height={20}
className="rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge"
alt="icon"
/>
</div>
)}
{showPluginNotInstalledWarn
? (
<NotFoundWarn
title={t('nodes.agent.pluginNotInstalled', { ns: 'workflow' })}
description={t('nodes.agent.pluginNotInstalledDesc', { ns: 'workflow' })}
/>
)
: showUnsupportedStrategy
<p
className={cn(value ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder', 'px-1 text-xs')}
>
{value?.agent_strategy_label || t('nodes.agent.strategy.selectTip', { ns: 'workflow' })}
</p>
<div className="ml-auto flex items-center gap-1">
{showInstallButton && value && (
<InstallPluginButton
onClick={e => e.stopPropagation()}
size="small"
uniqueIdentifier={value.plugin_unique_identifier}
/>
)}
{showPluginNotInstalledWarn
? (
<NotFoundWarn
title={t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
description={t('nodes.agent.strategyNotFoundDesc', { ns: 'workflow' })}
title={t('nodes.agent.pluginNotInstalled', { ns: 'workflow' })}
description={t('nodes.agent.pluginNotInstalledDesc', { ns: 'workflow' })}
/>
)
: <RiArrowDownSLine className="size-4 text-text-tertiary" />}
{showSwitchVersion && (
<SwitchPluginVersion
uniqueIdentifier={value.plugin_unique_identifier}
tooltip={(
<ToolTipContent
title={t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
>
{t('nodes.agent.strategyNotFoundDescAndSwitchVersion', { ns: 'workflow' })}
</ToolTipContent>
)}
onChange={() => {
refetchStrategyInfo()
}}
/>
)}
: showUnsupportedStrategy
? (
<NotFoundWarn
title={t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
description={t('nodes.agent.strategyNotFoundDesc', { ns: 'workflow' })}
/>
)
: <RiArrowDownSLine className="size-4 text-text-tertiary" />}
{showSwitchVersion && value && (
<SwitchPluginVersion
uniqueIdentifier={value.plugin_unique_identifier}
tooltip={(
<div className="w-[180px] space-y-1 text-xs">
<h3 className="font-semibold text-text-primary">
{t('nodes.agent.unsupportedStrategy', { ns: 'workflow' })}
</h3>
<p className="text-text-tertiary">
{t('nodes.agent.strategyNotFoundDescAndSwitchVersion', { ns: 'workflow' })}
</p>
</div>
)}
onChange={() => {
refetchStrategyInfo()
}}
/>
)}
</div>
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
)}
/>
<PopoverContent
placement="bottom"
sideOffset={0}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 10 } }}
>
<div className="w-[388px] overflow-hidden rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow">
<header className="flex gap-1 p-2">
<SearchInput placeholder={t('nodes.agent.strategy.searchPlaceholder', { ns: 'workflow' })} value={query} onChange={setQuery} className="w-full" />
@ -260,8 +272,8 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
)}
</main>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
})

View File

@ -1,4 +1,8 @@
import type { ComponentProps } from 'react'
import {
Popover,
PopoverContent,
} from '@langgenius/dify-ui/popover'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VarType as VarKindType } from '../../../../tool/types'
@ -38,46 +42,53 @@ const createProps = (
],
varName: '',
variableCategory: 'system',
WrapElem: 'div',
VarPickerWrap: 'div',
...overrides,
})
const renderWithPopover = (
overrides: Partial<ComponentProps<typeof VarReferencePickerTrigger>> = {},
) => {
const onOpenChange = vi.fn()
render(
<Popover onOpenChange={onOpenChange}>
<VarReferencePickerTrigger
{...createProps(overrides)}
/>
<PopoverContent popupClassName="border-none bg-transparent p-0 shadow-none">
<div>picker-content</div>
</PopoverContent>
</Popover>,
)
return { onOpenChange }
}
describe('VarReferencePickerTrigger', () => {
it('should show the placeholder state and open the picker for variable mode', () => {
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
placeholder: 'Pick variable',
setOpen,
})}
/>,
)
const { onOpenChange } = renderWithPopover({
placeholder: 'Pick variable',
})
expect(screen.getByText('Pick variable'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setOpen).toHaveBeenCalledWith(true)
expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything())
})
it('should render the selected variable state and clear it', () => {
const handleClearVar = vi.fn()
const handleVariableJump = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
handleClearVar,
handleVariableJump,
hasValue: true,
outputVarNode: { title: 'Source Node', desc: '', type: BlockEnum.Code },
outputVarNodeId: 'node-a',
type: VarType.string,
value: ['node-a', 'answer'],
varName: 'answer',
})}
/>,
)
renderWithPopover({
handleClearVar,
handleVariableJump,
hasValue: true,
outputVarNode: { title: 'Source Node', desc: '', type: BlockEnum.Code },
outputVarNodeId: 'node-a',
type: VarType.string,
value: ['node-a', 'answer'],
varName: 'answer',
})
expect(screen.getByText('Source Node'))!.toBeInTheDocument()
expect(screen.getByText('answer'))!.toBeInTheDocument()
@ -93,20 +104,16 @@ describe('VarReferencePickerTrigger', () => {
const setControlFocus = vi.fn()
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
isConstant: true,
isSupportConstantValue: true,
schemaWithDynamicSelect: {
type: 'text-input',
} as never,
setOpen,
setControlFocus,
value: 'constant-value',
})}
/>,
)
renderWithPopover({
isConstant: true,
isSupportConstantValue: true,
schemaWithDynamicSelect: {
type: 'text-input',
} as never,
setOpen,
setControlFocus,
value: 'constant-value',
})
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setControlFocus).toHaveBeenCalledTimes(1)
@ -116,38 +123,27 @@ describe('VarReferencePickerTrigger', () => {
})
it('should render add button trigger in table mode', () => {
render(
<VarReferencePickerTrigger
{...createProps({
hasValue: true,
isAddBtnTrigger: true,
isInTable: true,
value: ['node-a', 'answer'],
varName: 'answer',
})}
/>,
)
renderWithPopover({
hasValue: true,
isAddBtnTrigger: true,
isInTable: true,
value: ['node-a', 'answer'],
varName: 'answer',
})
expect(document.querySelector('button'))!.toBeInTheDocument()
expect(screen.getByTestId('add-button'))!.toBeInTheDocument()
})
it('should stay inert in readonly mode and show value type placeholder badge', () => {
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
placeholder: 'Readonly placeholder',
readonly: true,
setOpen,
typePlaceHolder: 'string',
valueTypePlaceHolder: 'text',
})}
/>,
)
const { onOpenChange } = renderWithPopover({
placeholder: 'Readonly placeholder',
readonly: true,
typePlaceHolder: 'string',
valueTypePlaceHolder: 'text',
})
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setOpen).not.toHaveBeenCalled()
expect(onOpenChange).not.toHaveBeenCalled()
expect(screen.getByText('string'))!.toBeInTheDocument()
expect(screen.getByText('text'))!.toBeInTheDocument()
})
@ -155,17 +151,13 @@ describe('VarReferencePickerTrigger', () => {
it('should show loading placeholder and remove rows in table mode', () => {
const onRemove = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
hasValue: false,
isInTable: true,
isLoading: true,
onRemove,
placeholder: 'Loading variable',
})}
/>,
)
renderWithPopover({
hasValue: false,
isInTable: true,
isLoading: true,
onRemove,
placeholder: 'Loading variable',
})
expect(screen.getByText('Loading variable'))!.toBeInTheDocument()

View File

@ -81,4 +81,27 @@ describe('var-reference-vars helpers', () => {
expect(vars[0]!.title).toBe('Node B')
expect(vars[0]!.vars).toEqual([expect.objectContaining({ variable: 'another_value' })])
})
it('should keep parent vars when search text matches a child variable', () => {
const vars = filterReferenceVars([
{
title: 'Node A',
nodeId: 'node-a',
vars: [{
variable: 'payload',
type: VarType.object,
children: [{ variable: 'child_name', type: VarType.string }],
}],
},
{
title: 'Node B',
nodeId: 'node-b',
vars: [{ variable: 'other_value', type: VarType.string }],
},
] as NodeOutPutVar[], 'child')
expect(vars).toHaveLength(1)
expect(vars[0]!.title).toBe('Node A')
expect(vars[0]!.vars).toEqual([expect.objectContaining({ variable: 'payload' })])
})
})

View File

@ -199,6 +199,34 @@ describe('VarReferenceVars', () => {
}))
})
it('should filter by externally controlled search text and match child variables', () => {
render(
<VarReferenceVars
hideSearch
searchText="child"
vars={createVars([
{
title: 'Object vars',
nodeId: 'node-obj',
vars: [{
variable: 'payload',
type: VarType.object,
children: [{ variable: 'child_name', type: VarType.string }],
}, {
variable: 'other_value',
type: VarType.string,
}],
},
])}
onChange={vi.fn()}
/>,
)
expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument()
expect(screen.getByText('payload')).toBeInTheDocument()
expect(screen.queryByText('other_value')).not.toBeInTheDocument()
})
it('should ignore file vars when file support is disabled and forward blur-sm events', () => {
const onChange = vi.fn()
const onBlur = vi.fn()

View File

@ -7,6 +7,7 @@ import type { Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { Node, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { PopoverTrigger } from '@langgenius/dify-ui/popover'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiArrowDownSLine, RiCloseLine, RiErrorWarningFill, RiLoader4Line, RiMoreLine } from '@remixicon/react'
@ -68,8 +69,6 @@ type Props = {
varKindTypes: Array<{ label: string, value: VarKindType }>
varName: string
variableCategory: string
WrapElem: React.ElementType
VarPickerWrap: React.ElementType
}
const VarReferencePickerTrigger: FC<Props> = ({
@ -114,9 +113,14 @@ const VarReferencePickerTrigger: FC<Props> = ({
varKindTypes,
varName,
variableCategory,
VarPickerWrap,
WrapElem,
}) => {
const handleTriggerReadonlyClick = (e: React.MouseEvent<HTMLElement>) => {
if (!readonly)
return
e.preventDefault()
e.stopPropagation()
}
const pill = (
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
{hasValue
@ -212,18 +216,36 @@ const VarReferencePickerTrigger: FC<Props> = ({
)
: pill
return (
<WrapElem
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
const variablePicker = (
<div className="h-full grow">
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
{hoveredPill}
</div>
</div>
)
const resolvedVariablePicker = isSupportConstantValue
? (
readonly
? variablePicker
: (
<PopoverTrigger
render={variablePicker}
onClick={handleTriggerReadonlyClick}
/>
)
)
: variablePicker
const triggerContent = (
<div
className={cn(className, 'group/picker-trigger-wrap relative flex!', !readonly && 'cursor-pointer')}
data-testid="var-reference-picker-trigger"
onClick={() => {
if (!isConstant || readonly)
return
setControlFocus(Date.now())
}}
>
<>
{isAddBtnTrigger
@ -278,24 +300,7 @@ const VarReferencePickerTrigger: FC<Props> = ({
isLoading={isLoading}
/>
)
: (
<VarPickerWrap
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
className="h-full grow"
>
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
{hoveredPill}
</div>
</VarPickerWrap>
)}
: resolvedVariablePicker}
{(hasValue && !readonly && !isInTable && !isJustShowValue) && (
<div
className="group invisible absolute top-[50%] right-1 h-5 translate-y-[-50%] cursor-pointer rounded-md p-1 group-hover/wrap:visible hover:bg-state-base-hover"
@ -330,8 +335,22 @@ const VarReferencePickerTrigger: FC<Props> = ({
)}
</>
<input ref={inputRef} className="sr-only" value={controlFocus} readOnly />
</WrapElem>
</div>
)
if (!isSupportConstantValue) {
if (readonly)
return triggerContent
return (
<PopoverTrigger
render={triggerContent}
onClick={handleTriggerReadonlyClick}
/>
)
}
return triggerContent
}
export default VarReferencePickerTrigger

View File

@ -6,6 +6,11 @@ import type { Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { CommonNodeType, Node, NodeOutPutVar, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
@ -16,11 +21,6 @@ import {
useReactFlow,
useStoreApi,
} from 'reactflow'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
useIsChatMode,
@ -141,10 +141,10 @@ const VarReferencePicker: FC<Props> = ({
})
const node = nodes.find(n => n.id === nodeId)
const isInIteration = !!(node?.data as any)?.isInIteration
const isInIteration = !!node?.data.isInIteration
const iterationNode = isInIteration ? (nodes.find(n => n.id === node?.parentId) ?? null) : null
const isInLoop = !!(node?.data as any)?.isInLoop
const isInLoop = !!node?.data.isInLoop
const loopNode = isInLoop ? (nodes.find(n => n.id === node?.parentId) ?? null) : null
const triggerRef = useRef<HTMLDivElement>(null)
@ -210,13 +210,11 @@ const VarReferencePicker: FC<Props> = ({
}, [onChange])
const inputRef = useRef<HTMLInputElement>(null)
const [isFocus, setIsFocus] = useState(false)
const [controlFocus, setControlFocus] = useState(0)
const isFocus = controlFocus > 0
useEffect(() => {
if (controlFocus && inputRef.current) {
if (controlFocus && inputRef.current)
inputRef.current.focus()
setIsFocus(true)
}
}, [controlFocus])
const handleVarReferenceChange = useCallback((value: ValueSelector, varInfo: Var) => {
@ -264,7 +262,7 @@ const VarReferencePicker: FC<Props> = ({
}, [availableNodes, reactflow, store])
const type = getCurrentVariableType({
parentNode: (isInIteration ? iterationNode : loopNode) as any,
parentNode: isInIteration ? iterationNode : loopNode,
valueSelector: value as ValueSelector,
availableNodes,
isChatMode,
@ -289,9 +287,6 @@ const VarReferencePicker: FC<Props> = ({
maxVarNameWidth,
} = getWidthAllocations(triggerWidth, outputVarNode?.title || '', varName || '', type || '')
const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const hoverPopup = useMemo<HoverPopup | null>(() => {
const tooltipType = getTooltipContent(hasValue, isShowAPart, isValidVar)
if (tooltipType === 'full-path') {
@ -349,15 +344,23 @@ const VarReferencePicker: FC<Props> = ({
)
const triggerPlaceholder = placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })
const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div>
return (
<div className={cn(className)}>
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement={isAddBtnTrigger ? 'bottom-end' : 'bottom-start'}
>
{!!trigger && <PortalToFollowElemTrigger onClick={() => setOpen(!open)}>{trigger}</PortalToFollowElemTrigger>}
{!!trigger && (
<PopoverTrigger
render={resolvedTrigger}
onClick={(e) => {
if (readonly)
e.preventDefault()
}}
/>
)}
{!trigger && (
<VarReferencePickerTrigger
className={className}
@ -403,15 +406,18 @@ const VarReferencePicker: FC<Props> = ({
varKindTypes={varKindTypes}
varName={varName}
variableCategory={variableCategory}
VarPickerWrap={VarPickerWrap}
WrapElem={WrapElem}
/>
)}
<PortalToFollowElemContent
style={{
zIndex: zIndex || 100,
}}
<PopoverContent
placement={isAddBtnTrigger ? 'bottom-end' : 'bottom-start'}
sideOffset={0}
className="mt-1"
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{
style: {
zIndex: zIndex || 100,
},
}}
>
{!isConstant && (
<VarReferencePopup
@ -424,8 +430,8 @@ const VarReferencePicker: FC<Props> = ({
preferSchemaType={preferSchemaType}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
</div>
)
}

View File

@ -1,3 +1,4 @@
import type { Field, StructuredOutput } from '@/app/components/workflow/nodes/llm/types'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
import { checkKeys } from '@/utils/var'
@ -76,6 +77,51 @@ const getVisibleChildren = (vars: Var[]) => {
return vars.filter(variable => checkKeys([variable.variable], false).isValid || isSpecialVar(variable.variable.split('.')[0]!))
}
const includesSearchText = (value: string | undefined, searchTextLower: string) => {
if (!value)
return false
return value.toLowerCase().includes(searchTextLower)
}
const isStructuredOutputChildren = (children: Var['children']): children is StructuredOutput => {
return !!children && !Array.isArray(children) && 'schema' in children
}
const matchesStructuredField = (fieldName: string, field: Field, searchTextLower: string): boolean => {
if (includesSearchText(fieldName, searchTextLower))
return true
if (field.properties)
return Object.entries(field.properties).some(([childName, childField]) => matchesStructuredField(childName, childField, searchTextLower))
if (field.items)
return matchesStructuredField(field.items.type, field.items, searchTextLower)
return false
}
const matchesVariableSearch = (variable: Var, searchTextLower: string): boolean => {
if (
includesSearchText(variable.variable, searchTextLower)
|| includesSearchText(variable.des, searchTextLower)
|| includesSearchText(variable.schemaType, searchTextLower)
) {
return true
}
if (!variable.children)
return false
if (Array.isArray(variable.children))
return getVisibleChildren(variable.children).some(child => matchesVariableSearch(child, searchTextLower))
if (isStructuredOutputChildren(variable.children))
return Object.entries(variable.children.schema.properties).some(([fieldName, field]) => matchesStructuredField(fieldName, field, searchTextLower))
return false
}
export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) => {
const searchTextLower = searchText.toLowerCase()
@ -85,7 +131,7 @@ export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) =
.filter((node) => {
if (!searchText)
return true
return node.vars.some(variable => variable.variable.toLowerCase().includes(searchTextLower))
return node.vars.some(variable => matchesVariableSearch(variable, searchTextLower))
|| node.title.toLowerCase().includes(searchTextLower)
})
.map((node) => {
@ -94,7 +140,7 @@ export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) =
return {
...node,
vars: node.vars.filter(variable => variable.variable.toLowerCase().includes(searchTextLower)),
vars: node.vars.filter(variable => matchesVariableSearch(variable, searchTextLower)),
}
})
}

View File

@ -4,6 +4,11 @@ import type { StructuredOutput } from '../../../llm/types'
import type { Field } from '@/app/components/workflow/nodes/llm/types'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useHover } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
@ -13,11 +18,6 @@ import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows
import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { VarType } from '@/app/components/workflow/types'
@ -143,7 +143,7 @@ const Item: FC<ItemProps> = ({
const open = (isObj || isStructureOutput) && isHovering
useEffect(() => {
onHovering?.(isHovering)
}, [isHovering])
}, [isHovering, onHovering])
const handleChosen = (e: React.MouseEvent) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
@ -167,62 +167,70 @@ const Item: FC<ItemProps> = ({
() => getVariableCategory({ isEnv, isChatVar, isLoopVar, isRagVariable }),
[isEnv, isChatVar, isLoopVar, isRagVariable],
)
const itemTrigger = (
<div
ref={itemRef}
className={cn(
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
isHovering && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
className,
)}
onClick={handleChosen}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}}
>
<div className="flex w-0 grow items-center">
{!isFlat && (
<VariableIconWithColor
variables={itemData.variable.split('.')}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
)}
{isFlat && flatVarIcon}
{!isEnv && !isChatVar && !isRagVariable && (
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{varName}</div>
)}
{isEnv && (
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('env.', '')}</div>
)}
{isChatVar && (
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('conversation.', '')}</div>
)}
{isRagVariable && (
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.split('.').slice(-1)[0]}</div>
)}
</div>
<div className="ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</div>
{
(isObj || isStructureOutput) && (
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
)
}
</div>
)
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={noop}
placement="left-start"
>
<PortalToFollowElemTrigger className="w-full">
<div
ref={itemRef}
className={cn(
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
isHovering && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
className,
)}
onClick={handleChosen}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}}
>
<div className="flex w-0 grow items-center">
{!isFlat && (
<VariableIconWithColor
variables={itemData.variable.split('.')}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
)}
{isFlat && flatVarIcon}
{!isEnv && !isChatVar && !isRagVariable && (
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{varName}</div>
)}
{isEnv && (
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('env.', '')}</div>
)}
{isChatVar && (
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('conversation.', '')}</div>
)}
{isRagVariable && (
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.split('.').slice(-1)[0]}</div>
)}
</div>
<div className="ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</div>
{
(isObj || isStructureOutput) && (
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
)
}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{
zIndex: zIndex || 100,
}}
<PopoverTrigger render={itemTrigger} />
<PopoverContent
placement="left-start"
sideOffset={0}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{
style: {
zIndex: zIndex || 100,
},
}}
>
{(isStructureOutput || isObj) && (
<PickerStructurePanel
@ -234,13 +242,14 @@ const Item: FC<ItemProps> = ({
}}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
type Props = {
hideSearch?: boolean
searchText?: string
searchBoxClassName?: string
vars: NodeOutPutVar[]
isSupportFileVar?: boolean
@ -258,6 +267,7 @@ type Props = {
}
const VarReferenceVars: FC<Props> = ({
hideSearch,
searchText,
searchBoxClassName,
vars,
isSupportFileVar,
@ -274,7 +284,8 @@ const VarReferenceVars: FC<Props> = ({
preferSchemaType,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const [internalSearchValue, setInternalSearchValue] = useState('')
const searchValue = searchText ?? internalSearchValue
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
@ -283,7 +294,7 @@ const VarReferenceVars: FC<Props> = ({
}
}
const filteredVars = useMemo(() => filterReferenceVars(vars, searchText), [vars, searchText])
const filteredVars = useMemo(() => filterReferenceVars(vars, searchValue), [vars, searchValue])
return (
<>
@ -295,11 +306,11 @@ const VarReferenceVars: FC<Props> = ({
className="var-search-input"
showLeftIcon
showClearIcon
value={searchText}
value={searchValue}
placeholder={t('common.searchVar', { ns: 'workflow' }) || ''}
onChange={e => setSearchText(e.target.value)}
onChange={e => setInternalSearchValue(e.target.value)}
onKeyDown={handleKeyDown}
onClear={() => setSearchText('')}
onClear={() => setInternalSearchValue('')}
onBlur={onBlur}
autoFocus={autoFocus}
/>

View File

@ -1,5 +1,10 @@
import type { FC } from 'react'
import type { CodeDependency } from './types'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
RiArrowDownSLine,
} from '@remixicon/react'
@ -8,7 +13,6 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import Input from '@/app/components/base/input'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
type Props = {
value: CodeDependency
@ -32,21 +36,22 @@ const DependencyPicker: FC<Props> = ({
}, [onChange])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)} className="grow cursor-pointer">
<div className="flex h-8 items-center justify-between rounded-lg border-0 bg-gray-100 px-2.5 text-[13px] text-gray-900">
<div className="w-0 grow truncate" title={value.name}>{value.name}</div>
<RiArrowDownSLine className="h-3.5 w-3.5 shrink-0 text-gray-700" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{
zIndex: 100,
}}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<div className="grow cursor-pointer">
<div className="flex h-8 items-center justify-between rounded-lg border-0 bg-gray-100 px-2.5 text-[13px] text-gray-900">
<div className="w-0 grow truncate" title={value.name}>{value.name}</div>
<RiArrowDownSLine className="h-3.5 w-3.5 shrink-0 text-gray-700" />
</div>
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 100 } }}
>
<div
className="rounded-lg bg-white p-1 shadow-sm"
@ -82,8 +87,8 @@ const DependencyPicker: FC<Props> = ({
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -2,6 +2,59 @@ import type { Member } from '@/models/common'
import { fireEvent, render, screen } from '@testing-library/react'
import MemberSelector from '../member-selector'
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: import('react').ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: import('react').ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return (
<div onClick={() => setOpen(!open)}>
{render}
</div>
)
}
const PopoverContent = ({ children }: { children: import('react').ReactNode }) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="popover-content">{children}</div> : null
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
const mockMemberList = vi.hoisted(() => vi.fn())
vi.mock('../member-list', () => ({

View File

@ -1,14 +1,13 @@
import type { Recipient as RecipientItem } from '../../../types'
import type { Member } from '@/models/common'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import EmailItem from './email-item'
import MemberList from './member-list'
@ -58,8 +57,7 @@ const EmailInput = ({
if (disabled)
return
setIsFocus(true)
const input = inputRef.current?.children[0] as HTMLInputElement
input?.focus()
inputRef.current?.focus()
}
const handleValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -141,28 +139,29 @@ const EmailInput = ({
/>
))}
{!disabled && (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: -40,
}}
>
<PortalToFollowElemTrigger className="block h-6 min-w-[166px]">
<input
ref={inputRef}
className="h-6 min-w-[166px] appearance-none bg-transparent p-1 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder"
placeholder={placeholder}
onFocus={() => setIsFocus(true)}
onBlur={handleInputBlur}
value={searchKey}
onChange={handleValueChange}
onKeyDown={handleKeyDown}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<Popover open={open} onOpenChange={setOpen}>
<input
ref={inputRef}
className="h-6 min-w-[166px] appearance-none bg-transparent p-1 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder"
placeholder={placeholder}
onFocus={() => setIsFocus(true)}
onBlur={handleInputBlur}
value={searchKey}
onChange={handleValueChange}
onKeyDown={handleKeyDown}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
alignOffset={-40}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{
anchor: inputRef,
style: {
zIndex: 1000,
},
}}
>
<MemberList
searchValue={searchKey}
list={list}
@ -172,8 +171,8 @@ const EmailInput = ({
email={email}
hideSearch
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)}
</div>
</div>

View File

@ -4,12 +4,16 @@ import type { Recipient } from '@/app/components/workflow/nodes/human-input/type
import type { Member } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
RiContactsBookLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import MemberList from './member-list'
const i18nPrefix = 'nodes.humanInput'
@ -31,39 +35,42 @@ const MemberSelector: FC<Props> = ({
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const handleSelect = useCallback((memberId: string) => {
onSelect(memberId)
setOpen(false)
}, [onSelect])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 35,
}}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={() => setOpen(v => !v)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
className={cn('w-full justify-between', open && 'bg-state-accent-hover')}
variant="ghost-accent"
>
<RiContactsBookLine className="mr-1 h-4 w-4" />
<div>{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.trigger`, { ns: 'workflow' })}</div>
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={35}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<Button
className={cn('w-full justify-between', open && 'bg-state-accent-hover')}
variant="ghost-accent"
>
<RiContactsBookLine className="mr-1 h-4 w-4" />
<div className="">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.trigger`, { ns: 'workflow' })}</div>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<MemberList
searchValue={searchValue}
list={list}
value={value}
onSearchChange={setSearchValue}
onSelect={onSelect}
onSelect={handleSelect}
email={email}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}
export default MemberSelector

View File

@ -5,17 +5,17 @@ import type {
Var,
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiAddLine } from '@remixicon/react'
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
type ConditionAddProps = {
@ -25,6 +25,7 @@ type ConditionAddProps = {
onSelectVariable: HandleAddCondition
disabled?: boolean
}
const ConditionAdd = ({
className,
caseId,
@ -38,29 +39,32 @@ const ConditionAdd = ({
const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => {
onSelectVariable(caseId, valueSelector, varItem)
setOpen(false)
}, [caseId, onSelectVariable, setOpen])
}, [caseId, onSelectVariable])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
<Button
size="small"
className={className}
disabled={disabled}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
size="small"
className={className}
disabled={disabled}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
</Button>
)}
onClick={(e) => {
if (disabled)
e.preventDefault()
}}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars
vars={variables}
@ -68,8 +72,8 @@ const ConditionAdd = ({
onChange={handleSelectVariable}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -1,5 +1,9 @@
import type { Node, NodeOutPutVar, ValueSelector, Var, VarType } from '@/app/components/workflow/types'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
@ -23,26 +27,25 @@ const ConditionVarSelector = ({
onChange,
}: ConditionVarSelectorProps) => {
return (
<PortalToFollowElem
open={open}
onOpenChange={onOpenChange}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger asChild onClick={() => onOpenChange(!open)}>
<div className="w-full cursor-pointer">
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
render={(
<div className="w-full cursor-pointer">
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars
vars={nodesOutputVars}
@ -50,8 +53,8 @@ const ConditionVarSelector = ({
onChange={onChange}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -2,8 +2,11 @@ import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-re
import type { MetadataInDoc } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import {
RiAddLine,
} from '@remixicon/react'
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiAddLine } from '@remixicon/react'
import {
useCallback,
useMemo,
@ -11,11 +14,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import MetadataIcon from './metadata-icon'
const AddCondition = ({
@ -36,25 +34,24 @@ const AddCondition = ({
}, [handleAddCondition])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 3,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
<Button
size="small"
variant="secondary"
>
<RiAddLine className="h-3.5 w-3.5" />
{t('nodes.knowledgeRetrieval.metadata.panel.add', { ns: 'workflow' })}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
size="small"
variant="secondary"
>
<RiAddLine className="h-3.5 w-3.5" />
{t('nodes.knowledgeRetrieval.metadata.panel.add', { ns: 'workflow' })}
</Button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={12}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1002 } }}
>
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<div className="p-2 pb-1">
<Input
@ -65,30 +62,28 @@ const AddCondition = ({
/>
</div>
<div className="p-1">
{
filteredMetadataList?.map(metadata => (
<div
key={metadata.name}
className="flex h-6 cursor-pointer items-center rounded-md px-3 system-sm-medium text-text-secondary hover:bg-state-base-hover"
>
<div className="mr-1 p-px">
<MetadataIcon type={metadata.type} />
</div>
<div
className="grow truncate"
title={metadata.name}
onClick={() => handleAddConditionWrapped(metadata)}
>
{metadata.name}
</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">{metadata.type}</div>
{filteredMetadataList?.map(metadata => (
<div
key={metadata.name}
className="flex h-6 cursor-pointer items-center rounded-md px-3 system-sm-medium text-text-secondary hover:bg-state-base-hover"
>
<div className="mr-1 p-px">
<MetadataIcon type={metadata.type} />
</div>
))
}
<div
className="grow truncate"
title={metadata.name}
onClick={() => handleAddConditionWrapped(metadata)}
>
{metadata.name}
</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">{metadata.type}</div>
</div>
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -1,12 +1,12 @@
import type { VarType } from '@/app/components/workflow/types'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type ConditionCommonVariableSelectorProps = {
variables?: { name: string, type: string, value: string }[]
@ -31,34 +31,17 @@ const ConditionCommonVariableSelector = ({
}, [onChange])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger
asChild
onClick={() => {
if (!variables.length)
return
setOpen(!open)
}}
>
<div className="flex h-6 grow cursor-pointer items-center">
{
selected && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<div className="flex h-6 grow cursor-pointer items-center">
{selected && (
<div className="inline-flex h-6 items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark pr-1.5 pl-[5px] system-xs-medium text-text-secondary shadow-xs">
<Variable02 className="mr-1 h-3.5 w-3.5 text-text-accent" />
{selected.value}
</div>
)
}
{
!selected && (
)}
{!selected && (
<>
<div className="flex grow items-center system-sm-regular text-components-input-text-placeholder">
<Variable02 className="mr-1 h-4 w-4" />
@ -68,27 +51,34 @@ const ConditionCommonVariableSelector = ({
{varType}
</div>
</>
)
}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
)}
</div>
)}
onClick={(e) => {
if (!variables.length)
e.preventDefault()
}}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[200px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{
variables.map(v => (
<div
key={v.value}
className="flex h-6 cursor-pointer items-center rounded-md px-2 system-xs-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => handleChange(v.value)}
>
<Variable02 className="mr-1 h-4 w-4 text-text-accent" />
{v.value}
</div>
))
}
{variables.map(v => (
<div
key={v.value}
className="flex h-6 cursor-pointer items-center rounded-md px-2 system-xs-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => handleChange(v.value)}
>
<Variable02 className="mr-1 h-4 w-4 text-text-accent" />
{v.value}
</div>
))}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -4,14 +4,14 @@ import type {
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { VarType } from '@/app/components/workflow/types'
@ -34,35 +34,25 @@ const ConditionVariableSelector = ({
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleChange = useCallback((valueSelector: ValueSelector, varItem: Var) => {
onChange(valueSelector, varItem)
const handleChange = useCallback((nextValueSelector: ValueSelector, varItem: Var) => {
onChange(nextValueSelector, varItem)
setOpen(false)
}, [onChange])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger asChild onClick={() => setOpen(!open)}>
<div className="flex h-6 grow cursor-pointer items-center">
{
!!valueSelector.length && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<div className="flex h-6 grow cursor-pointer items-center">
{!!valueSelector.length && (
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
)
}
{
!valueSelector.length && (
)}
{!valueSelector.length && (
<>
<div className="flex grow items-center system-sm-regular text-components-input-text-placeholder">
<Variable02 className="mr-1 h-4 w-4" />
@ -72,11 +62,16 @@ const ConditionVariableSelector = ({
{varType}
</div>
</>
)
}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
)}
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars
vars={nodesOutputVars}
@ -84,8 +79,8 @@ const ConditionVariableSelector = ({
onChange={handleChange}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -5,17 +5,17 @@ import type {
Var,
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiAddLine } from '@remixicon/react'
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
type ConditionAddProps = {
@ -24,6 +24,7 @@ type ConditionAddProps = {
onSelectVariable: HandleAddCondition
disabled?: boolean
}
const ConditionAdd = ({
className,
variables,
@ -36,29 +37,32 @@ const ConditionAdd = ({
const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => {
onSelectVariable(valueSelector, varItem)
setOpen(false)
}, [onSelectVariable, setOpen])
}, [onSelectVariable])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
<Button
size="small"
className={className}
disabled={disabled}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
size="small"
className={className}
disabled={disabled}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addCondition', { ns: 'workflow' })}
</Button>
)}
onClick={(e) => {
if (disabled)
e.preventDefault()
}}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars
vars={variables}
@ -66,8 +70,8 @@ const ConditionAdd = ({
onChange={handleSelectVariable}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -1,5 +1,9 @@
import type { Node, NodeOutPutVar, ValueSelector, Var, VarType } from '@/app/components/workflow/types'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
@ -23,26 +27,25 @@ const ConditionVarSelector = ({
onChange,
}: ConditionVarSelectorProps) => {
return (
<PortalToFollowElem
open={open}
onOpenChange={onOpenChange}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => onOpenChange(!open)}>
<div className="cursor-pointer">
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1000">
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
render={(
<div className="cursor-pointer">
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
positionerProps={{ style: { zIndex: 1000 } }}
>
<div className="w-[296px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars
vars={nodesOutputVars}
@ -50,8 +53,8 @@ const ConditionVarSelector = ({
onChange={onChange}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,159 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SearchInput from '../search-input'
const educationMocks = vi.hoisted(() => ({
schools: ['Alpha University', 'Beta College'],
setSchools: vi.fn(),
querySchoolsWithDebounced: vi.fn(),
handleUpdateSchools: vi.fn(),
hasNext: false,
}))
vi.mock('../hooks', () => ({
useEducation: () => educationMocks,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/input', () => ({
default: ({
value,
onChange,
placeholder,
className,
}: {
value?: string
onChange: (event: { target: { value: string } }) => void
placeholder?: string
className?: string
}) => (
<input
className={className}
placeholder={placeholder}
value={value}
onChange={e => onChange({ target: { value: e.target.value } })}
/>
),
}))
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled)
setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<PopoverContext.Provider value={{ open, setOpen }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = ({ render }: { render: ReactNode }) => <>{render}</>
const PopoverContent = ({ children }: { children: ReactNode }) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="education-search-popover">{children}</div> : null
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
const ControlledSearchInput = () => {
const [value, setValue] = useState('')
return <SearchInput value={value} onChange={setValue} />
}
describe('education-apply/search-input', () => {
beforeEach(() => {
vi.clearAllMocks()
educationMocks.schools = ['Alpha University', 'Beta College']
educationMocks.hasNext = false
})
it('opens the popover, queries schools, and closes after selection', async () => {
const user = userEvent.setup()
render(<ControlledSearchInput />)
const input = screen.getByPlaceholderText('form.schoolName.placeholder')
await user.type(input, 'A')
expect(educationMocks.setSchools).toHaveBeenCalledWith([])
expect(educationMocks.querySchoolsWithDebounced).toHaveBeenLastCalledWith({
keywords: 'A',
page: 0,
})
expect(screen.getByTestId('education-search-popover')).toBeInTheDocument()
expect(screen.getByText('Alpha University')).toBeInTheDocument()
await user.click(screen.getByText('Beta College'))
expect(screen.getByDisplayValue('Beta College')).toBeInTheDocument()
expect(screen.queryByTestId('education-search-popover')).not.toBeInTheDocument()
})
it('loads the next page when the dropdown is scrolled to the bottom', async () => {
const user = userEvent.setup()
educationMocks.hasNext = true
render(<ControlledSearchInput />)
await user.type(screen.getByPlaceholderText('form.schoolName.placeholder'), 'A')
const scrollContainer = screen.getByText('Alpha University').parentElement as HTMLDivElement
Object.defineProperties(scrollContainer, {
scrollTop: {
value: 60,
configurable: true,
},
scrollHeight: {
value: 100,
configurable: true,
},
clientHeight: {
value: 40,
configurable: true,
},
})
fireEvent.scroll(scrollContainer)
expect(educationMocks.handleUpdateSchools).toHaveBeenCalledWith({
keywords: 'A',
page: 1,
})
})
})

View File

@ -1,4 +1,9 @@
import type { ChangeEventHandler } from 'react'
import type { ChangeEventHandler, UIEventHandler } from 'react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
useCallback,
useRef,
@ -6,17 +11,13 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useEducation } from './hooks'
type SearchInputProps = {
value?: string
onChange: (value: string) => void
}
const SearchInput = ({
value,
onChange,
@ -48,7 +49,7 @@ const SearchInput = ({
keywords,
page,
})
}, [querySchoolsWithDebounced, handleUpdateSchools])
}, [handleUpdateSchools, querySchoolsWithDebounced])
const handleValueChange: ChangeEventHandler<HTMLInputElement> = useCallback((e) => {
setOpen(true)
@ -58,10 +59,10 @@ const SearchInput = ({
valueRef.current = inputValue
onChange(inputValue)
handleSearch(true)
}, [onChange, handleSearch, setSchools])
}, [handleSearch, onChange, setSchools])
const handleScroll = useCallback((e: Event) => {
const target = e.target as HTMLDivElement
const handleScroll: UIEventHandler<HTMLDivElement> = useCallback((e) => {
const target = e.currentTarget
const {
scrollTop,
scrollHeight,
@ -74,48 +75,45 @@ const SearchInput = ({
}, [handleSearch, hasNext])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom"
offset={4}
triggerPopupSameWidth
>
<PortalToFollowElemTrigger className="block w-full">
<Input
className="w-full"
placeholder={t('form.schoolName.placeholder', { ns: 'education' })}
value={value}
onChange={handleValueChange}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-32">
{
!!schools.length && value && (
<div
className="max-h-[330px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1"
onScroll={handleScroll as any}
>
{
schools.map((school, index) => (
<div
key={index}
className="flex h-8 cursor-pointer items-center truncate rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
title={school}
onClick={() => {
onChange(school)
setOpen(false)
}}
>
{school}
</div>
))
}
</div>
)
}
</PortalToFollowElemContent>
</PortalToFollowElem>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Input
className="w-full"
placeholder={t('form.schoolName.placeholder', { ns: 'education' })}
value={value}
onChange={handleValueChange}
/>
)}
/>
{!!schools.length && !!value && (
<PopoverContent
placement="bottom"
sideOffset={4}
popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"
positionerProps={{ style: { zIndex: 32 } }}
>
<div
className="max-h-[330px] overflow-y-auto"
onScroll={handleScroll}
>
{schools.map(school => (
<div
key={school}
className="flex h-8 cursor-pointer items-center truncate rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
title={school}
onClick={() => {
onChange(school)
setOpen(false)
}}
>
{school}
</div>
))}
</div>
</PopoverContent>
)}
</Popover>
)
}

View File

@ -13,9 +13,9 @@ import storybook from 'eslint-plugin-storybook'
import {
HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS,
NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS,
NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
OVERLAY_MIGRATION_LEGACY_BASE_FILES,
OVERLAY_RESTRICTED_IMPORT_PATTERNS,
WEB_RESTRICTED_IMPORT_PATTERNS,
} from './eslint.constants.mjs'
import dify from './plugins/eslint/index.js'
@ -161,13 +161,13 @@ export default antfu(
},
},
{
name: 'dify/no-direct-next-imports',
name: 'dify/restricted-imports',
files: [GLOB_TS, GLOB_TSX],
ignores: ['next/**'],
rules: {
'no-restricted-imports': ['error', {
paths: NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS,
patterns: NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
patterns: WEB_RESTRICTED_IMPORT_PATTERNS,
}],
},
},
@ -183,7 +183,7 @@ export default antfu(
'no-restricted-imports': ['error', {
paths: NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS,
patterns: [
...NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
...WEB_RESTRICTED_IMPORT_PATTERNS,
...OVERLAY_RESTRICTED_IMPORT_PATTERNS,
],
}],

View File

@ -5,7 +5,7 @@ export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS = [
},
]
export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [
const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [
{
group: ['next/image'],
message: 'Do not import next/image. Use native img tags instead.',
@ -20,6 +20,21 @@ export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [
},
]
const BASE_UI_RESTRICTED_IMPORT_PATTERNS = [
{
group: [
'@base-ui/react',
'@base-ui/react/*',
],
message: 'Do not import Base UI directly in web. Use @langgenius/dify-ui/* primitives instead.',
},
]
export const WEB_RESTRICTED_IMPORT_PATTERNS = [
...NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
...BASE_UI_RESTRICTED_IMPORT_PATTERNS,
]
export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
{
group: [