Compare commits

..

261 Commits

Author SHA1 Message Date
9d0597c22d feat(web): snippet layout update 2026-05-23 11:06:57 +08:00
5d489ab92d chore(web): remove snippet plan guard 2026-05-23 10:28:22 +08:00
930da499d1 feat(web): add snippet 2026-05-23 10:20:10 +08:00
f1527ef7c1 feat(web): create snippet from workflow 2026-05-23 10:01:25 +08:00
20f89b6e90 feat(web): operations of snippet card 2026-05-23 09:12:43 +08:00
05e69b104a fix(web): label of snippet creation modal 2026-05-23 08:59:46 +08:00
f39b1b6731 Merge branch 'main' into feat/evaluation-fe 2026-05-23 08:54:10 +08:00
a7005efab3 chore(web): remove icon info of snippet 2026-05-22 18:27:37 +08:00
f605288429 feat(web): snippets header nav 2026-05-22 18:13:27 +08:00
2bb3b439e0 refactor(web): snippet list page 2026-05-22 18:06:29 +08:00
75daf8e61b feat(web): update filters in apps 2026-05-22 17:43:24 +08:00
bf30b11d0d merge main 2026-05-22 16:12:02 +08:00
778e472173 merge main 2026-05-22 10:23:30 +08:00
2885ba8519 Merge branch 'main' into feat/evaluation-fe 2026-05-20 11:32:08 +08:00
e23c3d1491 chore(web): remove evaluation frontend 2026-05-19 17:39:28 +08:00
888292564b merge main 2026-05-19 13:58:23 +08:00
124b786dfb fix(web): fix app list 2026-05-05 21:34:55 +08:00
dd54ca0cab feat(web): fix template 2026-05-05 20:41:53 +08:00
8a72e46ce8 feat(web): use api to fetch template 2026-05-05 20:27:35 +08:00
f00f8e020f Merge branch 'main' into jzh 2026-05-05 19:55:21 +08:00
aa078a854c fix(web): evaluation detail in workflow log 2026-04-30 17:19:15 +08:00
712aae4d98 fix(web): workflow log crash 2026-04-30 17:06:56 +08:00
bacadc4d35 Merge branch 'main' into jzh 2026-04-30 16:51:33 +08:00
b060e81824 fix(web): test history of batch test 2026-04-30 16:51:16 +08:00
b45f83492e Merge branch 'main' into jzh 2026-04-30 15:31:04 +08:00
d1e1a4a8ab fix(web): correct templates 2026-04-30 15:30:37 +08:00
4519847e81 Merge branch 'main' into jzh 2026-04-30 14:19:21 +08:00
3763efbc7c fix(web): selected workflow name fix 2026-04-30 14:18:32 +08:00
552f202ca8 Merge branch 'main' into jzh 2026-04-30 13:00:41 +08:00
dc76f4082f fix(web): i18n of switch modal 2026-04-30 12:53:52 +08:00
6d01095586 fix(web): only workflow can switch 2026-04-30 12:41:37 +08:00
b914e48a41 fix(web): add toast for workflow switch 2026-04-30 12:30:22 +08:00
da482ec455 fix(web): custom metric display 2026-04-30 12:20:56 +08:00
48c38ace54 fix(web): rag publisher 2026-04-30 11:02:23 +08:00
2b1496c857 Merge branch 'main' into jzh 2026-04-30 08:54:42 +08:00
c15e437ff7 Merge branch 'main' into jzh 2026-04-30 00:07:30 +08:00
0ac0eccce4 fix(web): correct template of dataset evaluation 2026-04-30 00:06:59 +08:00
678327e994 fix(wbe): style of switch button 2026-04-29 22:48:38 +08:00
b0478f4df7 fix(web): workflow switch to evaluation 2026-04-29 22:25:47 +08:00
00319f0e43 fix(web): snippet run logs 2026-04-29 21:25:50 +08:00
55eb894d8e fix(web): scroll of dataset evaluation page 2026-04-29 20:48:39 +08:00
c59a80a41f fix(web): test detail of dataset 2026-04-29 20:38:54 +08:00
24b482893d fix(web): pipeline batch test template 2026-04-29 20:36:07 +08:00
ad58895b25 fix(web): template of dataset evaluation template 2026-04-29 20:30:43 +08:00
25fc518c5d fix(web): style of config 2026-04-29 20:28:41 +08:00
d92722e7ab fix(web): default metrics for dataset 2026-04-29 20:16:42 +08:00
4041fd7e5c fix(web): auto select model in evaluation 2026-04-29 20:10:37 +08:00
06ea73a19b Merge branch 'main' into jzh 2026-04-29 18:40:11 +08:00
7384a3c121 fix(web): template generate 2026-04-29 18:05:34 +08:00
c18c953a7c fix(web): style of batch test 2026-04-29 17:46:29 +08:00
ae2df0c35e fix(web): style of batch test 2026-04-29 17:28:53 +08:00
dacc7fc740 fix(web): style of conditions 2026-04-29 17:15:32 +08:00
9af2c1252c fix(web): remove node 2026-04-29 16:17:31 +08:00
35bfe26a3a fix(web): default metrics 2026-04-29 16:07:42 +08:00
8686362aeb Merge branch 'main' into jzh 2026-04-29 16:06:27 +08:00
f5955489ec Merge branch 'main' into jzh 2026-04-29 15:34:27 +08:00
aaa15770d5 fix(web): metric descriptions 2026-04-29 15:34:00 +08:00
08c01c4f3f chore(web): remove mock data of evaluation 2026-04-29 15:28:10 +08:00
0903c30060 fix(web): metric icon & tooltips 2026-04-29 15:10:51 +08:00
b420298398 fix(web): style of default metric 2026-04-29 14:31:23 +08:00
2607eb8d32 feat(web): default metrics 2026-04-29 13:56:33 +08:00
d8173b1cda feat(web): add reset button 2026-04-29 13:32:46 +08:00
c56f1a8216 fix(web): detail info in snippet evaluation 2026-04-29 13:11:09 +08:00
31e74371ef Merge branch 'main' into jzh 2026-04-29 12:06:29 +08:00
e48f13f173 Merge branch 'main' into jzh 2026-04-28 16:34:20 +08:00
c574363cf6 chore(web): remove unused data 2026-04-28 16:34:00 +08:00
70fd4a5c88 feat(web): support snippet nav 2026-04-28 16:26:39 +08:00
42889d23e5 fix(web): snippet card 2026-04-28 16:05:26 +08:00
3a7f09a250 fix(web): snippet publish check 2026-04-28 15:42:31 +08:00
d95d4335bf fix(web): snippet can use input fields 2026-04-28 15:30:35 +08:00
735e88f673 fix(web): snippet usage count 2026-04-28 15:03:03 +08:00
c55105bff3 fix(web): snippet add 2026-04-28 14:58:43 +08:00
77afc805e1 fix(web): snippet draft sync 2026-04-28 14:42:51 +08:00
9dd73b4d47 Merge branch 'main' into jzh 2026-04-28 11:02:44 +08:00
f2b12bfef7 Merge branch 'main' into jzh 2026-04-27 15:49:05 +08:00
dbeaf79d77 fix(web): snippet init 2026-04-27 15:45:13 +08:00
63dcb4dd6c refactor(web): remove mock data of snippet detail 2026-04-27 15:01:57 +08:00
9df3a7bcf9 Merge branch 'main' into jzh 2026-04-27 14:47:53 +08:00
89163edd16 Merge branch 'main' into jzh 2026-04-27 13:48:47 +08:00
eaa55aab1e Merge branch 'main' into jzh 2026-04-27 11:45:30 +08:00
8d3a690c0a Merge branch 'main' into jzh 2026-04-24 18:22:52 +08:00
5263a65ed6 Merge branch 'main' into jzh 2026-04-24 17:52:27 +08:00
24d3e8edba Merge branch 'main' into jzh 2026-04-23 16:18:36 +08:00
b371dd2cdf Merge branch 'main' into jzh 2026-04-22 20:44:13 +08:00
597ad8c425 Merge branch 'main' into jzh 2026-04-21 21:01:05 +08:00
33f9d96caa Merge branch 'main' into jzh 2026-04-21 11:06:45 +08:00
689571df22 Merge branch 'main' into jzh 2026-04-20 18:01:17 +08:00
a3242f0634 Merge branch 'main' into jzh 2026-04-20 16:42:16 +08:00
f5112928b3 fix(web): snippet graph view port 2026-04-20 16:39:38 +08:00
bcd87ddc58 fix: publish as evaluation 2026-04-20 16:24:53 +08:00
7c8a87af05 Merge branch 'main' into jzh 2026-04-20 16:11:07 +08:00
8e2d507e5c fix(web): fix selection menu hide 2026-04-20 16:08:58 +08:00
b6fbec066d fix(web): nav icons 2026-04-20 15:47:34 +08:00
bd136cadce fix: evaluation switch button 2026-04-20 14:37:13 +08:00
0a934e1143 fix merge error 2026-04-20 13:46:31 +08:00
c44ba62da3 Merge branch 'main' into jzh 2026-04-20 12:06:18 +08:00
76c0aed05c fix merge error 2026-04-20 11:58:17 +08:00
e7fc22c6b3 Merge branch 'main' into jzh 2026-04-20 10:24:40 +08:00
b91727b804 Merge branch 'main' into jzh 2026-04-20 10:23:09 +08:00
534fd79377 Merge branch 'main' into jzh 2026-04-17 16:46:10 +08:00
3ea4742b29 Merge branch 'main' into jzh 2026-04-17 16:26:19 +08:00
364c0eb6e2 Merge branch 'main' into jzh 2026-04-17 14:57:13 +08:00
322b3ff641 fix(web): fix merge error 2026-04-17 14:49:08 +08:00
38736c154b Merge branch 'main' into jzh 2026-04-17 14:05:23 +08:00
129f681c59 fix(web): slient snippet draft fetching 2026-04-16 12:29:07 +08:00
d776fc0827 fix(web): icon missing 2026-04-16 12:18:48 +08:00
7af6074cb5 Merge branch 'main' into jzh 2026-04-16 12:09:59 +08:00
7aa700bf2b fix(web): fix merge 2026-04-16 12:06:53 +08:00
0d47750b15 Merge branch 'main' into jzh 2026-04-16 11:48:48 +08:00
a9dc57eeef fix(web): input fields form & graph publish 2026-04-16 11:07:26 +08:00
5bfebd371d fix(web): snippet draft sync 2026-04-16 10:31:35 +08:00
f1da2c76d1 fix(web): add page title for snippet 2026-04-15 18:22:20 +08:00
b5dc774093 feat(web): empty list of snippet 2026-04-15 16:53:37 +08:00
b7fe45d800 Merge branch 'main' into jzh 2026-04-15 15:57:48 +08:00
7f5bbe0ee3 fix(web): button import 2026-04-15 15:23:50 +08:00
40632589a2 Merge branch 'main' into jzh 2026-04-15 15:01:33 +08:00
e6e063138e Merge branch 'main' into jzh 2026-04-15 10:20:27 +08:00
605af8d60e Merge branch 'main' into jzh 2026-04-14 14:05:44 +08:00
8747e3a2d3 Merge branch 'main' into jzh 2026-04-14 10:14:13 +08:00
1712a2732a Merge branch 'main' into jzh 2026-04-13 17:22:27 +08:00
46bc76bae3 feat(web): add evaluation cell in workflow log 2026-04-13 17:22:04 +08:00
8c6dda125f Merge branch 'main' into jzh 2026-04-13 14:13:32 +08:00
f6047aafe8 feat(web): metric descriptions 2026-04-13 14:13:04 +08:00
dce5715982 Merge branch 'main' into jzh 2026-04-13 13:35:13 +08:00
ea910b8e7d Merge branch 'main' into jzh 2026-04-13 10:29:55 +08:00
e51af66d95 feat(web): support creator filtering in apps & snippets 2026-04-12 12:19:17 +08:00
f93b287949 feat(web): billing for evaluation & snippets 2026-04-12 11:09:15 +08:00
627fbd2e86 feat(web): rag-pipeline evaluation configuration 2026-04-12 10:36:20 +08:00
e4c056a57a Merge branch 'main' into jzh 2026-04-12 10:16:13 +08:00
23291398ec feat(web): switch warining dialog 2026-04-10 18:13:13 +08:00
79fc352a5a feat(web): evaluation run detail 2026-04-10 17:48:28 +08:00
8b6b3cddea refactor(web): rag-pipeline evaluation 2026-04-10 17:31:10 +08:00
d1ca468c1e Merge branch 'main' into jzh 2026-04-10 17:12:57 +08:00
ce28ad771c feat(web): test history of rag-pipeline evaluation 2026-04-10 17:12:27 +08:00
ba951b01de feat(web): template download 2026-04-10 17:05:14 +08:00
670ab16ea1 feat(web): template download & upload & run in rag-pipeline 2026-04-10 16:51:06 +08:00
4680535ecd Merge branch 'main' into jzh 2026-04-10 13:49:26 +08:00
f96e63460e feat(web): save configuration 2026-04-10 13:49:05 +08:00
2df79c0404 refactor: input fields 2026-04-10 12:04:51 +08:00
acef9630d5 feat(web): input fields display 2026-04-10 11:39:35 +08:00
12c3b2e0cd feat(web): start run 2026-04-10 11:26:12 +08:00
577707ae50 refactor(web): input fields 2026-04-10 11:10:24 +08:00
03325e9750 feat(web): run history of batch test 2026-04-10 10:58:40 +08:00
a7ef8f9c12 Merge branch 'main' into jzh 2026-04-10 10:36:45 +08:00
40284d9f95 refactor(web): batch test of evaluation 2026-04-09 21:19:51 +08:00
5efe8b8bd7 feat(web): add condition 2026-04-09 21:08:45 +08:00
8dc6d736ee refactor(web): store of evaluation 2026-04-09 20:32:53 +08:00
5316372772 feat(web): judgement condition 2026-04-09 20:18:25 +08:00
4d1499ef75 refactor(web): refactor condition group 2026-04-09 19:46:18 +08:00
0438285277 Merge branch 'main' into jzh 2026-04-09 19:24:10 +08:00
4879ea5cd5 feat(web): support variable selecting in variable mapping 2026-04-09 19:23:22 +08:00
2a1761ac06 feat(web): add output 2026-04-09 18:16:30 +08:00
c29245c1cb feat(web): only one evaluation workflow can be added 2026-04-09 17:43:34 +08:00
5069694bba refactor(web): remove unused metric property 2026-04-09 17:30:10 +08:00
d1a80a85c0 refactor(web): evaluation configure schema update 2026-04-09 17:17:15 +08:00
5c93d74dec Merge branch 'main' into jzh 2026-04-09 15:36:00 +08:00
e52dbd49be feat(web): dataset evaluation configure 2026-04-09 15:34:59 +08:00
ccc8a5f278 refactor(web): dataset evaluation 2026-04-09 14:56:22 +08:00
cfb5b9dfea feat(web): dataset evaluation configure fetch 2026-04-09 14:21:01 +08:00
73d95245f8 feat(web): dataset evaluation layout 2026-04-09 13:44:29 +08:00
fb91984fcb feat(web): add evaluation navigation for rag-pipeline 2026-04-09 13:26:43 +08:00
29cb1fa12e Merge branch 'main' into jzh 2026-04-09 13:15:20 +08:00
78240ed199 Merge branch 'main' into jzh 2026-04-09 09:07:12 +08:00
8f8707fd77 Merge branch 'main' into jzh 2026-04-07 16:57:37 +08:00
ed3db06154 feat(web): restrictions of evalution workflow available nodes 2026-04-07 16:12:25 +08:00
7c05a68876 Merge branch 'main' into jzh 2026-04-07 14:41:42 +08:00
6cfc0dd8e1 Merge branch 'main' into jzh 2026-04-07 12:52:13 +08:00
81baeae5c4 fix(web): evaluation workflow switch 2026-04-03 18:22:44 +08:00
a3010bdc0b Merge branch 'main' into jzh 2026-04-03 18:05:54 +08:00
8133e550ed chore: fix pre-hook of web 2026-04-03 16:21:32 +08:00
2bb0eab636 chore(web): mapping row refactor 2026-04-03 16:10:41 +08:00
5311b5d00d feat(web): available evaluation workflow selector 2026-04-03 16:06:33 +08:00
9b02ccdd12 Merge branch 'main' into jzh 2026-04-03 15:15:11 +08:00
231783eebe chore(web): fix lint 2026-04-03 15:13:52 +08:00
756606f478 feat(web): hide card view in evaluation 2026-04-03 14:39:41 +08:00
6651c1c5da feat(web): workflow switch 2026-04-03 14:22:50 +08:00
61e257b2a8 feat(web): app switch api 2026-04-03 13:56:00 +08:00
3ac4caf735 Merge branch 'main' into jzh 2026-04-03 11:28:22 +08:00
268ae1751d Merge branch 'main' into jzh 2026-04-01 09:26:13 +08:00
015cbf850b Merge branch 'main' into jzh 2026-03-31 18:08:24 +08:00
873e13c2fb feat(web): support select node in metric card 2026-03-31 18:07:52 +08:00
688bf7e7a1 feat(web): metric card style 2026-03-31 17:43:56 +08:00
a6ffff3b39 fix(web): fix style of metric selector 2026-03-31 17:22:07 +08:00
023fc55bd5 fix(web): empty state of metric 2026-03-31 17:11:44 +08:00
351b909a53 feat(web): metric card 2026-03-31 17:00:37 +08:00
6bec4f65c9 refactor(web): metric section refactor 2026-03-31 16:28:48 +08:00
74f87ce152 Merge branch 'main' into jzh 2026-03-31 16:13:04 +08:00
92c472ccc7 Merge branch 'main' into jzh 2026-03-30 15:40:23 +08:00
b92b8becd1 feat(web): metric selector 2026-03-30 15:39:52 +08:00
23d0d6a65d chore(web): i18n of metrics 2026-03-30 14:20:43 +08:00
1660067d6e feat(web): judgement model selector 2026-03-30 14:03:37 +08:00
0642475b85 Merge branch 'main' into jzh 2026-03-30 13:30:10 +08:00
8cb634c9bc feat(web): evaluation layout 2026-03-30 11:27:06 +08:00
768b41c3cf Merge branch 'main' into jzh 2026-03-30 11:07:42 +08:00
ca88516d54 refactor(web): refactor evaluation page 2026-03-30 11:06:41 +08:00
871a2a149f refactor(web): split snippet index 2026-03-30 10:32:59 +08:00
60e381eff0 Merge branch 'main' into jzh 2026-03-30 09:48:58 +08:00
768b3eb6f9 feat(web): test run of snippet 2026-03-29 20:55:11 +08:00
2f88da4a6d feat(web): add variable inspect for snippet 2026-03-29 20:23:24 +08:00
a8cdf6964c feat(web): test run button 2026-03-29 20:02:59 +08:00
985c3db4fd feat(web): snippet input field panel layout 2026-03-29 18:02:27 +08:00
9636472db7 refactor(web): snippet main 2026-03-29 17:50:30 +08:00
0ad268aa7d feat(web): snippet publish 2026-03-29 17:29:37 +08:00
a4ea33167d feat(web): block selector in snippet 2026-03-29 17:01:32 +08:00
0f13aabea8 feat(web): input fields in snippet 2026-03-29 16:31:38 +08:00
1e76ef5ccb chore(web): ignore system vars & conversation vars in rag-pipeline and snippet 2026-03-29 15:56:24 +08:00
e6e3229d17 feat(web): input field button style 2026-03-29 15:45:05 +08:00
dccf8e723a feat(web): snippet version panel 2026-03-29 15:26:59 +08:00
c41ba7d627 feat(web): snippet header in graph 2026-03-29 15:02:34 +08:00
a6e9316de3 Merge branch 'main' into jzh 2026-03-29 14:07:49 +08:00
559d326cbd chore(web): mock data of snippet 2026-03-27 17:24:01 +08:00
abedf2506f Merge branch 'main' into jzh 2026-03-27 17:01:27 +08:00
d01428b5bc feat(web): snippet graph draft sync 2026-03-27 16:02:47 +08:00
0de1f17e5c Merge branch 'main' into jzh 2026-03-27 15:23:49 +08:00
17d07a5a43 feat(web): init snippet graph 2026-03-27 15:23:03 +08:00
3bdbea99a3 Merge branch 'main' into jzh 2026-03-27 14:04:10 +08:00
b7683aedb1 Merge branch 'main' into jzh 2026-03-26 21:38:48 +08:00
515036e758 test(web): add tests for snippets 2026-03-26 21:38:22 +08:00
22b382527f feat(web): add snippet to workflow 2026-03-26 21:26:29 +08:00
2cfe4b5b86 feat(web): snippet graph data fetching 2026-03-26 21:11:09 +08:00
6876c8041c feat(web): snippet list data fetching in block selector 2026-03-26 20:58:42 +08:00
7de45584ce refactor: snippets list 2026-03-26 20:41:51 +08:00
5572d7c7e8 Merge branch 'main' into jzh 2026-03-26 20:10:47 +08:00
db0a2fe52e Merge branch 'main' into jzh 2026-03-26 16:29:44 +08:00
f0ae8d6167 fix(web): unused imports caused by merge 2026-03-26 16:28:56 +08:00
2514e181ba Merge branch 'main' into jzh 2026-03-26 16:16:10 +08:00
be2e6e9a14 Merge branch 'main' into jzh 2026-03-26 14:23:29 +08:00
875e2eac1b Merge branch 'main' into jzh 2026-03-26 08:38:57 +08:00
c3c73ceb1f Merge branch 'main' into jzh 2026-03-25 23:02:18 +08:00
6318bf0a2a feat(web): create snippet from workflow 2026-03-25 22:57:48 +08:00
5e1f252046 feat(web): selection context menu style update 2026-03-25 22:36:27 +08:00
df3b960505 fix(web): position of selection context menu in workflow graph 2026-03-25 22:02:50 +08:00
26bc108bf1 chore(web): tests for snippet info 2026-03-25 21:35:36 +08:00
a5cff32743 feat(web): snippet info operations 2026-03-25 21:29:06 +08:00
d418dd8eec Merge branch 'main' into jzh 2026-03-25 20:17:32 +08:00
61702fe346 Merge branch 'main' into jzh 2026-03-25 18:17:03 +08:00
43f0c780c3 Merge branch 'main' into jzh 2026-03-25 15:30:21 +08:00
30ebf2bfa9 Merge branch 'main' into jzh 2026-03-24 07:25:22 +08:00
7e3027b5f7 feat(web): snippet card usage info 2026-03-23 17:02:00 +08:00
b3acf83090 Merge branch 'main' into jzh 2026-03-23 16:46:26 +08:00
36c3d6e48a feat(web): snippet list fetching & display 2026-03-23 16:37:05 +08:00
f782ac6b3c feat(web): create snippets by DSL import 2026-03-23 14:55:36 +08:00
feef2dd1fa feat(web): add snippet creation dialog flow 2026-03-23 11:29:41 +08:00
a716d8789d refactor: extract snippet list components 2026-03-23 10:48:15 +08:00
6816f89189 Merge branch 'main' into jzh 2026-03-23 10:13:45 +08:00
bfcac64a9d Merge branch 'main' into jzh 2026-03-20 15:33:49 +08:00
664eb601a2 feat(web): add api of snippet worfklows 2026-03-20 15:29:53 +08:00
8e5cc4e0aa feat(web): add evaluation api 2026-03-20 15:23:03 +08:00
9f28575903 feat(web): add snippets api 2026-03-20 15:11:33 +08:00
4b9a26a5e6 Merge branch 'main' into jzh 2026-03-20 14:01:34 +08:00
7b85adf1cc Merge branch 'main' into jzh 2026-03-20 10:46:45 +08:00
c964708ebe Merge branch 'main' into jzh 2026-03-18 18:07:20 +08:00
883eb498c0 Merge branch 'main' into jzh 2026-03-18 17:40:51 +08:00
4d3738d225 Merge branch 'main' into feat/evaluation-fe 2026-03-17 10:42:44 +08:00
dd0dee739d Merge branch 'main' into jzh 2026-03-16 15:43:20 +08:00
4d19914fcb Merge branch 'main' into feat/evaluation-fe 2026-03-16 10:47:37 +08:00
887c7710e9 feat: evaluation 2026-03-16 10:46:33 +08:00
7a722773c7 feat: snippet canvas 2026-03-13 17:45:04 +08:00
a763aff58b feat: snippets list 2026-03-13 16:12:42 +08:00
c1011f4e5c feat: add to snippet 2026-03-13 14:29:59 +08:00
f7afa103a5 feat: select snippets 2026-03-13 13:43:29 +08:00
224 changed files with 12181 additions and 1031 deletions

View File

@ -1770,6 +1770,14 @@
"count": 1
}
},
"web/app/components/base/textarea/index.stories.tsx": {
"no-console": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/voice-input/__tests__/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 3

View File

@ -33,7 +33,6 @@ import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Textarea } from '@langgenius/dify-ui/textarea'
import '@langgenius/dify-ui/styles.css' // once, in the app root
```
@ -41,16 +40,16 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./tabs`, `./toggle-group` | Tabs for panels; ToggleGroup for segmented modes. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./tabs`, `./toggle-group` | Tabs for panels; ToggleGroup for segmented modes. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
Utilities:
@ -65,7 +64,7 @@ Use `Form` for the submit boundary. It renders a native `<form>`, preserves Ente
Use `FieldRoot` for each standalone named field. A field must have a stable `name`, a label relationship, and either a `FieldControl` or another control that participates in the same Base UI field context. Prefer a visible label for normal form rows; when the surrounding UI already supplies the visible text, use the matching label primitive visually hidden or put `aria-label` on the actual interactive control. `FieldDescription` and `FieldError` provide the message relationships that screen readers need, while the Dify wrapper adds the default Form Input Set styling from the design system.
Choose the label primitive by the control semantics. Text-like inputs, `Textarea`, input-based `Combobox` / `Autocomplete`, single `Checkbox` / `Radio`, `Switch`, and `NumberField` use `FieldLabel`. Trigger-based `Select` fields use `SelectLabel`; `Slider` fields use `SliderLabel`, with per-thumb `aria-label` only when the thumbs need distinct names. `SelectGroupLabel` and `AutocompleteGroupLabel` only label grouped options inside their popup content; they are not field labels.
Choose the label primitive by the control semantics. Text-like inputs, input-based `Combobox` / `Autocomplete`, single `Checkbox` / `Radio`, `Switch`, and `NumberField` use `FieldLabel`. Trigger-based `Select` fields use `SelectLabel`; `Slider` fields use `SliderLabel`, with per-thumb `aria-label` only when the thumbs need distinct names. `SelectGroupLabel` and `AutocompleteGroupLabel` only label grouped options inside their popup content; they are not field labels.
Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group of related controls, such as checkbox groups, radio groups, multi-thumb sliders, or a section that combines several inputs. For checkbox and radio groups, wrap each option with `FieldItem` and give each option its own label:

View File

@ -113,10 +113,6 @@
"types": "./src/tabs/index.tsx",
"import": "./src/tabs/index.tsx"
},
"./textarea": {
"types": "./src/textarea/index.tsx",
"import": "./src/textarea/index.tsx"
},
"./toggle-group": {
"types": "./src/toggle-group/index.tsx",
"import": "./src/toggle-group/index.tsx"

View File

@ -1,130 +0,0 @@
import { render } from 'vitest-browser-react'
import {
FieldDescription,
FieldError,
FieldLabel,
FieldRoot,
} from '../../field'
import { Form } from '../../form'
import { Textarea } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
const setTextareaValue = (element: HTMLElement | SVGElement, value: string) => {
const textarea = asHTMLElement(element) as HTMLTextAreaElement
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
valueSetter?.call(textarea, value)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('Textarea', () => {
it('should render a labelled textarea through Base UI Field.Control', async () => {
const screen = await render(
<FieldRoot name="description">
<FieldLabel>Description</FieldLabel>
<Textarea defaultValue="A workspace for support automation." />
<FieldDescription>Shown to workspace members.</FieldDescription>
</FieldRoot>,
)
const textarea = screen.getByRole('textbox', { name: 'Description' })
await expect.element(textarea).toHaveValue('A workspace for support automation.')
await expect.element(textarea).toHaveAccessibleDescription('Shown to workspace members.')
await expect.element(textarea).toHaveClass('min-h-20', 'overflow-auto', 'rounded-lg', 'system-sm-regular')
expect(asHTMLElement(textarea.element()).tagName).toBe('TEXTAREA')
})
it('should apply size variants and custom classes', async () => {
const screen = await render(
<label>
Prompt
<Textarea size="large" className="resize-none" />
</label>,
)
await expect.element(screen.getByRole('textbox', { name: 'Prompt' })).toHaveClass(
'rounded-[10px]',
'px-4',
'py-2',
'system-md-regular',
'resize-none',
)
})
it('should call onValueChange and stay controlled until value changes', async () => {
const onValueChange = vi.fn()
const screen = await render(
<label>
Notes
<Textarea value="" onValueChange={onValueChange} />
</label>,
)
const textarea = screen.getByRole('textbox', { name: 'Notes' })
setTextareaValue(textarea.element(), 'a')
expect(onValueChange).toHaveBeenCalledWith('a', expect.any(Object))
await expect.element(textarea).toHaveValue('')
await screen.rerender(
<label>
Notes
<Textarea value="a" onValueChange={onValueChange} />
</label>,
)
await expect.element(screen.getByRole('textbox', { name: 'Notes' })).toHaveValue('a')
})
it('should submit valid values and show validation errors through Base UI Form', async () => {
const onFormSubmit = vi.fn()
const screen = await render(
<Form aria-label="dataset form" onFormSubmit={onFormSubmit}>
<FieldRoot name="summary">
<FieldLabel>Summary</FieldLabel>
<Textarea required minLength={10} />
<FieldError match="valueMissing">Summary is required.</FieldError>
<FieldError match="tooShort">Summary is too short.</FieldError>
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
const saveButton = asHTMLElement(screen.getByRole('button', { name: 'Save' }).element())
saveButton.click()
await vi.waitFor(async () => {
await expect.element(screen.getByText('Summary is required.')).toBeInTheDocument()
await expect.element(screen.getByRole('textbox', { name: 'Summary' })).toHaveAttribute('aria-invalid', 'true')
})
expect(onFormSubmit).not.toHaveBeenCalled()
await screen.rerender(
<Form aria-label="dataset form" onFormSubmit={onFormSubmit}>
<FieldRoot name="summary">
<FieldLabel>Summary</FieldLabel>
<Textarea key="valid-summary" required minLength={10} defaultValue="Long enough summary" />
<FieldError match="valueMissing">Summary is required.</FieldError>
<FieldError match="tooShort">Summary is too short.</FieldError>
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
expect(onFormSubmit).toHaveBeenCalledTimes(1)
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ summary: 'Long enough summary' })
})
it('should pass maxLength to the textarea without rendering a counter', async () => {
const screen = await render(
<label>
Release notes
<Textarea defaultValue="Draft" maxLength={20} />
</label>,
)
const textarea = screen.getByRole('textbox', { name: 'Release notes' })
await expect.element(textarea).toHaveAttribute('maxLength', '20')
expect(screen.container.textContent).not.toContain('5/20')
})
})

View File

@ -1,193 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { Button } from '../button'
import {
FieldDescription,
FieldError,
FieldLabel,
FieldRoot,
} from '../field'
import { Form } from '../form'
import { Textarea } from './index'
const meta = {
title: 'Base/Form/Textarea',
component: Textarea,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Multiline text control built on Base UI Field.Control. Use it with FieldRoot for labelled, described, and validated form fields.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Textarea>
export default meta
type Story = StoryObj<typeof meta>
export const Basic: Story = {
render: () => (
<div className="w-80">
<label htmlFor="workspace-description" className="mb-1 block w-fit py-1 text-text-secondary system-sm-medium">
Workspace description
</label>
<Textarea
id="workspace-description"
name="workspaceDescription"
placeholder="Describe how this workspace is used..."
/>
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="grid w-80 gap-3">
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="small-textarea">
Small
<Textarea id="small-textarea" size="small" name="smallTextarea" placeholder="Short note..." rows={3} />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="medium-textarea">
Medium
<Textarea id="medium-textarea" name="mediumTextarea" placeholder="Add context..." rows={3} />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="large-textarea">
Large
<Textarea id="large-textarea" size="large" name="largeTextarea" placeholder="Write a longer instruction..." rows={3} />
</label>
</div>
),
}
export const States: Story = {
render: () => (
<div className="grid w-80 gap-3">
<FieldRoot name="placeholderState">
<FieldLabel>Placeholder</FieldLabel>
<Textarea placeholder="Add a description..." rows={3} />
</FieldRoot>
<FieldRoot name="filledState">
<FieldLabel>Filled</FieldLabel>
<Textarea defaultValue="Use this dataset for support articles and product FAQs." rows={3} />
</FieldRoot>
<FieldRoot name="invalidState" invalid>
<FieldLabel>Invalid</FieldLabel>
<Textarea defaultValue="Too short" rows={3} />
<FieldError match>Use at least 20 characters.</FieldError>
</FieldRoot>
<FieldRoot name="disabledState">
<FieldLabel>Disabled</FieldLabel>
<Textarea disabled placeholder="Editing is unavailable..." rows={3} />
</FieldRoot>
<FieldRoot name="readonlyState">
<FieldLabel>Read-only</FieldLabel>
<Textarea readOnly defaultValue="Generated from the published workflow configuration." rows={3} />
</FieldRoot>
</div>
),
}
const FormDemo = () => {
const [savedDescription, setSavedDescription] = useState<string | null>(null)
return (
<Form
aria-label="Dataset settings"
className="grid w-80 gap-4"
onFormSubmit={(values) => {
setSavedDescription(String(values.description ?? ''))
}}
>
<FieldRoot name="description">
<FieldLabel>Description</FieldLabel>
<Textarea
required
minLength={20}
maxLength={160}
placeholder="Describe what this dataset contains..."
rows={4}
className="resize-y"
/>
<FieldDescription>Shown to teammates when they choose a knowledge source.</FieldDescription>
<FieldError match="valueMissing">Description is required.</FieldError>
<FieldError match="tooShort">Use at least 20 characters.</FieldError>
</FieldRoot>
<div className="flex justify-end">
<Button type="submit" variant="primary">Save Settings</Button>
</div>
{savedDescription && (
<div className="rounded-lg bg-background-section px-3 py-2 text-text-secondary system-xs-regular">
Saved:
{' '}
{savedDescription}
</div>
)}
</Form>
)
}
export const WithField: Story = {
render: () => <FormDemo />,
}
const ControlledDemo = () => {
const [value, setValue] = useState('Summarize customer feedback into actionable product themes.')
return (
<FieldRoot name="prompt">
<FieldLabel>Prompt</FieldLabel>
<Textarea
value={value}
onValueChange={nextValue => setValue(nextValue)}
rows={4}
className="resize-y"
/>
<FieldDescription>The saved value is updated from the controlled state.</FieldDescription>
</FieldRoot>
)
}
export const Controlled: Story = {
render: () => (
<div className="w-80">
<ControlledDemo />
</div>
),
}
const CharacterCounterDemo = () => {
const maxLength = 120
const [value, setValue] = useState('Summarize customer feedback into actionable product themes.')
return (
<FieldRoot name="limitedPrompt">
<FieldLabel>Prompt</FieldLabel>
<div className="relative">
<Textarea
value={value}
onValueChange={nextValue => setValue(nextValue)}
maxLength={maxLength}
rows={4}
className="resize-y pb-8"
/>
<div className="pointer-events-none absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-text-quaternary system-xs-medium">
<span>{value.length}</span>
/
<span className="text-text-tertiary">{maxLength}</span>
</div>
</div>
<FieldDescription>Character counters are composed at the usage site when the workflow needs one.</FieldDescription>
</FieldRoot>
)
}
export const WithCharacterCounter: Story = {
render: () => (
<div className="w-80">
<CharacterCounterDemo />
</div>
),
}

View File

@ -1,89 +0,0 @@
'use client'
import type { Field as BaseFieldNS } from '@base-ui/react/field'
import type { VariantProps } from 'class-variance-authority'
import type { ComponentPropsWithRef } from 'react'
import { Field as BaseField } from '@base-ui/react/field'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
const textareaVariants = cva(
[
'min-h-20 w-full appearance-none overflow-auto border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
'placeholder:text-components-input-text-placeholder',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'rounded-md px-2 py-1 system-xs-regular',
medium: 'rounded-lg px-3 py-2 system-sm-regular',
large: 'rounded-[10px] px-4 py-2 system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
type TextareaValue = string | number
export type TextareaSize = NonNullable<VariantProps<typeof textareaVariants>['size']>
export type TextareaChangeEventDetails = BaseFieldNS.Control.ChangeEventDetails
type TextareaOnValueChange = (value: string, eventDetails: TextareaChangeEventDetails) => void
type ControlledTextareaProps = {
value: TextareaValue
defaultValue?: never
onValueChange: TextareaOnValueChange
}
type UncontrolledTextareaProps = {
value?: never
defaultValue?: TextareaValue
onValueChange?: TextareaOnValueChange
}
type NativeTextareaProps = Omit<
ComponentPropsWithRef<'textarea'>,
'children' | 'className' | 'defaultValue' | 'onChange' | 'size' | 'value'
>
type TextareaControlProps = ControlledTextareaProps | UncontrolledTextareaProps
type TextareaVariantProps = VariantProps<typeof textareaVariants>
export type TextareaProps
= NativeTextareaProps
& TextareaControlProps
& TextareaVariantProps
& {
children?: never
className?: string
}
export function Textarea({
className,
defaultValue,
onValueChange,
ref,
size = 'medium',
value,
...props
}: TextareaProps) {
return (
<BaseField.Control
className={cn(textareaVariants({ size }), className)}
defaultValue={defaultValue}
onValueChange={onValueChange}
ref={ref}
render={<textarea {...props} />}
value={value}
/>
)
}

View File

@ -0,0 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 2.25C3.41421 2.25 3.75 2.58579 3.75 3V15C3.75 15.4142 3.41421 15.75 3 15.75C2.58579 15.75 2.25 15.4142 2.25 15V3C2.25 2.58579 2.58579 2.25 3 2.25Z" fill="#676F83"/>
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3V15C15.75 15.4142 15.4142 15.75 15 15.75C14.5858 15.75 14.25 15.4142 14.25 15V3C14.25 2.58579 14.5858 2.25 15 2.25Z" fill="#676F83"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 4.5C10.5392 4.5 10.875 4.83579 10.875 5.25V12.75C10.875 13.1642 10.5392 13.5 10.125 13.5H7.875C7.46079 13.5 7.125 13.1642 7.125 12.75V5.25C7.125 4.83579 7.46079 4.5 7.875 4.5H10.125ZM8.625 12H9.375V6H8.625V12Z" fill="#676F83"/>
</svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@ -0,0 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 14.25C15.4142 14.25 15.75 14.5858 15.75 15C15.75 15.4142 15.4142 15.75 15 15.75H3C2.58579 15.75 2.25 15.4142 2.25 15C2.25 14.5858 2.58579 14.25 3 14.25H15Z" fill="#676F83"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 7.125C13.9142 7.125 14.25 7.46079 14.25 7.875V10.125C14.25 10.5392 13.9142 10.875 13.5 10.875H4.5C4.08579 10.875 3.75 10.5392 3.75 10.125V7.875C3.75 7.46079 4.08579 7.125 4.5 7.125H13.5ZM5.25 9.375H12.75V8.625H5.25V9.375Z" fill="#676F83"/>
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75H3C2.58579 3.75 2.25 3.41421 2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15Z" fill="#676F83"/>
</svg>

After

Width:  |  Height:  |  Size: 763 B

View File

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 15V3.75V12.2625V10.6688V15ZM2.5 16.5C1.94772 16.5 1.5 16.0523 1.5 15.5V3.25C1.5 2.69771 1.94772 2.25 2.5 2.25H15.5C16.0523 2.25 16.5 2.69772 16.5 3.25V10.5H15V3.75H3V15H9V16.5H2.5ZM13.0125 17.25L10.35 14.5875L11.4188 13.5375L13.0125 15.1312L16.2 11.9438L17.25 13.0125L13.0125 17.25ZM7.5 9.75H13.5V8.25H7.5V9.75ZM7.5 6.75H13.5V5.25H7.5V6.75ZM4.5 9.75H6V8.25H4.5V9.75ZM4.5 6.75H6V5.25H4.5V6.75Z" fill="#495464"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

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

After

Width:  |  Height:  |  Size: 563 B

View File

@ -513,12 +513,27 @@
"width": 14,
"height": 14
},
"line-others-dhs": {
"body": "<g fill=\"currentColor\"><path d=\"M3 2.25a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3A.75.75 0 0 1 3 2.25m12 0a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M10.125 4.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75zm-1.5 7.5h.75V6h-.75z\" clip-rule=\"evenodd\"/></g>",
"width": 18,
"height": 18
},
"line-others-drag-handle": {
"body": "<g fill=\"none\"><g id=\"Drag Handle\"><path id=\"drag-handle\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 5C6.55228 5 7 4.55228 7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4C5 4.55228 5.44772 5 6 5ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM10 9C10.5523 9 11 8.55228 11 8C11 7.44772 10.5523 7 10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12ZM10 13C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11C9.44772 11 9 11.4477 9 12C9 12.5523 9.44772 13 10 13Z\" fill=\"currentColor\"/></g></g>"
},
"line-others-dvs": {
"body": "<g fill=\"currentColor\"><path d=\"M15 14.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/><path fill-rule=\"evenodd\" d=\"M13.5 7.125a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.75.75h-9a.75.75 0 0 1-.75-.75v-2.25a.75.75 0 0 1 .75-.75zm-8.25 2.25h7.5v-.75h-7.5z\" clip-rule=\"evenodd\"/><path d=\"M15 2.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/></g>",
"width": 18,
"height": 18
},
"line-others-env": {
"body": "<g fill=\"none\"><g id=\"env\"><g id=\"Vector\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 3.33325C1.33325 2.22868 2.22868 1.33325 3.33325 1.33325H12.6666C13.7712 1.33325 14.6666 2.22869 14.6666 3.33325V3.66659C14.6666 4.03478 14.3681 4.33325 13.9999 4.33325C13.6317 4.33325 13.3333 4.03478 13.3333 3.66659V3.33325C13.3333 2.96506 13.0348 2.66659 12.6666 2.66659H3.33325C2.96506 2.66659 2.66659 2.96506 2.66659 3.33325V3.66659C2.66659 4.03478 2.36811 4.33325 1.99992 4.33325C1.63173 4.33325 1.33325 4.03478 1.33325 3.66659V3.33325Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M14.6666 12.6666C14.6666 13.7712 13.7712 14.6666 12.6666 14.6666L3.33325 14.6666C2.22866 14.6666 1.33325 13.7711 1.33325 12.6666L1.33325 12.3333C1.33325 11.9651 1.63173 11.6666 1.99992 11.6666C2.36811 11.6666 2.66659 11.9651 2.66659 12.3333V12.6666C2.66659 13.0348 2.96505 13.3333 3.33325 13.3333L12.6666 13.3333C13.0348 13.3333 13.3333 13.0348 13.3333 12.6666V12.3333C13.3333 11.9651 13.6317 11.6666 13.9999 11.6666C14.3681 11.6666 14.6666 11.9651 14.6666 12.3333V12.6666Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 5.99992C1.33325 5.63173 1.63173 5.33325 1.99992 5.33325H4.33325C4.70144 5.33325 4.99992 5.63173 4.99992 5.99992C4.99992 6.36811 4.70144 6.66658 4.33325 6.66658H2.66659V7.33325H3.99992C4.36811 7.33325 4.66659 7.63173 4.66659 7.99992C4.66659 8.36811 4.36811 8.66658 3.99992 8.66658H2.66659V9.33325H4.33325C4.70144 9.33325 4.99992 9.63173 4.99992 9.99992C4.99992 10.3681 4.70144 10.6666 4.33325 10.6666H1.99992C1.63173 10.6666 1.33325 10.3681 1.33325 9.99992V5.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.4734 5.36186C6.75457 5.27673 7.05833 5.38568 7.22129 5.63012L8.66659 7.79807V5.99992C8.66659 5.63173 8.96506 5.33325 9.33325 5.33325C9.70144 5.33325 9.99992 5.63173 9.99992 5.99992V9.99992C9.99992 10.2937 9.80761 10.5528 9.52644 10.638C9.24527 10.7231 8.94151 10.6142 8.77855 10.3697L7.33325 8.20177V9.99992C7.33325 10.3681 7.03478 10.6666 6.66659 10.6666C6.2984 10.6666 5.99992 10.3681 5.99992 9.99992V5.99992C5.99992 5.70614 6.19222 5.44699 6.4734 5.36186Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11.0768 5.38453C11.4167 5.24292 11.807 5.40364 11.9486 5.74351L12.9999 8.26658L14.0512 5.74351C14.1928 5.40364 14.5831 5.24292 14.923 5.38453C15.2629 5.52614 15.4236 5.91646 15.282 6.25633L13.6153 10.2563C13.5118 10.5048 13.2691 10.6666 12.9999 10.6666C12.7308 10.6666 12.488 10.5048 12.3845 10.2563L10.7179 6.25633C10.5763 5.91646 10.737 5.52614 11.0768 5.38453Z\" fill=\"currentColor\"/></g></g></g>"
},
"line-others-evaluation": {
"body": "<path fill=\"currentColor\" d=\"M3 15V3.75v8.513v-1.594zm-.5 1.5a1 1 0 0 1-1-1V3.25a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7.25H15V3.75H3V15h6v1.5zm10.513.75l-2.663-2.662l1.069-1.05l1.593 1.593l3.188-3.187l1.05 1.068zM7.5 9.75h6v-1.5h-6zm0-3h6v-1.5h-6zm-3 3H6v-1.5H4.5zm0-3H6v-1.5H4.5z\"/>",
"width": 18,
"height": 18
},
"line-others-global-variable": {
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.23814 1.33333H9.76188C10.4844 1.33332 11.0672 1.33332 11.5391 1.37187C12.025 1.41157 12.4518 1.49545 12.8466 1.69664C13.4739 2.01622 13.9838 2.52615 14.3034 3.15336C14.5046 3.54822 14.5884 3.97501 14.6281 4.46091C14.6667 4.93283 14.6667 5.51559 14.6667 6.23811V9.76188C14.6667 10.4844 14.6667 11.0672 14.6281 11.5391C14.5884 12.025 14.5046 12.4518 14.3034 12.8466C13.9838 13.4738 13.4739 13.9838 12.8466 14.3033C12.4518 14.5045 12.025 14.5884 11.5391 14.6281C11.0672 14.6667 10.4844 14.6667 9.7619 14.6667H6.23812C5.51561 14.6667 4.93284 14.6667 4.46093 14.6281C3.97503 14.5884 3.54824 14.5045 3.15338 14.3033C2.52617 13.9838 2.01623 13.4738 1.69666 12.8466C1.49546 12.4518 1.41159 12.025 1.37189 11.5391C1.33333 11.0672 1.33334 10.4844 1.33334 9.76187V6.23812C1.33334 5.5156 1.33333 4.93283 1.37189 4.46091C1.41159 3.97501 1.49546 3.54822 1.69666 3.15336C2.01623 2.52615 2.52617 2.01622 3.15338 1.69664C3.54824 1.49545 3.97503 1.41157 4.46093 1.37187C4.93285 1.33332 5.51561 1.33332 6.23814 1.33333ZM4.5695 2.70078C4.16606 2.73374 3.93427 2.79519 3.7587 2.88465C3.38237 3.0764 3.07641 3.38236 2.88466 3.75868C2.79521 3.93425 2.73376 4.16604 2.70079 4.56949C2.6672 4.98072 2.66668 5.50892 2.66668 6.26666V9.73333C2.66668 10.4911 2.6672 11.0193 2.70079 11.4305C2.73376 11.8339 2.79521 12.0657 2.88466 12.2413C3.07641 12.6176 3.38237 12.9236 3.7587 13.1153C3.93427 13.2048 4.16606 13.2662 4.5695 13.2992C4.98073 13.3328 5.50894 13.3333 6.26668 13.3333H9.73334C10.4911 13.3333 11.0193 13.3328 11.4305 13.2992C11.834 13.2662 12.0658 13.2048 12.2413 13.1153C12.6176 12.9236 12.9236 12.6176 13.1154 12.2413C13.2048 12.0657 13.2663 11.8339 13.2992 11.4305C13.3328 11.0193 13.3333 10.4911 13.3333 9.73333V6.26666C13.3333 5.50892 13.3328 4.98072 13.2992 4.56949C13.2663 4.16604 13.2048 3.93425 13.1154 3.75868C12.9236 3.38236 12.6176 3.0764 12.2413 2.88465C12.0658 2.79519 11.834 2.73374 11.4305 2.70078C11.0193 2.66718 10.4911 2.66666 9.73334 2.66666H6.26668C5.50894 2.66666 4.98073 2.66718 4.5695 2.70078ZM5.08339 5.33333C5.08339 4.96514 5.38187 4.66666 5.75006 4.66666H6.68433C7.324 4.66666 7.87606 5.09677 8.04724 5.70542L8.30138 6.60902L9.2915 5.43554C9.7018 4.94926 10.3035 4.66666 10.9399 4.66666H11C11.3682 4.66666 11.6667 4.96514 11.6667 5.33333C11.6667 5.70152 11.3682 5.99999 11 5.99999H10.9399C10.7005 5.99999 10.4702 6.10616 10.3106 6.29537L8.73751 8.15972L9.23641 9.93357C9.24921 9.97909 9.28574 10 9.31579 10H10.2501C10.6182 10 10.9167 10.2985 10.9167 10.6667C10.9167 11.0349 10.6182 11.3333 10.2501 11.3333H9.31579C8.67612 11.3333 8.12406 10.9032 7.95288 10.2946L7.69871 9.39088L6.70852 10.5644C6.29822 11.0507 5.6965 11.3333 5.06011 11.3333H5.00001C4.63182 11.3333 4.33334 11.0349 4.33334 10.6667C4.33334 10.2985 4.63182 10 5.00001 10H5.06011C5.29949 10 5.52982 9.89383 5.68946 9.70462L7.26258 7.84019L6.76371 6.06642C6.75091 6.0209 6.71438 5.99999 6.68433 5.99999H5.75006C5.38187 5.99999 5.08339 5.70152 5.08339 5.33333Z\" fill=\"currentColor\"/></g>"
},
@ -1025,6 +1040,11 @@
"workflow-if-else": {
"body": "<g fill=\"none\"><g id=\"icons/if-else\"><path id=\"Vector (Stroke)\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.16667 2.98975C7.80423 2.98975 7.51042 2.69593 7.51042 2.3335C7.51042 1.97106 7.80423 1.67725 8.16667 1.67725H11.0833C11.4458 1.67725 11.7396 1.97106 11.7396 2.3335V5.25016C11.7396 5.6126 11.4458 5.90641 11.0833 5.90641C10.7209 5.90641 10.4271 5.6126 10.4271 5.25016V3.91782L7.34474 7.00016L10.4271 10.0825V8.75016C10.4271 8.38773 10.7209 8.09391 11.0833 8.09391C11.4458 8.09391 11.7396 8.38773 11.7396 8.75016V11.6668C11.7396 12.0293 11.4458 12.3231 11.0833 12.3231H8.16667C7.80423 12.3231 7.51042 12.0293 7.51042 11.6668C7.51042 11.3044 7.80423 11.0106 8.16667 11.0106H9.49901L6.14484 7.65641H1.75C1.38756 7.65641 1.09375 7.3626 1.09375 7.00016C1.09375 6.63773 1.38756 6.34391 1.75 6.34391H6.14484L9.49901 2.98975H8.16667Z\" fill=\"currentColor\"/></g></g>"
},
"workflow-input-field": {
"body": "<path fill=\"currentColor\" d=\"M5.333 3.333h2v9.334h-2V14h5.333v-1.333h-2V3.333h2V2H5.333zm-4 1.334a.667.667 0 0 0-.666.666v5.334c0 .368.298.666.666.666h4V10H2V6h3.333V4.667zM10.667 6H14v4h-3.334v1.333h4a.667.667 0 0 0 .667-.666V5.333a.667.667 0 0 0-.667-.666h-4z\"/>",
"width": 16,
"height": 16
},
"workflow-iteration": {
"body": "<g fill=\"none\"><g id=\"icons/iteration\"><path id=\"Vector\" d=\"M6.82849 0.754349C6.6007 0.526545 6.23133 0.526545 6.00354 0.754349C5.77573 0.982158 5.77573 1.3515 6.00354 1.57931L6.82849 0.754349ZM8.16602 2.91683L8.57849 3.32931C8.80628 3.1015 8.80628 2.73216 8.57849 2.50435L8.16602 2.91683ZM6.00354 4.25435C5.77573 4.48216 5.77573 4.8515 6.00354 5.07931C6.23133 5.30711 6.6007 5.30711 6.82849 5.07931L6.00354 4.25435ZM7.99516 9.74597C8.22295 9.51818 8.22295 9.14881 7.99516 8.92102C7.76737 8.69323 7.398 8.69323 7.17021 8.92102L7.99516 9.74597ZM5.83268 11.0835L5.4202 10.671C5.1924 10.8988 5.1924 11.2682 5.4202 11.496L5.83268 11.0835ZM7.17021 13.246C7.398 13.4738 7.76737 13.4738 7.99516 13.246C8.22295 13.0182 8.22295 12.6488 7.99516 12.421L7.17021 13.246ZM11.4993 3.73414C11.2738 3.50404 10.9045 3.5003 10.6744 3.72578C10.4443 3.95127 10.4405 4.32059 10.6661 4.55069L11.4993 3.73414ZM7.58268 3.50016C7.90486 3.50016 8.16602 3.23899 8.16602 2.91683C8.16602 2.59467 7.90486 2.3335 7.58268 2.3335L7.58268 3.50016ZM2.49938 10.2662C2.72486 10.4963 3.09419 10.5 3.32429 10.2745C3.55439 10.0491 3.55814 9.6797 3.33266 9.44964L2.49938 10.2662ZM6.00354 1.57931L7.75354 3.32931L8.57849 2.50435L6.82849 0.754349L6.00354 1.57931ZM7.75354 2.50435L6.00354 4.25435L6.82849 5.07931L8.57849 3.32931L7.75354 2.50435ZM7.17021 8.92102L5.4202 10.671L6.24516 11.496L7.99516 9.74597L7.17021 8.92102ZM5.4202 11.496L7.17021 13.246L7.99516 12.421L6.24516 10.671L5.4202 11.496ZM8.16602 10.5002L6.41602 10.5002V11.6668L8.16602 11.6668V10.5002ZM11.666 7.00016C11.666 8.93316 10.099 10.5002 8.16602 10.5002V11.6668C10.7434 11.6668 12.8327 9.57751 12.8327 7.00016H11.666ZM12.8327 7.00016C12.8327 5.72882 12.3235 4.57524 11.4993 3.73414L10.6661 4.55069C11.2852 5.18256 11.666 6.0463 11.666 7.00016H12.8327ZM5.83268 3.50016H7.58268L7.58268 2.3335H5.83268L5.83268 3.50016ZM2.33268 7.00016C2.33268 5.06717 3.89968 3.50016 5.83268 3.50016L5.83268 2.3335C3.25535 2.3335 1.16602 4.42283 1.16602 7.00016H2.33268ZM1.16602 7.00016C1.16602 8.27148 1.67517 9.42508 2.49938 10.2662L3.33266 9.44964C2.71348 8.81777 2.33268 7.95403 2.33268 7.00016H1.16602Z\" fill=\"currentColor\"/></g></g>"
},

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 277,
"total": 281,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@ -340,16 +340,11 @@ describe('App List Browsing Flow', () => {
// -- Tab navigation --
describe('Tab Navigation', () => {
it('should render all category tabs', () => {
it('should render the app type dropdown trigger', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
})
})
@ -380,21 +375,19 @@ describe('App List Browsing Flow', () => {
// -- "Created by me" filter --
describe('Created By Me Filter', () => {
it('should render the "created by me" checkbox', () => {
it('should not render a standalone "created by me" checkbox in the current header layout', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
})
it('should toggle the "created by me" filter on click', () => {
it('should keep the current layout stable without a "created by me" control', () => {
mockPages = [createPage([createMockApp()])]
renderList()
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
fireEvent.click(checkbox)
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
})
})

View File

@ -493,8 +493,8 @@ describe('Capacity Full Components Integration', () => {
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
// Should show usage/total fraction "5/5"
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
// Should have an accessible meter rendered
expect(screen.getByRole('meter', { name: /usagePage\.buildApps/i })).toBeInTheDocument()
// Should have a meter rendered
expect(screen.getByRole('meter')).toBeInTheDocument()
})
it('should display upgrade tip and upgrade button for professional plan', () => {

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import SnippetList from '@/app/components/snippet-list'
const SnippetsPage = () => {
return <SnippetList />
}
export default SnippetsPage

View File

@ -1,10 +1,10 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import { useAppContext } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
@ -63,12 +63,11 @@ export default function FeedBack(props: DeleteAccountProps) {
</DialogTitle>
<label className="mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary">{t('account.feedbackLabel', { ns: 'common' })}</label>
<Textarea
aria-label={t('account.feedbackLabel', { ns: 'common' }) as string}
rows={6}
value={userFeedback}
placeholder={t('account.feedbackPlaceholder', { ns: 'common' }) as string}
onValueChange={(value) => {
setUserFeedback(value)
onChange={(e) => {
setUserFeedback(e.target.value)
}}
/>
<div className="mt-3 flex w-full flex-col gap-2">

View File

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

View File

@ -37,12 +37,16 @@ type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
appInfoActions?: AppInfoActions
}
const AppDetailNav = ({
navigation,
extraInfo,
renderHeader,
renderNavigation,
iconType = 'app',
appInfoActions,
}: IAppDetailNavProps) => {
@ -122,18 +126,20 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
appInfoActions
? (
<AppInfoView
expand={expand}
actions={appInfoActions}
renderDetail={false}
/>
)
: <AppInfo expand={expand} />
)}
{iconType !== 'app' && (
{renderHeader
? renderHeader(appSidebarExpand)
: iconType === 'app' && (
appInfoActions
? (
<AppInfoView
expand={expand}
actions={appInfoActions}
renderDetail={false}
/>
)
: <AppInfo expand={expand} />
)}
{!renderHeader && iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@ -162,18 +168,20 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
return (
<NavLink
key={index}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
{renderNavigation
? renderNavigation(appSidebarExpand)
: navigation.map((item, index) => {
return (
<NavLink
key={index}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,46 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import SnippetInfoDropdown from './dropdown'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
const { t } = useTranslation('snippet')
if (!expand)
return null
return (
<div className="flex flex-col px-2 pt-2 pb-1">
<div className="flex flex-col gap-2 rounded-xl p-2">
<div className="flex items-center justify-end">
<SnippetInfoDropdown snippet={snippet} />
</div>
<div className="min-w-0">
<div className="truncate system-md-semibold text-text-secondary">
{snippet.name}
</div>
<div className="pt-1 system-2xs-medium-uppercase text-text-tertiary">
{t('typeLabel')}
</div>
</div>
{snippet.description && (
<p className="line-clamp-3 system-xs-regular break-words text-text-tertiary">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@ -1,9 +1,9 @@
'use client'
import type { FC } from 'react'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
import Textarea from '@/app/components/base/textarea'
export enum EditItemType {
Query = 'query',
@ -33,9 +33,8 @@ const EditItem: FC<Props> = ({
<div className="grow">
<div className="mb-1 system-xs-semibold text-text-primary">{name}</div>
<Textarea
aria-label={name}
value={content}
onValueChange={value => onChange(value)}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
placeholder={placeholder}
autoFocus
/>

View File

@ -2,12 +2,12 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
import Textarea from '@/app/components/base/textarea'
export enum EditItemType {
Query = 'query',
@ -130,9 +130,8 @@ const EditItem: FC<Props> = ({
<div className="mt-3">
<EditTitle title={editTitle} />
<Textarea
aria-label={editTitle}
value={newContent}
onValueChange={value => setNewContent(value)}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewContent(e.target.value)}
placeholder={placeholder}
autoFocus
/>

View File

@ -1,7 +1,6 @@
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { FileUpload } from '@/app/components/base/features/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher'
import type { Features, FileUpload } from '@/app/components/base/features/types'
import type { ModelConfig } from '@/models/debug'
import {
AlertDialog,
AlertDialogActions,
@ -21,9 +20,15 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
type PublishedModelConfig = ModelConfig & {
resetAppConfig?: () => void
}
type Props = Omit<AppPublisherProps, 'onPublish'> & {
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
publishedConfig?: any
onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise<unknown> | unknown
publishedConfig: {
modelConfig: PublishedModelConfig
}
resetAppConfig?: () => void
}
@ -71,7 +76,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
setRestoreConfirmOpen(false)
}, [featuresStore, props])
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
const handlePublish = useCallback((params?: AppPublisherPublishParams) => {
return props.onPublish?.(params, features)
}, [features, props])

View File

@ -86,8 +86,10 @@ export type AppPublisherProps = {
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
type AppPublisherPublishHandler
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
= | ((params?: AppPublisherPublishParams) => Promise<unknown> | unknown)
| ((params?: unknown) => Promise<unknown> | unknown)
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown

View File

@ -3,12 +3,12 @@ import type { VersionHistory } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '../../base/textarea'
type VersionInfoModalProps = {
isOpen: boolean
@ -57,8 +57,8 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
onClose()
}
const handleDescriptionChange = useCallback((value: string) => {
setReleaseNotes(value)
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setReleaseNotes(e.target.value)
}, [])
return (
@ -95,16 +95,17 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
onValueChange={setTitle}
/>
</FieldRoot>
<FieldRoot name="releaseNotes" invalid={releaseNotesError} className="gap-y-1">
<FieldLabel className="flex h-6 items-center py-0 system-sm-semibold text-text-secondary">
<div className="flex flex-col gap-y-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
{t('versionHistory.editField.releaseNotes', { ns: 'workflow' })}
</FieldLabel>
</div>
<Textarea
value={releaseNotes}
placeholder={`${t('versionHistory.releaseNotesPlaceholder', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`}
onValueChange={handleDescriptionChange}
onChange={handleDescriptionChange}
destructive={releaseNotesError}
/>
</FieldRoot>
</div>
</div>
<div className="flex justify-end p-6 pt-5">
<div className="flex items-center gap-x-3">

View File

@ -13,12 +13,12 @@ import {
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { Trans } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -121,9 +121,8 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
{type === InputVarType.paragraph && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Textarea
aria-label={t('variableConfig.defaultValue', { ns: 'appDebug' })}
value={String(tempPayload.default ?? '')}
onValueChange={value => onPayloadChange('default')(value || undefined)}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>

View File

@ -96,7 +96,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType.chat,
mode: mode as unknown as ModelModeType,
completion_params: {} as CompletionParams,
})
const {

View File

@ -1,11 +1,11 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import Textarea from '@/app/components/base/textarea'
const i18nPrefix = 'generate'
@ -40,11 +40,10 @@ const IdeaOutput: FC<Props> = ({
</div>
{!isFoldIdeaOutput && (
<Textarea
aria-label={t(`${i18nPrefix}.idealOutput`, { ns: 'appDebug' })}
className="h-[80px]"
placeholder={t(`${i18nPrefix}.idealOutputPlaceholder`, { ns: 'appDebug' })}
value={value}
onValueChange={value => onChange(value)}
onChange={e => onChange(e.target.value)}
/>
)}
</div>

View File

@ -78,7 +78,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType.chat,
mode: mode as unknown as ModelModeType,
completion_params: defaultCompletionParams,
})
const {

View File

@ -4,13 +4,13 @@ import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from 'es-toolkit/predicate'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import IndexMethod from '@/app/components/datasets/settings/index-method'
@ -224,9 +224,8 @@ const SettingsModal: FC<SettingsModalProps> = ({
</div>
<div className="w-full">
<Textarea
aria-label={t('form.desc', { ns: 'datasetSettings' })}
value={localeCurrentDataset.description || ''}
onValueChange={value => handleValueChange('description', value)}
onChange={e => handleValueChange('description', e.target.value)}
className="resize-none"
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
/>

View File

@ -84,6 +84,25 @@ vi.mock('@langgenius/dify-ui/select', async () => {
}
})
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, placeholder, readOnly, className }: {
value: string
onChange: (e: { target: { value: string } }) => void
placeholder?: string
readOnly?: boolean
className?: string
}) => (
<textarea
data-testid={`textarea-${placeholder}`}
value={value}
onChange={onChange}
placeholder={placeholder}
readOnly={readOnly}
className={className}
/>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
default: ({ name, value, required, onChange, readonly }: {
name: string
@ -204,7 +223,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{}} />)
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
})
it('should render select input type', () => {
@ -256,7 +275,7 @@ describe('ChatUserInput', () => {
render(<ChatUserInput inputs={{}} />)
expect(screen.getByTestId('input-Name')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
expect(screen.getByTestId('select-input')).toBeInTheDocument()
})
@ -315,7 +334,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{ desc: 'Long text here' }} />)
expect(screen.getByRole('textbox', { name: 'Description' })).toHaveValue('Long text here')
expect(screen.getByTestId('textarea-Description')).toHaveValue('Long text here')
})
it('should display existing input values for number type', () => {
@ -399,7 +418,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{}} />)
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { target: { value: 'New Description' } })
fireEvent.change(screen.getByTestId('textarea-Description'), { target: { value: 'New Description' } })
expect(mockSetInputs).toHaveBeenCalledWith({ desc: 'New Description' })
})
@ -507,7 +526,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{}} />)
expect(screen.getByRole('textbox', { name: 'Description' })).toHaveAttribute('readonly')
expect(screen.getByTestId('textarea-Description')).toHaveAttribute('readonly')
})
it('should disable select when readonly is true', () => {

View File

@ -1,12 +1,12 @@
import type { Inputs } from '@/models/debug'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import ConfigContext from '@/context/debug-configuration'
@ -94,10 +94,9 @@ const ChatUserInput = ({
{type === 'paragraph' && (
<Textarea
className="h-[120px] grow"
aria-label={name || key}
placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''}
onValueChange={(value) => { handleInputValueChange(key, value) }}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
readOnly={readonly}
/>
)}

View File

@ -1,5 +1,6 @@
'use client'
import type { ComponentProps } from 'react'
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher'
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { Features as FeaturesData, OnFeaturesChange } from '@/app/components/base/features/types'
@ -21,7 +22,6 @@ import type {
TextToSpeechConfig,
} from '@/models/debug'
import type { VisionSettings } from '@/types/app'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
@ -481,7 +481,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
resolvedModelModeType,
])
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => {
const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params
? params
: undefined

View File

@ -5,7 +5,6 @@ import type { VisionFile, VisionSettings } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiArrowDownSLine,
@ -20,6 +19,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum, ModelModeType } from '@/types/app'
@ -151,11 +151,10 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
)}
{type === 'paragraph' && (
<Textarea
aria-label={name}
className="h-[120px] grow"
placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''}
onValueChange={(value) => { handleInputValueChange(key, value) }}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
readOnly={readonly}
/>
)}

View File

@ -4,7 +4,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
@ -14,6 +13,7 @@ import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
@ -240,11 +240,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</span>
</div>
<Textarea
aria-label={t('newApp.captionDescription', { ns: 'app' })}
className="resize-none"
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
value={description}
onValueChange={value => setDescription(value)}
onChange={e => setDescription(e.target.value)}
/>
</div>
</div>
@ -338,29 +337,40 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
function AppPreview({ mode }: { mode: AppModeEnum }) {
const { t } = useTranslation()
const modeToPreviewInfoMap = {
[AppModeEnum.CHAT]: {
title: t('types.chatbot', { ns: 'app' }),
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
},
[AppModeEnum.ADVANCED_CHAT]: {
title: t('types.advanced', { ns: 'app' }),
description: t('newApp.advancedUserDescription', { ns: 'app' }),
},
[AppModeEnum.AGENT_CHAT]: {
title: t('types.agent', { ns: 'app' }),
description: t('newApp.agentUserDescription', { ns: 'app' }),
},
[AppModeEnum.COMPLETION]: {
title: t('newApp.completeApp', { ns: 'app' }),
description: t('newApp.completionUserDescription', { ns: 'app' }),
},
[AppModeEnum.WORKFLOW]: {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
},
}
const previewInfo = modeToPreviewInfoMap[mode]
const previewInfo = (() => {
switch (mode) {
case AppModeEnum.CHAT:
return {
title: t('types.chatbot', { ns: 'app' }),
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
}
case AppModeEnum.ADVANCED_CHAT:
return {
title: t('types.advanced', { ns: 'app' }),
description: t('newApp.advancedUserDescription', { ns: 'app' }),
}
case AppModeEnum.AGENT_CHAT:
return {
title: t('types.agent', { ns: 'app' }),
description: t('newApp.agentUserDescription', { ns: 'app' }),
}
case AppModeEnum.COMPLETION:
return {
title: t('newApp.completeApp', { ns: 'app' }),
description: t('newApp.completionUserDescription', { ns: 'app' }),
}
case AppModeEnum.WORKFLOW:
return {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
}
default:
return {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
}
}
})()
return (
<div className="px-8 py-4">
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>

View File

@ -8,7 +8,6 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
@ -19,6 +18,7 @@ import AppIconPicker from '@/app/components/base/app-icon-picker'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { PremiumBadgeButton } from '@/app/components/base/premium-badge'
import Textarea from '@/app/components/base/textarea'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -289,10 +289,9 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<div className="relative">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
<Textarea
aria-label={t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}
className="mt-1"
value={inputInfo.desc}
onValueChange={onDesChange}
onChange={e => onDesChange(e.target.value)}
placeholder={t(`${prefixSettings}.webDescPlaceholder`, { ns: 'appOverview' }) as string}
/>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
@ -465,10 +464,9 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
<Textarea
aria-label={t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}
className="mt-1"
value={inputInfo.customDisclaimer}
onValueChange={value => setInputInfo(item => ({ ...item, customDisclaimer: value }))}
onChange={onChange('customDisclaimer')}
placeholder={t(`${prefixSettings}.more.customDisclaimerPlaceholder`, { ns: 'appOverview' }) as string}
/>
</div>

View File

@ -7,8 +7,8 @@ import {
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { InputVarType } from '@/app/components/workflow/types'
@ -74,7 +74,7 @@ const WorkflowHiddenInputFields = ({
<Textarea
id={fieldId}
value={typeof fieldValue === 'string' ? fieldValue : ''}
onValueChange={value => onValueChange(variable.variable, value)}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onValueChange(variable.variable, event.target.value)}
placeholder={label}
maxLength={variable.max_length}
className="min-h-24"

View File

@ -2,6 +2,8 @@ import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Empty from '../empty'
const defaultMessage = 'workflow.tabs.noSnippetsFound'
describe('Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -9,32 +11,32 @@ describe('Empty', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
render(<Empty message={defaultMessage} />)
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
})
it('should render 36 placeholder cards', () => {
const { container } = render(<Empty />)
const { container } = render(<Empty message={defaultMessage} />)
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
expect(placeholderCards).toHaveLength(36)
})
it('should display the no apps found message', () => {
render(<Empty />)
it('should display the provided message', () => {
render(<Empty message="app.newApp.noAppsFound" />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling for overlay', () => {
const { container } = render(<Empty />)
const { container } = render(<Empty message={defaultMessage} />)
const overlay = container.querySelector('.pointer-events-none')
expect(overlay).toBeInTheDocument()
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
})
it('should have correct styling for placeholder cards', () => {
const { container } = render(<Empty />)
const { container } = render(<Empty message={defaultMessage} />)
const card = container.querySelector('.bg-background-default-lighter')
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
})
@ -42,10 +44,10 @@ describe('Empty', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
const { rerender } = render(<Empty message={defaultMessage} />)
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
rerender(<Empty />)
rerender(<Empty message="app.newApp.noAppsFound" />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})

View File

@ -45,18 +45,19 @@ vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
userProfile: { id: 'creator-1' },
}),
}))
const mockSetKeywords = vi.fn()
const mockSetTagIDs = vi.fn()
const mockSetIsCreatedByMe = vi.fn()
const mockSetCreatorID = vi.fn()
const mockSetCategory = vi.fn()
const mockQueryState = {
category: 'all',
tagIDs: [] as string[],
keywords: '',
isCreatedByMe: false,
creatorID: '',
}
vi.mock('../hooks/use-apps-query-state', () => ({
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
@ -65,7 +66,18 @@ vi.mock('../hooks/use-apps-query-state', () => ({
setCategory: mockSetCategory,
setKeywords: mockSetKeywords,
setTagIDs: mockSetTagIDs,
setIsCreatedByMe: mockSetIsCreatedByMe,
setCreatorID: mockSetCreatorID,
}),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
],
},
}),
}))
@ -190,9 +202,9 @@ vi.mock('../app-card', () => ({
}))
vi.mock('../new-app-card', () => ({
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
}),
default: ({ ref: _ref }: { ref?: React.Ref<HTMLDivElement> }) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button', 'ref': _ref }, 'New App Card')
},
}))
vi.mock('../empty', () => ({
@ -229,11 +241,15 @@ beforeAll(() => {
// Render helper wrapping with shared nuqs testing helper plus a seeded
// systemFeatures cache so List can resolve its useSuspenseQuery.
const renderList = (searchParams = '') => {
const renderList = (searchParams = '', pageType: 'apps' | 'snippets' = 'apps') => {
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
return renderWithNuqs(<SystemFeaturesWrapper><List pageType={pageType} /></SystemFeaturesWrapper>, { searchParams })
}
const openTypeFilter = () => {
fireEvent.click(screen.getByRole('button', { name: /^app\.(studio\.filters\.types|types\.)/ }))
}
type AppListInfiniteOptions = {
@ -255,7 +271,7 @@ describe('List', () => {
mockQueryState.category = 'all'
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockQueryState.creatorID = ''
mockUseWorkflowOnlineUsers.mockClear()
intersectionCallback = null
localStorage.clear()
@ -264,11 +280,12 @@ describe('List', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
renderList()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
it('should render app type dropdown with all app types', () => {
renderList()
openTypeFilter()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
@ -288,9 +305,21 @@ describe('List', () => {
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
})
it('should render created by me checkbox', () => {
it('should render creators filter', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
})
it('should render link to snippets on apps page', () => {
renderList()
expect(screen.getByRole('link', { name: 'app.studio.viewSnippets' })).toHaveAttribute('href', '/snippets')
})
it('should not render link to snippets on snippets page', () => {
renderList('', 'snippets')
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
@ -325,20 +354,22 @@ describe('List', () => {
})
})
describe('Tab Navigation', () => {
it('should update category when workflow tab is clicked', () => {
describe('Type Filter', () => {
it('should update category when workflow type is selected', () => {
renderList()
openTypeFilter()
fireEvent.click(screen.getByText('app.types.workflow'))
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.workflow' }))
expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
})
it('should update category when all tab is clicked', () => {
it('should update category when all type is selected', () => {
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
openTypeFilter()
fireEvent.click(screen.getByText('app.types.all'))
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.all' }))
expect(mockSetCategory).toHaveBeenCalledWith('all')
})
@ -364,10 +395,7 @@ describe('List', () => {
renderList()
const clearButton = document.querySelector('.group')
expect(clearButton)!.toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(mockSetKeywords).toHaveBeenCalledWith('')
})
@ -377,7 +405,7 @@ describe('List', () => {
it('should build paged query input from active filters', () => {
mockQueryState.tagIDs = ['tag-1']
mockQueryState.keywords = 'sales'
mockQueryState.isCreatedByMe = true
mockQueryState.creatorID = 'creator-1'
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
@ -390,7 +418,7 @@ describe('List', () => {
limit: 30,
name: 'sales',
tag_ids: ['tag-1'],
is_created_by_me: true,
creator_id: 'creator-1',
mode: AppModeEnum.WORKFLOW,
},
})
@ -406,19 +434,19 @@ describe('List', () => {
})
})
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
describe('Creators Filter', () => {
it('should render creators filter with correct label', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
})
it('should handle checkbox change', () => {
it('should handle creator selection as a single creator filter', () => {
renderList()
const checkbox = screen.getByRole('checkbox', { name: 'app.showMyCreatedAppsOnly' })
fireEvent.click(checkbox)
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true)
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
})
})
@ -464,11 +492,11 @@ describe('List', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { unmount } = renderList()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
unmount()
renderList()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
})
it('should render app cards correctly', () => {
@ -481,9 +509,10 @@ describe('List', () => {
it('should render with all filter options visible', () => {
renderList()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
})
})
@ -500,9 +529,10 @@ describe('List', () => {
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
describe('App Type Dropdown', () => {
it('should render all app type options', () => {
renderList()
openTypeFilter()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
@ -512,9 +542,7 @@ describe('List', () => {
expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
})
it('should update category for each app type tab click', () => {
renderList()
it('should update category for each app type option click', () => {
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
@ -525,8 +553,11 @@ describe('List', () => {
for (const { mode, text } of appTypeTexts) {
mockSetCategory.mockClear()
fireEvent.click(screen.getByText(text))
const { unmount } = renderList()
openTypeFilter()
fireEvent.click(screen.getByRole('menuitemradio', { name: text }))
expect(mockSetCategory).toHaveBeenCalledWith(mode)
unmount()
}
})
})

View File

@ -0,0 +1,16 @@
import { parseAsStringLiteral } from 'nuqs'
import { AppModes } from '@/types/app'
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
export type { AppListCategory }
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })

View File

@ -0,0 +1,76 @@
'use client'
import type { AppListCategory } from './app-type-filter-shared'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { AppModeEnum } from '@/types/app'
import { isAppListCategory } from './app-type-filter-shared'
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
type AppTypeFilterProps = {
value: AppListCategory
onChange: (value: AppListCategory) => void
}
export function AppTypeFilter({
value,
onChange,
}: AppTypeFilterProps) {
const { t } = useTranslation()
const options = useMemo(() => ([
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
]), [t])
const activeOption = options.find(option => option.value === value)
const isSelected = value !== 'all'
const triggerLabel = isSelected ? activeOption?.text : t('studio.filters.types', { ns: 'app' })
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(
chipClassName,
isSelected
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
)}
/>
)}
>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
<span className="px-1 text-text-tertiary">{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
<DropdownMenuRadioGroup value={value} onValueChange={nextValue => isAppListCategory(nextValue) && onChange(nextValue)}>
{options.map(option => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
<span>{option.text}</span>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,230 @@
'use client'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Input } from '@langgenius/dify-ui/input'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { useMembers } from '@/service/use-common'
type CreatorsFilterProps = {
value: string[]
onChange: (value: string[]) => void
}
type CreatorOption = {
id: string
name: string
avatarUrl: string | null
isYou: boolean
}
const baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
const CreatorsFilter = ({
value,
onChange,
}: CreatorsFilterProps) => {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const { data: membersData } = useMembers()
const [keywords, setKeywords] = useState('')
const creatorOptions = useMemo<CreatorOption[]>(() => {
const currentUserId = userProfile?.id
const members = membersData?.accounts ?? []
return [...members]
.filter(member => member.status !== 'pending')
.sort((left, right) => {
if (left.id === currentUserId)
return -1
if (right.id === currentUserId)
return 1
return left.name.localeCompare(right.name)
})
.map(member => ({
id: member.id,
name: member.name,
avatarUrl: member.avatar_url,
isYou: member.id === currentUserId,
}))
}, [membersData?.accounts, userProfile?.id])
const filteredCreators = useMemo(() => {
const normalizedKeywords = keywords.trim().toLowerCase()
if (!normalizedKeywords)
return creatorOptions
return creatorOptions.filter((creator) => {
const keyword = normalizedKeywords
return creator.name.toLowerCase().includes(keyword)
})
}, [creatorOptions, keywords])
const selectedCreators = useMemo(() => {
const creatorMap = new Map(creatorOptions.map(creator => [creator.id, creator]))
return value
.map(id => creatorMap.get(id))
.filter((creator): creator is CreatorOption => Boolean(creator))
}, [creatorOptions, value])
const toggleCreator = useCallback((creatorId: string) => {
if (value.includes(creatorId)) {
onChange(value.filter(id => id !== creatorId))
return
}
onChange([...value, creatorId])
}, [onChange, value])
const resetCreators = useCallback(() => {
onChange([])
setKeywords('')
}, [onChange])
const selectedCount = value.length
const selectedAvatarCreators = selectedCreators.slice(0, 3)
const isSelected = selectedCount > 0
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(
baseChipClassName,
isSelected
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
)}
/>
)}
>
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
{!isSelected && (
<>
<span className="px-1 text-text-tertiary">{t('studio.filters.allCreators', { ns: 'app' })}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</>
)}
{isSelected && (
<>
<span className="px-1 text-text-tertiary">{t('studio.filters.creators', { ns: 'app' })}</span>
<span className="flex items-center pr-1">
{selectedAvatarCreators.map((creator, index) => (
<Avatar
key={creator.id}
avatar={creator.avatarUrl}
name={creator.name}
size="xs"
className={cn(
'border border-components-panel-bg',
index > 0 && '-ml-1',
)}
/>
))}
</span>
<span className="text-xs leading-4 font-medium text-text-tertiary">{`+${selectedCount}`}</span>
<span
role="button"
tabIndex={0}
aria-label={t('studio.filters.reset', { ns: 'app' })}
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-xs text-text-quaternary hover:text-text-tertiary"
onClick={(event) => {
event.stopPropagation()
resetCreators()
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ')
return
event.preventDefault()
event.stopPropagation()
resetCreators()
}}
>
<span aria-hidden className="i-ri-close-circle-fill h-3.5 w-3.5" />
</span>
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
<div className="flex items-center gap-1 p-2 pb-1">
<div className="relative min-w-0 grow">
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
<Input
className={cn('pl-6.5', keywords && 'pr-6.5')}
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
/>
{!!keywords && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
onClick={() => setKeywords('')}
>
<span aria-hidden className="i-ri-close-circle-fill size-4" />
</button>
)}
</div>
{isSelected && (
<button
type="button"
className="shrink-0 rounded-sm px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={resetCreators}
>
{t('studio.filters.reset', { ns: 'app' })}
</button>
)}
</div>
<div className="max-h-60 overflow-y-auto px-1 pb-1">
{filteredCreators.map((creator) => {
const checked = value.includes(creator.id)
return (
<button
key={creator.id}
type="button"
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => toggleCreator(creator.id)}
>
<Checkbox
id={creator.id}
checked={checked}
className="shrink-0"
/>
<div className="flex min-w-0 grow items-center gap-2 px-1">
<Avatar
avatar={creator.avatarUrl}
name={creator.name}
size="xs"
className="border-[0.5px] border-divider-regular"
/>
<div className="flex min-w-0 grow items-center justify-between gap-2">
<span className="truncate text-sm text-text-secondary">{creator.name}</span>
{creator.isYou && (
<span className="shrink-0 text-sm text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
)}
</div>
</div>
</button>
)
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default CreatorsFilter

View File

@ -17,7 +17,11 @@ const DefaultCards = React.memo(() => {
)
})
const Empty = () => {
type EmptyProps = {
message?: string
}
const Empty = ({ message }: EmptyProps) => {
const { t } = useTranslation()
return (
@ -25,7 +29,7 @@ const Empty = () => {
<DefaultCards />
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-linear-to-t from-background-body to-transparent">
<span className="system-md-medium text-text-tertiary">
{t('newApp.noAppsFound', { ns: 'app' })}
{message ?? t('newApp.noAppsFound', { ns: 'app' })}
</span>
</div>
</>

View File

@ -5,6 +5,7 @@ import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants'
import { useAppsQueryState } from '../use-apps-query-state'
const renderWithAdapter = (searchParams = '') => {
// eslint-disable-next-line react/use-state -- testing a custom URL query hook, not React.useState
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
}
@ -20,24 +21,24 @@ describe('useAppsQueryState', () => {
category: 'all',
tagIDs: [],
keywords: '',
isCreatedByMe: false,
creatorID: '',
})
expect(typeof result.current.setCategory).toBe('function')
expect(typeof result.current.setKeywords).toBe('function')
expect(typeof result.current.setTagIDs).toBe('function')
expect(typeof result.current.setIsCreatedByMe).toBe('function')
expect(typeof result.current.setCreatorID).toBe('function')
})
it('should parse app list filters from URL', () => {
const { result } = renderWithAdapter(
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true',
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&creatorID=creator-1',
)
expect(result.current.query).toEqual({
category: AppModeEnum.WORKFLOW,
tagIDs: ['tag1', 'tag2'],
keywords: 'search term',
isCreatedByMe: true,
creatorID: 'creator-1',
})
})
@ -144,30 +145,30 @@ describe('useAppsQueryState', () => {
expect(update.searchParams.has('tagIDs')).toBe(false)
})
it('should update created-by-me URL state', async () => {
it('should update creator ID URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setIsCreatedByMe(true)
result.current.setCreatorID('creator-1')
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.isCreatedByMe).toBe(true)
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
expect(result.current.query.creatorID).toBe('creator-1')
expect(update.searchParams.get('creatorID')).toBe('creator-1')
expect(update.options.history).toBe('push')
})
it('should remove isCreatedByMe from URL when disabled', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
it('should remove creatorID from URL when cleared', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?creatorID=creator-1')
act(() => {
result.current.setIsCreatedByMe(false)
result.current.setCreatorID('')
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.isCreatedByMe).toBe(false)
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
expect(result.current.query.creatorID).toBe('')
expect(update.searchParams.has('creatorID')).toBe(false)
})
})

View File

@ -1,29 +1,19 @@
import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import type { AppListCategory } from '../app-type-filter-shared'
import { debounce, parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { AppModes } from '@/types/app'
import { parseAsAppListCategory } from '../app-type-filter-shared'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
const appListQueryParsers = {
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' }),
category: parseAsAppListCategory,
tagIDs: parseAsArrayOf(parseAsString, ';')
.withDefault([])
.withOptions({ history: 'push' }),
keywords: parseAsString.withDefault('').withOptions({
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
}),
isCreatedByMe: parseAsBoolean
.withDefault(false)
creatorID: parseAsString
.withDefault('')
.withOptions({ history: 'push' }),
}
@ -42,8 +32,8 @@ export function useAppsQueryState() {
setQuery({ tagIDs })
}, [setQuery])
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
setQuery({ isCreatedByMe })
const setCreatorID = useCallback((creatorID: string) => {
setQuery({ creatorID })
}, [setQuery])
return useMemo(() => ({
@ -51,6 +41,6 @@ export function useAppsQueryState() {
setCategory,
setKeywords,
setTagIDs,
setIsCreatedByMe,
}), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe])
setCreatorID,
}), [query, setCategory, setKeywords, setTagIDs, setCreatorID])
}

View File

@ -15,19 +15,29 @@ import { fetchAppDetail } from '@/service/explore'
import { trackCreateApp } from '@/utils/create-app-tracking'
import List from './list'
export type StudioPageType = 'apps' | 'snippets'
type AppsProps = {
pageType?: StudioPageType
}
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
const ImportFromMarketplaceTemplateModal = dynamic(() => import('./import-from-marketplace-template-modal'), { ssr: false })
const Apps = () => {
const Apps = ({
pageType = 'apps',
}: AppsProps) => {
const { t } = useTranslation()
const searchParams = useSearchParams()
const { replace } = useRouter()
const templateId = searchParams.get('template-id')
const templateDismissedRef = useRef(false)
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useDocumentTitle(pageType === 'apps'
? t('menus.apps', { ns: 'common' })
: t('tabs.snippets', { ns: 'workflow' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
@ -165,7 +175,7 @@ const Apps = () => {
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} />
<List controlRefreshList={controlRefreshList} pageType={pageType} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}

View File

@ -1,29 +1,31 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { AppListQuery } from '@/contract/console/apps'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import { AppTypeFilter } from './app-type-filter'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import CreatorsFilter from './creators-filter'
import Empty from './empty'
import Footer from './footer'
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
import { useAppsQueryState } from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
import NewAppCard from './new-app-card'
@ -37,9 +39,11 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
type Props = {
controlRefreshList?: number
pageType?: StudioPageType
}
const List: FC<Props> = ({
controlRefreshList = 0,
pageType = 'apps',
}) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -47,11 +51,11 @@ const List: FC<Props> = ({
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { category, tagIDs, keywords, isCreatedByMe },
query: { category, tagIDs, keywords, creatorID },
setCategory,
setKeywords,
setTagIDs,
setIsCreatedByMe,
setCreatorID,
} = useAppsQueryState()
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
const newAppCardRef = useRef<HTMLDivElement>(null)
@ -76,9 +80,9 @@ const List: FC<Props> = ({
limit: 30,
name: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
...(creatorID ? { creator_id: creatorID } : {}),
...(category !== 'all' ? { mode: category } : {}),
}), [category, debouncedKeywords, isCreatedByMe, tagIDs])
}), [category, creatorID, debouncedKeywords, tagIDs])
const {
data,
@ -112,14 +116,6 @@ const List: FC<Props> = ({
}, [controlRefreshList, refetch])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line h-[14px] w-[14px]" /> },
]
useEffect(() => {
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
@ -158,9 +154,9 @@ const List: FC<Props> = ({
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
const handleCreatedByMeChange = useCallback((checked: boolean) => {
setIsCreatedByMe(checked)
}, [setIsCreatedByMe])
const handleCreatorsChange = useCallback((creatorIDs: string[]) => {
setCreatorID(creatorIDs.at(-1) ?? '')
}, [setCreatorID])
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
@ -193,32 +189,45 @@ const List: FC<Props> = ({
</div>
)}
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-7 pb-5">
<TabSliderNew
value={category}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setCategory(nextValue)
}}
options={options}
/>
<div className="flex items-center gap-2">
<label className="mr-2 flex h-7 items-center space-x-2">
<Checkbox checked={isCreatedByMe} onCheckedChange={handleCreatedByMeChange} />
<div className="text-sm font-normal text-text-secondary">
{t('showMyCreatedAppsOnly', { ns: 'app' })}
</div>
</label>
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 bg-background-body px-12 pt-7 pb-5">
<div className="flex flex-wrap items-center gap-2">
<AppTypeFilter
value={category}
onChange={setCategory}
/>
<CreatorsFilter
value={creatorID ? [creatorID] : []}
onChange={handleCreatorsChange}
/>
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<div className="relative w-50">
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
<Input
className={cn('pl-6.5', keywords && 'pr-6.5')}
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder={t('operation.search', { ns: 'common' })}
/>
{!!keywords && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
onClick={() => setKeywords('')}
>
<span aria-hidden className="i-ri-close-circle-fill size-4" />
</button>
)}
</div>
</div>
{pageType === 'apps' && (
<Link
href="/snippets"
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary hover:bg-state-base-hover hover:text-text-primary"
>
{t('studio.viewSnippets', { ns: 'app' })}
</Link>
)}
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
@ -246,7 +255,7 @@ const List: FC<Props> = ({
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
))
: <Empty />}
: <Empty message={pageType === 'snippets' ? t('tabs.noSnippetsFound', { ns: 'workflow' }) : undefined} />}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}

View File

@ -1,10 +1,10 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -71,9 +71,8 @@ const InputsFormContent = ({ showTip }: Props) => {
)}
{form.type === InputVarType.paragraph && (
<Textarea
aria-label={form.label}
value={inputsFormValue?.[form.variable] || ''}
onValueChange={value => handleFormChange(form.variable, value)}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
/>
)}

View File

@ -1,8 +1,8 @@
import type { ContentItemProps } from './type'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useMemo } from 'react'
import { Markdown } from '@/app/components/base/markdown'
import Textarea from '@/app/components/base/textarea'
const ContentItem = ({
content,
@ -42,10 +42,9 @@ const ContentItem = ({
<div className="py-3">
{formInputField.type === 'paragraph' && (
<Textarea
aria-label={fieldName}
className="h-[104px] sm:text-xs"
value={inputs[fieldName]!}
onValueChange={(value) => { onInputChange(fieldName, value) }}
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
data-testid="content-item-textarea"
/>
)}

View File

@ -6,7 +6,6 @@ import type {
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import copy from 'copy-to-clipboard'
@ -22,6 +21,7 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
import Log from '@/app/components/base/chat/chat/log'
import AnnotationCtrlButton from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button'
import NewAudioButton from '@/app/components/base/new-audio-button'
import Textarea from '@/app/components/base/textarea'
import { useChatContext } from '../context'
type OperationProps = {
@ -394,7 +394,7 @@ function Operation({
id={feedbackTextareaId}
name="feedback-content"
value={feedbackContent}
onValueChange={value => setFeedbackContent(value)}
onChange={e => setFeedbackContent(e.target.value)}
placeholder={t('feedback.placeholder', { ns: 'common' }) || 'Please describe what went wrong or how we can improve…'}
rows={4}
className="w-full"

View File

@ -1,10 +1,10 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -71,9 +71,8 @@ const InputsFormContent = ({ showTip }: Props) => {
)}
{form.type === InputVarType.paragraph && (
<Textarea
aria-label={form.label}
value={inputsFormValue?.[form.variable] || ''}
onValueChange={value => handleFormChange(form.variable, value)}
onChange={e => handleFormChange(form.variable, e.target.value)}
placeholder={form.label}
/>
)}

View File

@ -12,10 +12,10 @@ import { FieldItem, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { produce } from 'immer'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
@ -220,10 +220,9 @@ const FollowUpSettingModal = ({
</div>
{promptMode === PROMPT_MODE.custom && (
<Textarea
aria-label={t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOption', { ns: 'appDebug' })}
className="mt-3 min-h-32 resize-y border-components-input-border-active bg-components-input-bg-normal"
value={prompt}
onValueChange={value => setPrompt(value)}
onChange={e => setPrompt(e.target.value)}
maxLength={CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH}
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
/>

View File

@ -2,7 +2,7 @@ import type { FC } from 'react'
import type { CodeBasedExtensionForm } from '@/models/common'
import type { ModerationConfig } from '@/models/debug'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import Textarea from '@/app/components/base/textarea'
import { useLocale } from '@/context/i18n'
type FormGenerationProps = {
@ -55,11 +55,10 @@ const FormGeneration: FC<FormGenerationProps> = ({
form.type === 'paragraph' && (
<div className="relative">
<Textarea
aria-label={locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
className="resize-none"
value={value?.[form.variable] || ''}
placeholder={form.placeholder}
onValueChange={value => handleFormChange(form.variable, value)}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
</div>
)

View File

@ -1,7 +1,6 @@
import type { FC } from 'react'
import type { ModerationContentConfig } from '@/models/debug'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useTranslation } from 'react-i18next'
type ModerationContentProps = {
@ -51,14 +50,12 @@ const ModerationContent: FC<ModerationContentProps> = ({
{t('feature.moderation.modal.content.preset', { ns: 'appDebug' })}
<span className="text-xs font-normal text-text-tertiary">{t('feature.moderation.modal.content.supportMarkdown', { ns: 'appDebug' })}</span>
</div>
{/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */}
<div className="relative h-20">
<Textarea
aria-label={t('feature.moderation.modal.content.preset', { ns: 'appDebug' }) as string}
<div className="relative h-20 rounded-lg bg-components-input-bg-normal px-3 py-2">
<textarea
value={config.preset_response || ''}
className="size-full resize-none pb-8"
className="block size-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden"
placeholder={t('feature.moderation.modal.content.placeholder', { ns: 'appDebug' }) || ''}
onValueChange={value => handleConfigChange('preset_response', value)}
onChange={e => handleConfigChange('preset_response', e.target.value)}
/>
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">
<span>{(config.preset_response || '').length}</span>

View File

@ -1,10 +1,9 @@
import type { FC } from 'react'
import type { ChangeEvent, FC } from 'react'
import type { CodeBasedExtensionItem } from '@/models/common'
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -104,7 +103,9 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
})
}
const handleDataKeywordsChange = (value: string) => {
const handleDataKeywordsChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
const arr = value.split('\n').reduce((prev: string[], next: string) => {
if (next !== '')
prev.push(next.slice(0, 100))
@ -291,13 +292,11 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
<div className="py-2">
<div className="mb-1 text-sm font-medium text-text-primary">{t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' })}</div>
<div className="mb-2 text-xs text-text-tertiary">{t('feature.moderation.modal.keywords.tip', { ns: 'appDebug' })}</div>
{/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */}
<div className="relative h-[88px]">
<Textarea
aria-label={t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' }) as string}
<div className="relative h-[88px] rounded-lg bg-components-input-bg-normal px-3 py-2">
<textarea
value={localeData.config?.keywords || ''}
onValueChange={handleDataKeywordsChange}
className="size-full resize-none pb-8"
onChange={handleDataKeywordsChange}
className="block size-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden"
placeholder={t('feature.moderation.modal.keywords.placeholder', { ns: 'appDebug' }) || ''}
/>
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">

View File

@ -1,16 +1,16 @@
import type { TextareaProps } from '@langgenius/dify-ui/textarea'
import type { TextareaProps } from '../../../textarea'
import type { LabelProps } from '../label'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useFieldContext } from '../..'
import Textarea from '../../../textarea'
import Label from '../label'
type TextAreaFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
} & Omit<TextareaProps, 'className' | 'defaultValue' | 'onBlur' | 'value' | 'id'>
} & Omit<TextareaProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>
const TextAreaField = ({
label,
@ -30,7 +30,7 @@ const TextAreaField = ({
<Textarea
id={field.name}
value={field.state.value}
onValueChange={value => field.handleChange(value)}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
{...inputProps}
/>

View File

@ -3,7 +3,6 @@ import type { Dayjs } from 'dayjs'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useChatContext } from '@/app/components/base/chat/chat/context'
@ -11,6 +10,7 @@ import DatePicker from '@/app/components/base/date-and-time-picker/date-picker'
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-time-picker/utils/dayjs'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
const DATA_FORMAT = {
TEXT: 'text',
@ -372,12 +372,11 @@ const MarkdownForm = ({ node }: { node: HastElement }) => {
return null
return (
<Textarea
aria-label={name}
key={key}
name={name}
placeholder={str(child.properties.placeholder)}
value={str(formValues[name])}
onValueChange={value => updateValue(name, value)}
onChange={e => updateValue(name, e.target.value)}
/>
)
}

View File

@ -2,12 +2,12 @@
import type { FC } from 'react'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { VarType } from '@/app/components/workflow/types'
import Textarea from '../../../textarea'
import TagLabel from './tag-label'
import TypeSwitch from './type-switch'
@ -72,7 +72,6 @@ const PrePopulate: FC<Props> = ({
value,
onValueChange,
}) => {
const { t } = useTranslation()
const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false)
const handleTypeChange = useCallback((isVar: boolean) => {
setOnPlaceholderClicked(true)
@ -128,10 +127,9 @@ const PrePopulate: FC<Props> = ({
return (
<div className={cn('relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}>
<Textarea
aria-label={t(`${i18nPrefix}.staticContent`, { ns: 'workflow' })}
value={value || ''}
className="h-[43px] min-h-[43px] rounded-none border-none bg-transparent px-3 hover:bg-transparent focus:bg-transparent focus:shadow-none"
onValueChange={value => onValueChange?.(value)}
onChange={e => onValueChange?.(e.target.value)}
onFocus={() => {
setOnPlaceholderClicked(true)
setIsFocus(true)

View File

@ -0,0 +1,77 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import TextArea from '../index'
describe('TextArea', () => {
it('should render correctly with default props', () => {
render(<TextArea value="" onChange={vi.fn()} />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveValue('')
})
it('should handle value and onChange correctly', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
const { rerender } = render(<TextArea value="initial" onChange={handleChange} />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toHaveValue('initial')
await user.type(textarea, ' updated')
expect(handleChange).toHaveBeenCalled()
rerender(<TextArea value="initial updated" onChange={handleChange} />)
expect(textarea).toHaveValue('initial updated')
})
it('should handle autoFocus correctly', () => {
render(<TextArea value="" onChange={vi.fn()} autoFocus />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toHaveFocus()
})
it('should handle disabled state', () => {
render(<TextArea value="" onChange={vi.fn()} disabled />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toBeDisabled()
expect(textarea).toHaveClass('cursor-not-allowed')
})
it('should handle placeholder', () => {
render(<TextArea value="" onChange={vi.fn()} placeholder="Enter text here" />)
expect(screen.getByPlaceholderText('Enter text here')).toBeInTheDocument()
})
it('should handle className', () => {
render(<TextArea value="" onChange={vi.fn()} className="custom-class" />)
expect(screen.getByTestId('text-area')).toHaveClass('custom-class')
})
it('should handle size variants', () => {
const { rerender } = render(<TextArea value="" onChange={vi.fn()} size="small" />)
expect(screen.getByTestId('text-area')).toHaveClass('py-1')
rerender(<TextArea value="" onChange={vi.fn()} size="large" />)
expect(screen.getByTestId('text-area')).toHaveClass('px-4')
})
it('should handle destructive state', () => {
render(<TextArea value="" onChange={vi.fn()} destructive />)
expect(screen.getByTestId('text-area')).toHaveClass('border-components-input-border-destructive')
})
it('should handle onFocus and onBlur', async () => {
const user = userEvent.setup()
const handleFocus = vi.fn()
const handleBlur = vi.fn()
render(<TextArea value="" onChange={vi.fn()} onFocus={handleFocus} onBlur={handleBlur} />)
const textarea = screen.getByTestId('text-area')
await user.click(textarea)
expect(handleFocus).toHaveBeenCalled()
await user.tab()
expect(handleBlur).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,562 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import Textarea from '.'
const meta = {
title: 'Base/Data Entry/Textarea',
component: Textarea,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Textarea component with multiple sizes (small, regular, large). Built with class-variance-authority for consistent styling.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['small', 'regular', 'large'],
description: 'Textarea size',
},
value: {
control: 'text',
description: 'Textarea value',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
destructive: {
control: 'boolean',
description: 'Error/destructive state',
},
rows: {
control: 'number',
description: 'Number of visible text rows',
},
},
} satisfies Meta<typeof Textarea>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const TextareaDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '500px' }}>
<Textarea
{...args}
value={value}
onChange={(e) => {
setValue(e.target.value)
console.log('Textarea changed:', e.target.value)
}}
/>
{value && (
<div className="mt-3 text-sm text-gray-600">
Character count:
{' '}
<span className="font-semibold">{value.length}</span>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
placeholder: 'Enter text...',
rows: 4,
value: '',
},
}
// Small size
export const SmallSize: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'small',
placeholder: 'Small textarea...',
rows: 3,
value: '',
},
}
// Large size
export const LargeSize: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'large',
placeholder: 'Large textarea...',
rows: 5,
value: '',
},
}
// With initial value
export const WithInitialValue: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This is some initial text content.\n\nIt spans multiple lines.',
rows: 4,
},
}
// Disabled state
export const Disabled: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This textarea is disabled and cannot be edited.',
disabled: true,
rows: 3,
},
}
// Destructive/error state
export const DestructiveState: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This content has an error.',
destructive: true,
rows: 3,
},
}
// Size comparison
const SizeComparisonDemo = () => {
const [small, setSmall] = useState('')
const [regular, setRegular] = useState('')
const [large, setLarge] = useState('')
return (
<div style={{ width: '600px' }} className="space-y-4">
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Small</label>
<Textarea
size="small"
value={small}
onChange={e => setSmall(e.target.value)}
placeholder="Small textarea..."
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Regular</label>
<Textarea
size="regular"
value={regular}
onChange={e => setRegular(e.target.value)}
placeholder="Regular textarea..."
rows={4}
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Large</label>
<Textarea
size="large"
value={large}
onChange={e => setLarge(e.target.value)}
placeholder="Large textarea..."
rows={5}
/>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// State comparison
const StateComparisonDemo = () => {
const [normal, setNormal] = useState('Normal state')
const [error, setError] = useState('Error state')
return (
<div style={{ width: '500px' }} className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Normal</label>
<Textarea
value={normal}
onChange={e => setNormal(e.target.value)}
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Destructive</label>
<Textarea
value={error}
onChange={e => setError(e.target.value)}
destructive
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Disabled</label>
<Textarea
value="Disabled state"
onChange={() => undefined}
disabled
rows={3}
/>
</div>
</div>
)
}
export const StateComparison: Story = {
render: () => <StateComparisonDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Comment form
const CommentFormDemo = () => {
const [comment, setComment] = useState('')
const maxLength = 500
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Leave a Comment</h3>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="Share your thoughts..."
rows={5}
maxLength={maxLength}
/>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-gray-500">
{comment.length}
{' '}
/
{maxLength}
{' '}
characters
</span>
<button
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={comment.trim().length === 0}
>
Post Comment
</button>
</div>
</div>
)
}
export const CommentForm: Story = {
render: () => <CommentFormDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Feedback form
const FeedbackFormDemo = () => {
const [feedback, setFeedback] = useState('')
const [email, setEmail] = useState('')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">Send Feedback</h3>
<p className="mb-4 text-sm text-gray-600">Help us improve our product</p>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Your Email</label>
<input
type="email"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="email@example.com"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Your Feedback</label>
<Textarea
value={feedback}
onChange={e => setFeedback(e.target.value)}
placeholder="Tell us what you think..."
rows={6}
/>
</div>
<button className="w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
Submit Feedback
</button>
</div>
</div>
)
}
export const FeedbackForm: Story = {
render: () => <FeedbackFormDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Code snippet
const CodeSnippetDemo = () => {
const [code, setCode] = useState(`function hello() {
console.log("Hello, world!");
}`)
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Code Editor</h3>
<Textarea
value={code}
onChange={e => setCode(e.target.value)}
className="font-mono"
rows={8}
/>
<div className="mt-4 flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Run Code
</button>
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
Copy
</button>
</div>
</div>
)
}
export const CodeSnippet: Story = {
render: () => <CodeSnippetDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Message composer
const MessageComposerDemo = () => {
const [message, setMessage] = useState('')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Compose Message</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">To</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Recipient name"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Subject</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Message subject"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Message</label>
<Textarea
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="Type your message here..."
rows={8}
/>
</div>
<div className="flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Send Message
</button>
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
Save Draft
</button>
</div>
</div>
</div>
)
}
export const MessageComposer: Story = {
render: () => <MessageComposerDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Bio editor
const BioEditorDemo = () => {
const [bio, setBio] = useState('Software developer passionate about building great products.')
const maxLength = 200
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Edit Your Bio</h3>
<Textarea
value={bio}
onChange={e => setBio(e.target.value.slice(0, maxLength))}
placeholder="Tell us about yourself..."
rows={4}
/>
<div className="mt-2 flex items-center justify-between text-xs">
<span className={bio.length > maxLength * 0.9 ? 'text-orange-600' : 'text-gray-500'}>
{bio.length}
{' '}
/
{maxLength}
{' '}
characters
</span>
{bio.length > maxLength * 0.9 && (
<span className="text-orange-600">
{maxLength - bio.length}
{' '}
characters remaining
</span>
)}
</div>
<div className="mt-4 rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs font-medium text-gray-600">Preview:</div>
<p className="text-sm text-gray-800">{bio || 'Your bio will appear here...'}</p>
</div>
</div>
)
}
export const BioEditor: Story = {
render: () => <BioEditorDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - JSON editor
const JSONEditorDemo = () => {
const [json, setJson] = useState(`{
"name": "John Doe",
"age": 30,
"email": "john@example.com"
}`)
const [isValid, setIsValid] = useState(true)
const validateJSON = (value: string) => {
try {
JSON.parse(value)
setIsValid(true)
}
catch {
setIsValid(false)
}
}
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">JSON Editor</h3>
<span className={`rounded-sm px-2 py-1 text-xs ${isValid ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{isValid ? '✓ Valid' : '✗ Invalid'}
</span>
</div>
<Textarea
value={json}
onChange={(e) => {
setJson(e.target.value)
validateJSON(e.target.value)
}}
className="font-mono"
destructive={!isValid}
rows={10}
/>
<div className="mt-4 flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" disabled={!isValid}>
Save JSON
</button>
<button
className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
onClick={() => {
try {
const formatted = JSON.stringify(JSON.parse(json), null, 2)
setJson(formatted)
}
catch {
// Invalid JSON, do nothing
}
}}
>
Format
</button>
</div>
</div>
)
}
export const JSONEditor: Story = {
render: () => <JSONEditorDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Task description
const TaskDescriptionDemo = () => {
const [title, setTitle] = useState('Implement user authentication')
const [description, setDescription] = useState('Add login and registration functionality with JWT tokens.')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Create New Task</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Task Title</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Description</label>
<Textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Describe the task in detail..."
rows={6}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Priority</label>
<select className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm">
<option>Low</option>
<option>Medium</option>
<option>High</option>
<option>Urgent</option>
</select>
</div>
<button className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Create Task
</button>
</div>
</div>
)
}
export const TaskDescription: Story = {
render: () => <TaskDescriptionDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
export const Playground: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
placeholder: 'Enter text...',
rows: 4,
disabled: false,
destructive: false,
value: '',
},
}

View File

@ -0,0 +1,60 @@
import type { VariantProps } from 'class-variance-authority'
import type { CSSProperties } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { cva } from 'class-variance-authority'
import * as React from 'react'
const textareaVariants = cva(
'',
{
variants: {
size: {
small: 'rounded-md py-1 system-xs-regular',
regular: 'rounded-md px-3 system-sm-regular',
large: 'rounded-lg px-4 system-md-regular',
},
},
defaultVariants: {
size: 'regular',
},
},
)
export type TextareaProps = {
value: string | number
disabled?: boolean
destructive?: boolean
styleCss?: CSSProperties
ref?: React.Ref<HTMLTextAreaElement>
onFocus?: React.FocusEventHandler<HTMLTextAreaElement>
onBlur?: React.FocusEventHandler<HTMLTextAreaElement>
} & React.TextareaHTMLAttributes<HTMLTextAreaElement> & VariantProps<typeof textareaVariants>
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, value, onChange, disabled, size, destructive, styleCss, onFocus, onBlur, ...props }, ref) => {
return (
<textarea
ref={ref}
onFocus={onFocus}
onBlur={onBlur}
style={styleCss}
className={cn(
'min-h-20 w-full appearance-none border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
textareaVariants({ size }),
disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled',
destructive && 'border-components-input-border-destructive bg-components-input-bg-destructive text-components-input-text-filled hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive',
className,
)}
value={value ?? ''}
onChange={onChange}
disabled={disabled}
data-testid="text-area"
{...props}
>
</textarea>
)
},
)
Textarea.displayName = 'Textarea'
export default Textarea

View File

@ -25,7 +25,6 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
const total = plan.total.buildApps
const percent = total > 0 ? (usage / total) * 100 : 0
const tone: MeterTone = percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'neutral'
const buildAppsLabel = t('usagePage.buildApps', { ns: 'billing' })
return (
<div className={cn(
'flex flex-col gap-3 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-4 shadow-xs backdrop-blur-xs',
@ -62,14 +61,14 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between system-xs-medium text-text-secondary">
<div>{buildAppsLabel}</div>
<div>{t('usagePage.buildApps', { ns: 'billing' })}</div>
<div>
{usage}
/
{total}
</div>
</div>
<MeterRoot value={Math.min(percent, 100)} max={100} aria-label={buildAppsLabel}>
<MeterRoot value={Math.min(percent, 100)} max={100}>
<MeterTrack>
<MeterIndicator tone={tone} />
</MeterTrack>

View File

@ -229,7 +229,7 @@ describe('UsageInfo', () => {
/>,
)
expect(screen.getByRole('meter', { name: 'Storage' })).toBeInTheDocument()
expect(screen.getByRole('meter')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
})
@ -270,7 +270,7 @@ describe('UsageInfo', () => {
/>,
)
expect(screen.getByRole('meter', { name: 'Storage' })).toBeInTheDocument()
expect(screen.getByRole('meter')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
})

View File

@ -144,13 +144,13 @@ const UsageInfo: FC<Props> = ({
<div
className={cn(
'h-1 rounded-md bg-progress-bar-indeterminate-stripe',
isSandboxPlan ? 'w-full' : 'w-7.5',
isSandboxPlan ? 'w-full' : 'w-[30px]',
)}
/>
</div>
)
: (
<MeterRoot value={effectivePercent} max={100} aria-label={name}>
<MeterRoot value={effectivePercent} max={100}>
<MeterTrack>
<MeterIndicator tone={tone} />
</MeterTrack>
@ -162,7 +162,7 @@ const UsageInfo: FC<Props> = ({
return (
<Tooltip>
<TooltipTrigger render={<div className="cursor-default">{children}</div>} />
<TooltipContent className="w-50 max-w-50">
<TooltipContent className="w-[200px] max-w-[200px]">
{storageTooltip}
</TooltipContent>
</Tooltip>

View File

@ -1,7 +1,6 @@
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { PipelineTemplate } from '@/models/pipeline'
import { Button } from '@langgenius/dify-ui/button'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
@ -10,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { useInvalidCustomizedTemplateList, useUpdateTemplateInfo } from '@/service/use-pipeline'
type EditPipelineInfoProps = {
@ -57,7 +57,8 @@ const EditPipelineInfo = ({
setShowAppIconPicker(false)
}, [])
const handleDescriptionChange = useCallback((value: string) => {
const handleDescriptionChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value
setDescription(value)
}, [])
@ -132,8 +133,7 @@ const EditPipelineInfo = ({
{t('knowledgeDescription', { ns: 'datasetPipeline' })}
</label>
<Textarea
aria-label={t('knowledgeDescription', { ns: 'datasetPipeline' })}
onValueChange={handleDescriptionChange}
onChange={handleDescriptionChange}
value={description}
placeholder={t('knowledgeDescriptionPlaceholder', { ns: 'datasetPipeline' })}
/>

View File

@ -5,12 +5,12 @@ import type { DataSet } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { updateDatasetSetting } from '@/service/datasets'
import AppIcon from '../../base/app-icon'
import AppIconPicker from '../../base/app-icon-picker'
@ -117,7 +117,7 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset
{t('form.desc', { ns: 'datasetSettings' })}
</div>
<div className="w-full">
<Textarea aria-label={t('form.desc', { ns: 'datasetSettings' })} value={description} onValueChange={value => setDescription(value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} />
<Textarea value={description} onChange={e => setDescription(e.target.value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} />
</div>
</div>
</div>

View File

@ -3,11 +3,11 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { Member } from '@/models/common'
import type { DataSet, DatasetPermission, IconInfo } from '@/models/datasets'
import type { AppIconType } from '@/types/app'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import PermissionSelector from '../../permission-selector'
const rowClass = 'flex gap-x-1'
@ -85,12 +85,11 @@ const BasicInfoSection = ({
</div>
<div className="grow">
<Textarea
aria-label={t('form.desc', { ns: 'datasetSettings' })}
disabled={!currentDataset?.embedding_available}
className="resize-none"
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
value={description}
onValueChange={value => setDescription(value)}
onChange={e => setDescription(e.target.value)}
/>
</div>
</div>

View File

@ -1,7 +1,7 @@
import type { ChangeEvent } from 'react'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import {
memo,
useCallback,
@ -9,6 +9,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Textarea from '@/app/components/base/textarea'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
@ -52,9 +53,9 @@ const SummaryIndexSetting = ({
})
}, [onSummaryIndexSettingChange])
const handleSummaryIndexPromptChange = useCallback((value: string) => {
const handleSummaryIndexPromptChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
onSummaryIndexSettingChange?.({
summary_prompt: value,
summary_prompt: e.target.value,
})
}, [onSummaryIndexSettingChange])
@ -94,9 +95,8 @@ const SummaryIndexSetting = ({
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
value={summaryIndexSetting?.summary_prompt ?? ''}
onValueChange={handleSummaryIndexPromptChange}
onChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>
@ -166,9 +166,8 @@ const SummaryIndexSetting = ({
</div>
<div className="grow">
<Textarea
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
value={summaryIndexSetting?.summary_prompt ?? ''}
onValueChange={handleSummaryIndexPromptChange}
onChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>
@ -215,9 +214,8 @@ const SummaryIndexSetting = ({
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
value={summaryIndexSetting?.summary_prompt ?? ''}
onValueChange={handleSummaryIndexPromptChange}
onChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>

View File

@ -3,7 +3,6 @@ import type { AppIconType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useDebounceFn, useKeyPress } from 'ahooks'
import * as React from 'react'
@ -11,6 +10,7 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { useProviderContext } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
@ -145,11 +145,10 @@ const CreateAppModal = ({
<div className="pt-2">
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('newApp.captionDescription', { ns: 'app' })}</div>
<Textarea
aria-label={t('newApp.captionDescription', { ns: 'app' })}
className="resize-none"
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
value={description}
onValueChange={value => setDescription(value)}
onChange={e => setDescription(e.target.value)}
/>
</div>
{/* answer icon */}

View File

@ -104,12 +104,23 @@ vi.mock('../../nav', () => ({
onCreate,
onLoadMore,
navigationItems,
activeSegment,
activeLink,
text,
}: {
onCreate: (state: string) => void
onLoadMore?: () => void
navigationItems?: Array<{ id: string, name: string, link: string }>
activeSegment?: string | string[]
activeLink?: { segment: string, text: string, link: string }
text?: string
}) => (
<div data-testid="nav">
<div data-testid="nav-text">{text}</div>
<div data-testid="nav-active-segment">{JSON.stringify(activeSegment)}</div>
{activeLink && (
<div data-testid="nav-active-link">{`${activeLink.segment}:${activeLink.text}->${activeLink.link}`}</div>
)}
<ul data-testid="nav-items">
{(navigationItems ?? []).map(item => (
<li key={item.id}>{`${item.name} -> ${item.link}`}</li>
@ -201,6 +212,15 @@ describe('AppNav', () => {
expect(options.getNextPageParam({ has_more: false, page: 3 })).toBeUndefined()
})
it('should configure snippets as an active studio child link', () => {
setupDefaultMocks()
render(<AppNav />)
expect(screen.getByTestId('nav-text')).toHaveTextContent('menus.apps')
expect(screen.getByTestId('nav-active-segment')).toHaveTextContent(JSON.stringify(['apps', 'app', 'snippets']))
expect(screen.getByTestId('nav-active-link')).toHaveTextContent('snippets:tabs.snippets->/snippets')
})
it('should build editor links and update app name when app detail changes', async () => {
setupDefaultMocks({
isEditor: true,

View File

@ -103,8 +103,13 @@ const AppNav = () => {
icon={<RiRobot2Line className="size-4" />}
activeIcon={<RiRobot2Fill className="size-4" />}
text={t('menus.apps', { ns: 'common' })}
activeSegment={['apps', 'app']}
activeSegment={['apps', 'app', 'snippets']}
link="/apps"
activeLink={{
segment: 'snippets',
text: t('tabs.snippets', { ns: 'workflow' }),
link: '/snippets',
}}
curNav={appDetail}
navigationItems={navItems}
createText={t('menus.newApp', { ns: 'common' })}

View File

@ -14,7 +14,7 @@ const HeaderWrapper = ({
children,
}: HeaderWrapperProps) => {
const pathname = usePathname()
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
// Check if the current path is a workflow canvas & fullscreen
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')

View File

@ -123,6 +123,27 @@ describe('Nav Component', () => {
expect(screen.getByTestId('active-icon')).toBeInTheDocument()
})
it('should render active child link when activeLink matches the current segment', () => {
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
render(
<Nav
{...defaultProps}
activeSegment={['apps', 'app', 'snippets']}
activeLink={{
segment: 'snippets',
text: 'SNIPPETS',
link: '/snippets',
}}
/>,
)
expect(screen.getByText('Nav Text')).toBeInTheDocument()
expect(screen.getByText('Nav Text')).toHaveClass('max-[1024px]:hidden')
expect(screen.getByRole('link', { name: 'SNIPPETS' })).toHaveAttribute('href', '/snippets')
expect(screen.getByRole('link', { name: 'SNIPPETS' })).not.toHaveClass('max-[1024px]:hidden')
})
it('should not show hover background if not activated', () => {
vi.mocked(useSelectedLayoutSegment).mockReturnValue('other')
const { container } = render(<Nav {...defaultProps} />)
@ -148,6 +169,14 @@ describe('Nav Component', () => {
expect(mockSetAppDetail).not.toHaveBeenCalled()
})
it('should not call setAppDetail from snippets segment', () => {
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} />)
const link = screen.getByRole('link')
fireEvent.click(link.firstChild!)
expect(mockSetAppDetail).not.toHaveBeenCalled()
})
it('should show ArrowNarrowLeft on hover when curNav is provided and activated', () => {
const curNav = navigationItems[0]
render(<Nav {...defaultProps} curNav={curNav} />)
@ -185,19 +214,20 @@ describe('Nav Component', () => {
})
it('should navigate when an item is selected', async () => {
render(<Nav {...defaultProps} curNav={curNav} />)
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} curNav={curNav} />)
const selectorButton = screen.getByRole('button', { name: /Item 1/i })
await act(async () => {
fireEvent.click(selectorButton)
})
mockSetAppDetail.mockClear()
const item2 = await screen.findByText('Item 2')
await act(async () => {
fireEvent.click(item2)
})
expect(mockSetAppDetail).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/item2')
})

View File

@ -5,7 +5,6 @@ import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useState } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import NavSelector from './nav-selector'
@ -16,6 +15,11 @@ type INavProps = {
text: string
activeSegment: string | string[]
link: string
activeLink?: {
segment: string
text: string
link: string
}
isApp: boolean
} & INavSelectorProps
@ -25,6 +29,7 @@ const Nav = ({
text,
activeSegment,
link,
activeLink,
curNav,
navigationItems,
createText,
@ -37,10 +42,11 @@ const Nav = ({
const [hovered, setHovered] = useState(false)
const segment = useSelectedLayoutSegment()
const isActivated = Array.isArray(activeSegment) ? activeSegment.includes(segment!) : segment === activeSegment
const shouldShowActiveLink = isActivated && activeLink && segment === activeLink.segment
return (
<div className={`
flex h-8 max-w-[670px] shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-[400px]
flex h-8 max-w-167.5 shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-100
${isActivated && 'bg-components-main-nav-nav-button-bg-active font-semibold shadow-md'}
${!curNav && !isActivated && 'hover:bg-components-main-nav-nav-button-bg-hover'}
`}
@ -51,6 +57,8 @@ const Nav = ({
// Don't clear state if opening in new tab/window
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
return
if (segment === 'snippets')
return
setAppDetail()
}}
className={cn('flex h-7 cursor-pointer items-center rounded-[10px] px-2.5', isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text', curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover')}
@ -60,7 +68,7 @@ const Nav = ({
<div>
{
(hovered && curNav)
? <ArrowNarrowLeft className="size-4" />
? <span className="i-custom-vender-line-arrows-arrow-narrow-left size-4" />
: isActivated
? activeIcon
: icon
@ -87,6 +95,19 @@ const Nav = ({
</>
)
}
{
!curNav && shouldShowActiveLink && (
<>
<div className="font-light text-divider-deep">/</div>
<Link
href={activeLink.link}
className="hover:bg-components-main-nav-nav-button-bg-active-hover flex h-7 cursor-pointer items-center rounded-[10px] px-2.5 text-components-main-nav-nav-button-text-active"
>
{activeLink.text}
</Link>
</>
)
}
</div>
)
}

View File

@ -1,9 +1,9 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { InputVarType } from '@/app/components/workflow/types'
type Props = {
@ -55,9 +55,8 @@ const AppInputsForm = ({
if (form.type === InputVarType.paragraph) {
return (
<Textarea
aria-label={label}
value={inputs[variable] || ''}
onValueChange={value => handleFormChange(variable, value)}
onChange={e => handleFormChange(variable, e.target.value)}
placeholder={label}
/>
)

View File

@ -523,7 +523,9 @@ describe('useToolSelectorState Hook', () => {
)
act(() => {
result.current.handleDescriptionChange('new description')
result.current.handleDescriptionChange({
target: { value: 'new description' },
} as React.ChangeEvent<HTMLTextAreaElement>)
})
expect(onSelect).toHaveBeenCalledWith(
@ -1722,7 +1724,9 @@ describe('Edge Cases', () => {
)
act(() => {
result.current.handleDescriptionChange('')
result.current.handleDescriptionChange({
target: { value: '' },
} as React.ChangeEvent<HTMLTextAreaElement>)
})
expect(onSelect).toHaveBeenCalledWith(

View File

@ -1,6 +1,24 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, disabled, placeholder }: {
value?: string
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
disabled?: boolean
placeholder?: string
}) => (
<textarea
data-testid="description-textarea"
value={value || ''}
onChange={onChange}
disabled={disabled}
placeholder={placeholder}
/>
),
}))
vi.mock('../../../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
@ -50,28 +68,28 @@ describe('ToolBaseForm', () => {
it('should render description textarea', () => {
render(<ToolBaseForm {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByTestId('description-textarea')).toBeInTheDocument()
})
it('should disable textarea when no provider_name in value', () => {
render(<ToolBaseForm {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByTestId('description-textarea')).toBeDisabled()
})
it('should enable textarea when value has provider_name', () => {
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
render(<ToolBaseForm {...defaultProps} value={value} />)
expect(screen.getByRole('textbox')).not.toBeDisabled()
expect(screen.getByTestId('description-textarea')).not.toBeDisabled()
})
it('should call onDescriptionChange when textarea content changes', () => {
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
render(<ToolBaseForm {...defaultProps} value={value} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } })
expect(mockOnDescriptionChange).toHaveBeenCalledWith('Updated', expect.any(Object))
fireEvent.change(screen.getByTestId('description-textarea'), { target: { value: 'Updated' } })
expect(mockOnDescriptionChange).toHaveBeenCalled()
})
it('should show ReadmeEntrance when provider has plugin_unique_identifier', () => {

View File

@ -4,8 +4,8 @@ import type { FC } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import { ReadmeEntrance } from '../../../readme-panel/entrance'
import ToolTrigger from './tool-trigger'
@ -23,7 +23,7 @@ type ToolBaseFormProps = {
onPanelShowStateChange?: (state: boolean) => void
onSelectTool: (tool: ToolDefaultValue) => void
onSelectMultipleTool: (tools: ToolDefaultValue[]) => void
onDescriptionChange: (value: string) => void
onDescriptionChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
}
const ToolBaseForm: FC<ToolBaseFormProps> = ({
@ -85,10 +85,9 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({
</div>
<Textarea
className="resize-none"
aria-label={t('detailPanel.toolSelector.descriptionLabel', { ns: 'plugin' })}
placeholder={t('detailPanel.toolSelector.descriptionPlaceholder', { ns: 'plugin' })}
value={value?.extra?.description || ''}
onValueChange={onDescriptionChange}
onChange={onDescriptionChange}
disabled={!value?.provider_name}
/>
</div>

View File

@ -1,3 +1,4 @@
import type * as React from 'react'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -160,8 +161,9 @@ describe('useToolSelectorState', () => {
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
)
const event = { target: { value: 'New description' } } as React.ChangeEvent<HTMLTextAreaElement>
act(() => {
result.current.handleDescriptionChange('New description')
result.current.handleDescriptionChange(event)
})
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({

View File

@ -144,14 +144,14 @@ export const useToolSelectorState = ({
onSelectMultiple?.(toolValues)
}, [getToolValue, onSelectMultiple])
const handleDescriptionChange = useCallback((description: string) => {
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!value)
return
onSelect({
...value,
extra: {
...value.extra,
description: description || '',
description: e.target.value || '',
},
})
}, [value, onSelect])

View File

@ -44,6 +44,17 @@ vi.mock('@/app/components/base/input', () => ({
),
}))
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, ...props }: Record<string, unknown>) => (
<textarea
data-testid="description-textarea"
value={value as string}
onChange={onChange as () => void}
{...props}
/>
),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ onClick }: { onClick?: () => void }) => (
<div data-testid="app-icon" onClick={onClick} />
@ -104,7 +115,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should initialize description as empty', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' }) as HTMLTextAreaElement
const textarea = screen.getByTestId('description-textarea') as HTMLTextAreaElement
expect(textarea.value).toBe('')
})
@ -148,7 +159,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should update description when textarea changes', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' })
const textarea = screen.getByTestId('description-textarea')
fireEvent.change(textarea, { target: { value: 'My description' } })
expect((textarea as HTMLTextAreaElement).value).toBe('My description')
@ -222,7 +233,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
const nameInput = screen.getByTestId('name-input')
fireEvent.change(nameInput, { target: { value: ' Trimmed Name ' } })
const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' })
const textarea = screen.getByTestId('description-textarea')
fireEvent.change(textarea, { target: { value: ' Some desc ' } })
fireEvent.click(screen.getByText('workflow.common.publish'))

View File

@ -3,13 +3,13 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { IconInfo } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiCloseLine } from '@remixicon/react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { useWorkflowStore } from '@/app/components/workflow/store'
type PublishAsKnowledgePipelineModalProps = {
@ -118,10 +118,9 @@ const PublishAsKnowledgePipelineModal = ({
</div>
<Textarea
className="resize-none"
aria-label={t('common.publishAsPipeline.description', { ns: 'pipeline' })}
placeholder={t('common.publishAsPipeline.descriptionPlaceholder', { ns: 'pipeline' }) || ''}
value={description}
onValueChange={value => setDescription(value)}
onChange={e => setDescription(e.target.value)}
/>
</div>
</div>

View File

@ -7,7 +7,6 @@ import type { VisionFile, VisionSettings } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import {
RiLoader2Line,
RiPlayLargeLine,
@ -19,6 +18,7 @@ import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uplo
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -159,11 +159,10 @@ const RunOnce: FC<IRunOnceProps> = ({
)}
{item.type === 'paragraph' && (
<Textarea
aria-label={item.name}
className="h-[104px] sm:text-xs"
placeholder={item.name}
value={inputs[item.key] as string}
onValueChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/>
)}
{item.type === 'number' && (

View File

@ -0,0 +1,270 @@
import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { renderWithNuqs } from '@/test/nuqs-testing'
import SnippetList from '..'
const mockUseInfiniteSnippetList = vi.hoisted(() => vi.fn())
const mockSetKeywords = vi.hoisted(() => vi.fn())
const mockSetTagIDs = vi.hoisted(() => vi.fn())
const mockSetCreatorID = vi.hoisted(() => vi.fn())
const mockQueryState = vi.hoisted(() => ({
tagIDs: [] as string[],
keywords: '',
creatorID: '',
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: vi.fn(),
}),
useInfiniteSnippetList: (params: unknown, options: unknown) => mockUseInfiniteSnippetList(params, options),
useUpdateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
}))
vi.mock('../hooks/use-snippets-query-state', () => ({
useSnippetsQueryState: () => ({
query: mockQueryState,
setKeywords: mockSetKeywords,
setTagIDs: mockSetTagIDs,
setCreatorID: mockSetCreatorID,
}),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
systemFeatures: vi.fn(),
},
consoleQuery: {
tags: {
list: {
queryOptions: (options: unknown) => options,
},
},
systemFeatures: {
queryKey: () => ['console', 'systemFeatures'],
},
},
}))
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: false,
userProfile: { id: 'creator-1' },
}),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
],
},
}),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(''),
}))
vi.mock('@/next/dynamic', () => ({
default: () => {
return function MockDynamicComponent() {
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
},
}))
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
default: () => null,
}))
vi.mock('@/features/tag-management/components/tag-selector', () => ({
TagSelector: () => <div data-testid="snippet-card-tags" />,
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
beforeAll(() => {
globalThis.IntersectionObserver = class MockIntersectionObserver {
constructor(_callback: IntersectionObserverCallback) {}
observe = mockObserve
disconnect = mockDisconnect
unobserve = vi.fn()
root = null
rootMargin = ''
thresholds = []
takeRecords = () => []
} as unknown as typeof IntersectionObserver
})
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
const mockSnippetListState = {
data: {
pages: [{
data: [
{
id: 'snippet-1',
name: 'Sales Snippet',
description: 'Builds a sales follow-up.',
type: 'node',
is_published: true,
use_count: 12,
tags: [],
created_at: 1704067200,
created_by: 'creator-1',
updated_at: 1704153600,
updated_by: 'creator-2',
},
],
page: 1,
limit: 30,
total: 1,
has_more: false,
}],
},
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
hasNextPage: false,
error: null as Error | null,
}
const renderList = () => {
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
return renderWithNuqs(
<SystemFeaturesWrapper>
<SnippetList />
</SystemFeaturesWrapper>,
)
}
describe('SnippetList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.creatorID = ''
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockUseInfiniteSnippetList.mockReturnValue({
...mockSnippetListState,
refetch: mockRefetch,
fetchNextPage: mockFetchNextPage,
})
})
it('renders the dedicated snippets list layout', () => {
renderList()
expect(screen.getByText('app.studio.filters.allCreators')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('workflow.tabs.searchSnippets')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'snippet.create' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Sales Snippet/ })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
})
it('passes creator, tag, and search filters to the snippets list query', () => {
mockQueryState.tagIDs = ['tag-1', 'tag-2']
mockQueryState.keywords = 'sales'
mockQueryState.creatorID = 'creator-1'
renderList()
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({
page: 1,
limit: 30,
keyword: 'sales',
tag_ids: ['tag-1', 'tag-2'],
creator_id: 'creator-1',
}, {
enabled: true,
})
})
it('updates the search query state from the search input', () => {
renderList()
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'summary' } })
expect(mockSetKeywords).toHaveBeenCalledWith('summary')
})
it('clears the search query state', () => {
mockQueryState.keywords = 'summary'
renderList()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(mockSetKeywords).toHaveBeenCalledWith('')
})
it('updates the creator query state as a single creator filter', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
})
it('hides the create button for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByRole('button', { name: 'snippet.create' })).not.toBeInTheDocument()
})
it('shows an empty state when no snippets are returned', () => {
mockUseInfiniteSnippetList.mockReturnValue({
...mockSnippetListState,
data: {
pages: [{
data: [],
page: 1,
limit: 30,
total: 0,
has_more: false,
}],
},
refetch: mockRefetch,
fetchNextPage: mockFetchNextPage,
})
renderList()
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,196 @@
import type { SnippetListItem } from '@/types/snippet'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SnippetCard from '../snippet-card'
const {
mockDeleteMutate,
mockDownloadBlob,
mockExportMutateAsync,
mockOnRefresh,
mockToastError,
mockToastSuccess,
mockUpdateMutate,
} = vi.hoisted(() => ({
mockDeleteMutate: vi.fn(),
mockDownloadBlob: vi.fn(),
mockExportMutateAsync: vi.fn(),
mockOnRefresh: vi.fn(),
mockToastError: vi.fn(),
mockToastSuccess: vi.fn(),
mockUpdateMutate: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'creator-id', name: 'Creator', email: 'creator@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
{ id: 'updater-id', name: 'Updater', email: 'updater@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/service/use-snippets', () => ({
useDeleteSnippetMutation: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: mockExportMutateAsync,
}),
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
}))
vi.mock('@/utils/time', () => ({
formatTime: () => 'formatted-time',
}))
vi.mock('@/utils/download', () => ({
downloadBlob: mockDownloadBlob,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}))
vi.mock('@/features/tag-management/components/tag-selector', () => ({
TagSelector: ({ value }: { value: Array<{ name: string }> }) => (
<div data-testid="snippet-tags">{value.map(tag => tag.name).join(', ')}</div>
),
}))
const createSnippet = (overrides: Partial<SnippetListItem> = {}): SnippetListItem => ({
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts.',
type: 'node',
is_published: true,
use_count: 19,
tags: [],
created_at: 1_704_067_200,
created_by: 'creator-id',
updated_at: 1_704_153_600,
updated_by: 'updater-id',
...overrides,
})
describe('SnippetCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render updater name and updated time from member data', () => {
render(<SnippetCard snippet={createSnippet()} />)
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('snippet.updatedBy:{"name":"Updater","time":"formatted-time"}')).toBeInTheDocument()
expect(screen.queryByText('snippet.usageCount:{"count":19}')).not.toBeInTheDocument()
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('should fall back to creator name when updater is unavailable', () => {
render(<SnippetCard snippet={createSnippet({ updated_by: 'missing-user' })} />)
expect(screen.getByText('snippet.updatedBy:{"name":"Creator","time":"formatted-time"}')).toBeInTheDocument()
})
it('should not render draft status for unpublished snippets', () => {
render(<SnippetCard snippet={createSnippet({ is_published: false })} />)
expect(screen.queryByText('snippet.draft')).not.toBeInTheDocument()
})
it('should render supported operations only', async () => {
render(<SnippetCard snippet={createSnippet()} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: /duplicate/i })).not.toBeInTheDocument()
})
it('should export a snippet from the operations menu', async () => {
mockExportMutateAsync.mockResolvedValue('snippet-yaml')
render(<SnippetCard snippet={createSnippet()} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.exportSnippet' }))
await waitFor(() => {
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: 'snippet-1' })
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
fileName: 'Tone Rewriter.yml',
}))
})
})
it('should update snippet info from the operations menu', async () => {
mockUpdateMutate.mockImplementation((_payload, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetCard snippet={createSnippet()} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' }))
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'Updated Snippet' },
})
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
body: {
name: 'Updated Snippet',
description: 'Rewrites rough drafts.',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockOnRefresh).toHaveBeenCalled()
})
})
it('should delete a snippet from the operations menu', async () => {
mockDeleteMutate.mockImplementation((_payload, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetCard snippet={createSnippet()} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.deleteSnippet' }))
fireEvent.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
await waitFor(() => {
expect(mockDeleteMutate).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockOnRefresh).toHaveBeenCalled()
})
})
})
})

View File

@ -0,0 +1,70 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SnippetCreateButton from '../snippet-create-button'
const { mockPush, mockCreateMutate, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockCreateMutate: vi.fn(),
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutate: mockCreateMutate,
isPending: false,
}),
}))
describe('SnippetCreateButton', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should open the create dialog and create a snippet from the modal', async () => {
mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => {
options?.onSuccess?.({ id: 'snippet-123' })
})
render(<SnippetCreateButton />)
fireEvent.click(screen.getByRole('button', { name: 'snippet.create' }))
expect(screen.getByText('workflow.snippet.createDialogTitle')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'My Snippet' },
})
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), {
target: { value: 'Useful snippet description' },
})
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
expect(mockCreateMutate).toHaveBeenCalledWith({
body: {
name: 'My Snippet',
description: 'Useful snippet description',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
})
})

View File

@ -0,0 +1,245 @@
'use client'
import type { SnippetListItem } from '@/types/snippet'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { useAppContext } from '@/context/app-context'
import { TagSelector } from '@/features/tag-management/components/tag-selector'
import Link from '@/next/link'
import { useMembers } from '@/service/use-common'
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
import { downloadBlob } from '@/utils/download'
import { formatTime } from '@/utils/time'
type Props = {
snippet: SnippetListItem
onOpenTagManagement?: () => void
onRefresh?: () => void
onTagsChange?: () => void
}
const SnippetCard = ({
snippet,
onOpenTagManagement = () => {},
onRefresh,
onTagsChange,
}: Props) => {
const { t } = useTranslation('snippet')
const { t: tCommon } = useTranslation()
const { isCurrentWorkspaceEditor } = useAppContext()
const { data: membersData } = useMembers()
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const updateSnippetMutation = useUpdateSnippetMutation()
const exportSnippetMutation = useExportSnippetMutation()
const deleteSnippetMutation = useDeleteSnippetMutation()
const memberNameById = useMemo(() => {
return new Map((membersData?.accounts ?? []).map(member => [member.id, member.name]))
}, [membersData?.accounts])
const updatedByName = memberNameById.get(snippet.updated_by)
|| memberNameById.get(snippet.created_by)
|| t('unknownUser')
const updatedAt = snippet.updated_at || snippet.created_at
const updatedAtText = formatTime({
date: (updatedAt > 1_000_000_000_000 ? updatedAt : updatedAt * 1000),
dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`,
})
const updatedText = t('updatedBy', {
name: updatedByName,
time: updatedAtText,
})
const initialValue = useMemo(() => ({
name: snippet.name,
description: snippet.description,
}), [snippet.description, snippet.name])
const handleOpenEditDialog = () => {
setIsOperationsMenuOpen(false)
setIsEditDialogOpen(true)
}
const handleExportSnippet = async () => {
setIsOperationsMenuOpen(false)
try {
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
}
catch {
toast.error(t('exportFailed'))
}
}
const handleDeleteSnippet = () => {
deleteSnippetMutation.mutate({
params: { snippetId: snippet.id },
}, {
onSuccess: () => {
toast.success(t('deleted'))
setIsDeleteDialogOpen(false)
onRefresh?.()
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
},
})
}
const handleUpdateSnippet = ({ name, description }: {
name: string
description: string
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
},
}, {
onSuccess: () => {
toast.success(t('editDone'))
setIsEditDialogOpen(false)
onRefresh?.()
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('editFailed'))
},
})
}
return (
<>
<article className="group relative col-span-1 inline-flex h-55 w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg p-6 shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
<Link href={`/snippets/${snippet.id}/orchestrate`} className="flex min-h-0 grow flex-col">
<div className="truncate text-lg leading-6 font-semibold text-text-secondary" title={snippet.name}>
{snippet.name}
</div>
<div className="mt-1 truncate text-sm leading-5 text-text-tertiary italic" title={updatedText}>
{updatedText}
</div>
<div className="mt-6 min-h-0 text-sm leading-5 text-text-tertiary">
<div className="line-clamp-3" title={snippet.description}>
{snippet.description}
</div>
</div>
</Link>
<div className="mt-4 mr-10">
<TagSelector
placement="bottom-start"
type="snippet"
targetId={snippet.id}
value={snippet.tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onTagsChange}
/>
</div>
{isCurrentWorkspaceEditor && (
<div
className={cn(
'absolute right-6 bottom-5 flex items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={tCommon('operation.more', { ns: 'common' })}
className="flex size-8 items-center justify-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg p-2 text-text-tertiary shadow-xs hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset data-popup-open:bg-state-base-hover"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[216px]"
>
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenEditDialog}>
<span className="system-sm-regular text-text-secondary">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 px-3" onClick={handleExportSnippet}>
<span className="system-sm-regular text-text-secondary">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={() => {
setIsOperationsMenuOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span className="system-sm-regular">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</article>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={tCommon('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleUpdateSnippet}
/>
)}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="w-100">
<div className="space-y-2 p-6">
<AlertDialogTitle className="title-md-semi-bold text-text-primary">
{t('deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('deleteConfirmContent')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogCancelButton disabled={deleteSnippetMutation.isPending}>
{tCommon('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default SnippetCard

View File

@ -0,0 +1,66 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import {
useCreateSnippetMutation,
} from '@/service/use-snippets'
const SnippetCreateButton = () => {
const { t } = useTranslation('snippet')
const { push } = useRouter()
const createSnippetMutation = useCreateSnippetMutation()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const handleCreateSnippet = ({
name,
description,
}: {
name: string
description: string
}) => {
createSnippetMutation.mutate({
body: {
name,
description: description || undefined,
},
}, {
onSuccess: (snippet) => {
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
setIsCreateDialogOpen(false)
push(`/snippets/${snippet.id}/orchestrate`)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('createFailed'))
},
})
}
return (
<>
<Button
variant="primary"
disabled={createSnippetMutation.isPending}
onClick={() => setIsCreateDialogOpen(true)}
>
<span aria-hidden className="mr-0.5 i-ri-add-line size-4" />
<span>{t('create')}</span>
</Button>
{isCreateDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateDialogOpen}
isSubmitting={createSnippetMutation.isPending}
onClose={() => setIsCreateDialogOpen(false)}
onConfirm={handleCreateSnippet}
/>
)}
</>
)
}
export default SnippetCreateButton

View File

@ -0,0 +1 @@
export const SNIPPET_LIST_SEARCH_DEBOUNCE_MS = 500

View File

@ -0,0 +1,38 @@
import { debounce, parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
const snippetListQueryParsers = {
tagIDs: parseAsArrayOf(parseAsString, ';')
.withDefault([])
.withOptions({ history: 'push' }),
keywords: parseAsString.withDefault('').withOptions({
limitUrlUpdates: debounce(SNIPPET_LIST_SEARCH_DEBOUNCE_MS),
}),
creatorID: parseAsString
.withDefault('')
.withOptions({ history: 'push' }),
}
export function useSnippetsQueryState() {
const [query, setQuery] = useQueryStates(snippetListQueryParsers)
const setKeywords = useCallback((keywords: string) => {
setQuery({ keywords })
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery({ tagIDs })
}, [setQuery])
const setCreatorID = useCallback((creatorID: string) => {
setQuery({ creatorID })
}, [setQuery])
return useMemo(() => ({
query,
setKeywords,
setTagIDs,
setCreatorID,
}), [query, setCreatorID, setKeywords, setTagIDs])
}

View File

@ -0,0 +1,195 @@
'use client'
import type { SnippetListItem } from '@/types/snippet'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import useDocumentTitle from '@/hooks/use-document-title'
import dynamic from '@/next/dynamic'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import CreatorsFilter from '../apps/creators-filter'
import Empty from '../apps/empty'
import Footer from '../apps/footer'
import SnippetCard from './components/snippet-card'
import SnippetCreateButton from './components/snippet-create-button'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import { useSnippetsQueryState } from './hooks/use-snippets-query-state'
const TagManagementModal = dynamic(() => import('@/features/tag-management/components/tag-management-modal').then(mod => mod.TagManagementModal), {
ssr: false,
})
const SNIPPET_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
type SnippetCardSkeletonProps = {
count: number
}
const SnippetCardSkeleton = ({ count }: SnippetCardSkeletonProps) => {
return (
<>
{SNIPPET_CARD_SKELETON_KEYS.slice(0, count).map(key => (
<div
key={key}
className="col-span-1 h-55 animate-pulse rounded-xl bg-background-default-lighter"
/>
))}
</>
)
}
const SnippetList = () => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { tagIDs, keywords, creatorID },
setKeywords,
setTagIDs,
setCreatorID,
} = useSnippetsQueryState()
const debouncedKeywords = useDebounce(keywords, { wait: SNIPPET_LIST_SEARCH_DEBOUNCE_MS })
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
useDocumentTitle(t('tabs.snippets', { ns: 'workflow' }))
const snippetListQuery = useMemo(() => ({
page: 1,
limit: 30,
keyword: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(creatorID ? { creator_id: creatorID } : {}),
}), [creatorID, debouncedKeywords, tagIDs])
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
error,
refetch,
} = useInfiniteSnippetList(snippetListQuery, {
enabled: !isCurrentWorkspaceDatasetOperator,
})
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
fetchNextPage()
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [error, fetchNextPage, hasNextPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading])
const handleCreatorsChange = useCallback((creatorIDs: string[]) => {
setCreatorID(creatorIDs.at(-1) ?? '')
}, [setCreatorID])
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const snippets = useMemo<SnippetListItem[]>(() => pages.flatMap(({ data: pageSnippets }) => pageSnippets), [pages])
const hasAnySnippet = (pages[0]?.total ?? 0) > 0
const showSkeleton = isLoading || (isFetching && pages.length === 0)
return (
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 bg-background-body px-12 pt-7 pb-5">
<div className="flex flex-wrap items-center gap-2">
<CreatorsFilter
value={creatorID ? [creatorID] : []}
onChange={handleCreatorsChange}
/>
<TagFilter type="snippet" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<div className="relative w-50">
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
<Input
className={cn('pl-6.5', keywords && 'pr-6.5')}
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder={t('tabs.searchSnippets', { ns: 'workflow' })}
/>
{!!keywords && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
onClick={() => setKeywords('')}
>
<span aria-hidden className="i-ri-close-circle-fill size-4" />
</button>
)}
</div>
</div>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<SnippetCreateButton />
)}
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
!hasAnySnippet && 'overflow-hidden',
)}
>
{showSkeleton
? <SnippetCardSkeleton count={6} />
: hasAnySnippet
? snippets.map(snippet => (
<SnippetCard
key={snippet.id}
snippet={snippet}
onOpenTagManagement={() => setShowTagManagementModal(true)}
onRefresh={refetch}
onTagsChange={refetch}
/>
))
: <Empty message={t('tabs.noSnippetsFound', { ns: 'workflow' })} />}
{isFetchingNextPage && (
<SnippetCardSkeleton count={3} />
)}
</div>
{!systemFeatures.branding.enabled && (
<Footer />
)}
<div ref={anchorRef} className="h-0"> </div>
<TagManagementModal
type="snippet"
show={showTagManagementModal}
onClose={() => setShowTagManagementModal(false)}
onTagsChange={refetch}
/>
</div>
)
}
export default SnippetList

View File

@ -0,0 +1,137 @@
import type { SnippetDetailPayload } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import SnippetPage from '..'
const mockUseSnippetInit = vi.fn()
const mockSetAppSidebarExpand = vi.fn()
vi.mock('../hooks/use-snippet-init', () => ({
useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId),
}))
vi.mock('../components/snippet-main', () => ({
default: ({ snippetId }: { snippetId: string }) => <div data-testid="snippet-main">{snippetId}</div>,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: vi.fn(),
push: vi.fn(),
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
setAppSidebarExpand: mockSetAppSidebarExpand,
}),
}))
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-default-context">{children}</div>
),
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
initialNodes: (nodes: unknown[]) => nodes,
initialEdges: (edges: unknown[]) => edges,
}
})
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
renderHeader,
renderNavigation,
}: {
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}) => (
<div data-testid="app-sidebar">
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
</div>
),
}))
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{name}</button>
),
}))
vi.mock('@/app/components/app-sidebar/snippet-info', () => ({
default: () => <div data-testid="snippet-info" />,
}))
const mockSnippetDetail: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
updatedAt: 'Updated 2h ago',
usage: 'Used 19 times',
tags: [],
status: 'Draft',
},
graph: {
viewport: { x: 0, y: 0, zoom: 1 },
nodes: [],
edges: [],
},
inputFields: [],
uiMeta: {
inputFieldCount: 0,
checklistCount: 0,
autoSavedAt: 'Auto-saved · a few seconds ago',
},
}
describe('SnippetPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSnippetInit.mockReturnValue({
data: mockSnippetDetail,
isLoading: false,
})
})
it('should render the orchestrate route shell with independent main content', () => {
render(<SnippetPage snippetId="snippet-1" />)
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
expect(screen.getByTestId('snippet-info')).toBeInTheDocument()
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
expect(screen.getByTestId('snippet-main')).toHaveTextContent('snippet-1')
})
it('should render loading fallback when orchestrate data is unavailable', () => {
mockUseSnippetInit.mockReturnValue({
data: null,
isLoading: false,
})
render(<SnippetPage snippetId="missing-snippet" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})

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