mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
Merge branch 'main' into feat/replayable-stream
This commit is contained in:
commit
26c00ffd85
@ -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",
|
||||
|
||||
@ -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
80
api/uv.lock
generated
@ -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]]
|
||||
|
||||
@ -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
410
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -4,6 +4,7 @@ export default defineConfig({
|
||||
pack: {
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
platform: "node",
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}})`
|
||||
+ ')$',
|
||||
)
|
||||
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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\\]')
|
||||
|
||||
@ -97,6 +97,7 @@ const PluginTasks = () => {
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
nativeButton={false}
|
||||
render={<div />}
|
||||
disabled={!canOpenMenu}
|
||||
>
|
||||
|
||||
@ -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 = []
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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' })])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
159
web/app/education-apply/__tests__/search-input.spec.tsx
Normal file
159
web/app/education-apply/__tests__/search-input.spec.tsx
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
],
|
||||
}],
|
||||
|
||||
@ -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: [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user