Compare commits

..

294 Commits

Author SHA1 Message Date
c46a313d78 Merge branch 'main' into feat/snippet-fe 2026-05-28 14:31:54 +08:00
57b02e341c refactor: add @override decorators to storage backend subclasses (#36406) (#36755) 2026-05-28 06:04:47 +00:00
b94ff65e9f fix(docker): copy dify-agent source into production stage (#36757) 2026-05-28 06:01:11 +00:00
678260e34e test: migrate workspace members tests to containers (#36738)
Co-authored-by: jamesrayammons <63717587+jamesrayammons@users.noreply.github.com>
2026-05-28 06:01:05 +00:00
739e34d08a fix(docker): pin web docker node version (#36756) 2026-05-28 05:25:41 +00:00
e1fec86a2a Merge branch 'main' into feat/evaluation-fe 2026-05-28 13:11:45 +08:00
6ec893cb0e feat(web): new interactions in snippet detail graph 2026-05-28 13:11:07 +08:00
8be34ee000 feat(web): support create snippet by import DSL 2026-05-28 11:42:57 +08:00
458f669883 Merge branch 'main' into feat/evaluation-fe 2026-05-28 11:04:59 +08:00
94fd4e9c67 Merge branch 'main' into feat/evaluation-fe 2026-05-27 22:34:15 +08:00
f5da3ce499 fix(web): refresh list after snippet created 2026-05-27 22:33:45 +08:00
08f2971f72 fix(web): snippet draft sync 2026-05-27 22:29:49 +08:00
54ac42fbc4 Merge branch 'main' into feat/evaluation-fe 2026-05-27 22:08:12 +08:00
1d1d571213 fix(web): error message of snippet creation 2026-05-27 22:07:06 +08:00
056caa8b2f fix(web): style of tag filter of snippets 2026-05-27 22:00:05 +08:00
1cf6cdb764 fix(web): stop snippet run 2026-05-27 21:39:14 +08:00
ac9083fbf1 fix(web): tooltip of snippet saved 2026-05-27 21:24:07 +08:00
fdfc9ab3d3 fix(web): sys variables not supported in snippet 2026-05-27 21:13:19 +08:00
83cd1a8d7a fix(web): snippet restore 2026-05-27 21:06:36 +08:00
a3dfd670b0 fix(web): sys variables not supported in snippet 2026-05-27 21:03:00 +08:00
facace019b fix(web): merge error fix 2026-05-27 20:58:03 +08:00
fd9543868d Merge branch 'main' into feat/evaluation-fe 2026-05-27 20:33:59 +08:00
89188256e1 Merge branch 'main' into feat/evaluation-fe 2026-05-25 15:44:50 +08:00
bba3a1bcee fix(web): draft sync of snippet 2026-05-25 15:44:27 +08:00
7c0be7f905 Merge branch 'main' into feat/evaluation-fe 2026-05-25 14:35:41 +08:00
599e3475f2 fix(web): hide snippets tab in block-selector in snippet detail 2026-05-25 14:35:24 +08:00
718fe548e9 fix(web): cancel button in snippet 2026-05-25 14:30:39 +08:00
060ceaffd1 feat(web): snippet info siderbar 2026-05-25 14:18:15 +08:00
00908ca0fb Merge branch 'main' into feat/evaluation-fe 2026-05-25 13:36:05 +08:00
2812d61e24 feat(web): snippet card style 2026-05-25 13:35:59 +08:00
be1d6520f9 fix(web): icon_info nullable in dataset 2026-05-25 11:41:02 +08:00
7fb2e4751f Merge branch 'main' into feat/evaluation-fe 2026-05-25 11:05:26 +08:00
5441992604 Merge branch 'main' into feat/evaluation-fe 2026-05-25 10:00:09 +08:00
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
180 changed files with 12321 additions and 660 deletions

View File

@ -1,4 +1,4 @@
name: Deploy SaaS
name: Deploy Agent Dev
permissions:
contents: read
@ -7,7 +7,7 @@ on:
workflow_run:
workflows: ["Build and Push API & Web"]
branches:
- "deploy/saas"
- "deploy/agent-dev"
types:
- completed
@ -16,7 +16,7 @@ jobs:
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/saas'
github.event.workflow_run.head_branch == 'deploy/agent-dev'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5

View File

@ -27,7 +27,7 @@ COPY api/providers ./providers
COPY dify-agent/pyproject.toml dify-agent/README.md /app/dify-agent/
COPY dify-agent/src /app/dify-agent/src
# Trust the checked-in lock during image builds; local path sources are copied from the repository context.
RUN uv sync --frozen --no-dev
RUN uv sync --frozen --no-dev --no-editable
# production stage
FROM base AS production

View File

@ -1,5 +1,6 @@
import posixpath
from collections.abc import Generator
from typing import override
import oss2 as aliyun_s3
@ -29,9 +30,11 @@ class AliyunOssStorage(BaseStorage):
cloudbox_id=dify_config.ALIYUN_CLOUDBOX_ID,
)
@override
def save(self, filename, data):
self.client.put_object(self.__wrapper_folder_filename(filename), data)
@override
def load_once(self, filename: str) -> bytes:
obj = self.client.get_object(self.__wrapper_folder_filename(filename))
data = obj.read()
@ -39,17 +42,21 @@ class AliyunOssStorage(BaseStorage):
return b""
return data
@override
def load_stream(self, filename: str) -> Generator:
obj = self.client.get_object(self.__wrapper_folder_filename(filename))
while chunk := obj.read(4096):
yield chunk
@override
def download(self, filename: str, target_filepath):
self.client.get_object_to_file(self.__wrapper_folder_filename(filename), target_filepath)
@override
def exists(self, filename: str):
return self.client.object_exists(self.__wrapper_folder_filename(filename))
@override
def delete(self, filename: str):
self.client.delete_object(self.__wrapper_folder_filename(filename))

View File

@ -1,5 +1,6 @@
import logging
from collections.abc import Generator
from typing import override
import boto3
from botocore.client import Config
@ -48,9 +49,11 @@ class AwsS3Storage(BaseStorage):
# other error, raise exception
raise
@override
def save(self, filename, data):
self.client.put_object(Bucket=self.bucket_name, Key=filename, Body=data)
@override
def load_once(self, filename: str) -> bytes:
try:
data: bytes = self.client.get_object(Bucket=self.bucket_name, Key=filename)["Body"].read()
@ -61,6 +64,7 @@ class AwsS3Storage(BaseStorage):
raise
return data
@override
def load_stream(self, filename: str) -> Generator:
try:
response = self.client.get_object(Bucket=self.bucket_name, Key=filename)
@ -73,9 +77,11 @@ class AwsS3Storage(BaseStorage):
else:
raise
@override
def download(self, filename, target_filepath):
self.client.download_file(self.bucket_name, filename, target_filepath)
@override
def exists(self, filename):
try:
self.client.head_object(Bucket=self.bucket_name, Key=filename)
@ -83,5 +89,6 @@ class AwsS3Storage(BaseStorage):
except:
return False
@override
def delete(self, filename: str):
self.client.delete_object(Bucket=self.bucket_name, Key=filename)

View File

@ -1,5 +1,6 @@
from collections.abc import Generator
from datetime import timedelta
from typing import override
from azure.identity import ChainedTokenCredential, DefaultAzureCredential
from azure.storage.blob import AccountSasPermissions, BlobServiceClient, ResourceTypes, generate_account_sas
@ -26,6 +27,7 @@ class AzureBlobStorage(BaseStorage):
else:
self.credential = None
@override
def save(self, filename, data):
if not self.bucket_name:
return
@ -34,6 +36,7 @@ class AzureBlobStorage(BaseStorage):
blob_container = client.get_container_client(container=self.bucket_name)
blob_container.upload_blob(filename, data)
@override
def load_once(self, filename: str) -> bytes:
if not self.bucket_name:
raise FileNotFoundError("Azure bucket name is not configured.")
@ -46,6 +49,7 @@ class AzureBlobStorage(BaseStorage):
raise TypeError(f"Expected bytes from blob.readall(), got {type(data).__name__}")
return data
@override
def load_stream(self, filename: str) -> Generator:
if not self.bucket_name:
raise FileNotFoundError("Azure bucket name is not configured.")
@ -55,6 +59,7 @@ class AzureBlobStorage(BaseStorage):
blob_data = blob.download_blob()
yield from blob_data.chunks()
@override
def download(self, filename, target_filepath):
if not self.bucket_name:
return
@ -66,6 +71,7 @@ class AzureBlobStorage(BaseStorage):
blob_data = blob.download_blob()
blob_data.readinto(my_blob)
@override
def exists(self, filename):
if not self.bucket_name:
return False
@ -75,6 +81,7 @@ class AzureBlobStorage(BaseStorage):
blob = client.get_blob_client(container=self.bucket_name, blob=filename)
return blob.exists()
@override
def delete(self, filename: str):
if not self.bucket_name:
return

View File

@ -1,6 +1,7 @@
import base64
import hashlib
from collections.abc import Generator
from typing import override
from baidubce.auth.bce_credentials import BceCredentials
from baidubce.bce_client_configuration import BceClientConfiguration
@ -26,6 +27,7 @@ class BaiduObsStorage(BaseStorage):
self.client = BosClient(config=client_config)
@override
def save(self, filename, data):
md5 = hashlib.md5()
md5.update(data)
@ -34,24 +36,29 @@ class BaiduObsStorage(BaseStorage):
bucket_name=self.bucket_name, key=filename, data=data, content_length=len(data), content_md5=content_md5
)
@override
def load_once(self, filename: str) -> bytes:
response = self.client.get_object(bucket_name=self.bucket_name, key=filename)
data: bytes = response.data.read()
return data
@override
def load_stream(self, filename: str) -> Generator:
response = self.client.get_object(bucket_name=self.bucket_name, key=filename).data
while chunk := response.read(4096):
yield chunk
@override
def download(self, filename, target_filepath):
self.client.get_object_to_file(bucket_name=self.bucket_name, key=filename, file_name=target_filepath)
@override
def exists(self, filename):
res = self.client.get_object_meta_data(bucket_name=self.bucket_name, key=filename)
if res is None:
return False
return True
@override
def delete(self, filename: str):
self.client.delete_object(bucket_name=self.bucket_name, key=filename)

View File

@ -10,7 +10,7 @@ import tempfile
from collections.abc import Generator
from io import BytesIO
from pathlib import Path
from typing import Any
from typing import Any, override
import clickzetta
from pydantic import BaseModel, model_validator
@ -251,6 +251,7 @@ class ClickZettaVolumeStorage(BaseStorage):
# Don't raise exception, let the operation continue
# The table might exist but not be visible due to permissions
@override
def save(self, filename: str, data: bytes):
"""Save data to ClickZetta Volume.
@ -304,6 +305,7 @@ class ClickZettaVolumeStorage(BaseStorage):
# Clean up temporary file
Path(temp_file_path).unlink(missing_ok=True)
@override
def load_once(self, filename: str) -> bytes:
"""Load file content from ClickZetta Volume.
@ -364,6 +366,7 @@ class ClickZettaVolumeStorage(BaseStorage):
logger.debug("File %s loaded from ClickZetta Volume", filename)
return content
@override
def load_stream(self, filename: str) -> Generator:
"""Load file as stream from ClickZetta Volume.
@ -382,6 +385,7 @@ class ClickZettaVolumeStorage(BaseStorage):
logger.debug("File %s loaded as stream from ClickZetta Volume", filename)
@override
def download(self, filename: str, target_filepath: str):
"""Download file from ClickZetta Volume to local path.
@ -395,6 +399,7 @@ class ClickZettaVolumeStorage(BaseStorage):
logger.debug("File %s downloaded from ClickZetta Volume to %s", filename, target_filepath)
@override
def exists(self, filename: str) -> bool:
"""Check if file exists in ClickZetta Volume.
@ -436,6 +441,7 @@ class ClickZettaVolumeStorage(BaseStorage):
logger.warning("Error checking file existence for %s: %s", filename, e)
return False
@override
def delete(self, filename: str):
"""Delete file from ClickZetta Volume.
@ -472,6 +478,7 @@ class ClickZettaVolumeStorage(BaseStorage):
logger.debug("File %s deleted from ClickZetta Volume", filename)
@override
def scan(self, path: str, files: bool = True, directories: bool = False) -> list[str]:
"""Scan files and directories in ClickZetta Volume.

View File

@ -1,7 +1,7 @@
import base64
import io
from collections.abc import Generator
from typing import Any
from typing import Any, override
from google.cloud import storage as google_cloud_storage # type: ignore
from pydantic import TypeAdapter
@ -29,12 +29,14 @@ class GoogleCloudStorage(BaseStorage):
else:
self.client = google_cloud_storage.Client()
@override
def save(self, filename, data):
bucket = self.client.get_bucket(self.bucket_name)
blob = bucket.blob(filename)
with io.BytesIO(data) as stream:
blob.upload_from_file(stream)
@override
def load_once(self, filename: str) -> bytes:
bucket = self.client.get_bucket(self.bucket_name)
blob = bucket.get_blob(filename)
@ -43,6 +45,7 @@ class GoogleCloudStorage(BaseStorage):
data: bytes = blob.download_as_bytes()
return data
@override
def load_stream(self, filename: str) -> Generator:
bucket = self.client.get_bucket(self.bucket_name)
blob = bucket.get_blob(filename)
@ -52,6 +55,7 @@ class GoogleCloudStorage(BaseStorage):
while chunk := blob_stream.read(4096):
yield chunk
@override
def download(self, filename, target_filepath):
bucket = self.client.get_bucket(self.bucket_name)
blob = bucket.get_blob(filename)
@ -59,11 +63,13 @@ class GoogleCloudStorage(BaseStorage):
raise FileNotFoundError("File not found")
blob.download_to_filename(target_filepath)
@override
def exists(self, filename):
bucket = self.client.get_bucket(self.bucket_name)
blob = bucket.blob(filename)
return blob.exists()
@override
def delete(self, filename: str):
bucket = self.client.get_bucket(self.bucket_name)
bucket.delete_blob(filename)

View File

@ -1,4 +1,5 @@
from collections.abc import Generator
from typing import override
from obs import ObsClient
@ -20,27 +21,33 @@ class HuaweiObsStorage(BaseStorage):
path_style=dify_config.HUAWEI_OBS_PATH_STYLE,
)
@override
def save(self, filename, data):
self.client.putObject(bucketName=self.bucket_name, objectKey=filename, content=data)
@override
def load_once(self, filename: str) -> bytes:
data: bytes = self.client.getObject(bucketName=self.bucket_name, objectKey=filename)["body"].response.read()
return data
@override
def load_stream(self, filename: str) -> Generator:
response = self.client.getObject(bucketName=self.bucket_name, objectKey=filename)["body"].response
while chunk := response.read(4096):
yield chunk
@override
def download(self, filename, target_filepath):
self.client.getObject(bucketName=self.bucket_name, objectKey=filename, downloadPath=target_filepath)
@override
def exists(self, filename):
res = self._get_meta(filename)
if res is None:
return False
return True
@override
def delete(self, filename: str):
self.client.deleteObject(bucketName=self.bucket_name, objectKey=filename)

View File

@ -2,7 +2,7 @@ import logging
import os
from collections.abc import Generator
from pathlib import Path
from typing import Any
from typing import Any, override
import opendal
from dotenv import dotenv_values
@ -41,10 +41,12 @@ class OpenDALStorage(BaseStorage):
logger.debug("opendal operator created with scheme %s", scheme)
logger.debug("added retry layer to opendal operator")
@override
def save(self, filename: str, data: bytes):
self.op.write(path=filename, bs=data)
logger.debug("file %s saved", filename)
@override
def load_once(self, filename: str) -> bytes:
if not self.exists(filename):
raise FileNotFoundError("File not found")
@ -53,6 +55,7 @@ class OpenDALStorage(BaseStorage):
logger.debug("file %s loaded", filename)
return content
@override
def load_stream(self, filename: str) -> Generator:
if not self.exists(filename):
raise FileNotFoundError("File not found")
@ -67,6 +70,7 @@ class OpenDALStorage(BaseStorage):
yield chunk
logger.debug("file %s loaded as stream", filename)
@override
def download(self, filename: str, target_filepath: str):
if not self.exists(filename):
raise FileNotFoundError("File not found")
@ -74,9 +78,11 @@ class OpenDALStorage(BaseStorage):
Path(target_filepath).write_bytes(self.op.read(path=filename))
logger.debug("file %s downloaded to %s", filename, target_filepath)
@override
def exists(self, filename: str) -> bool:
return self.op.exists(path=filename)
@override
def delete(self, filename: str):
if self.exists(filename):
self.op.delete(path=filename)
@ -84,6 +90,7 @@ class OpenDALStorage(BaseStorage):
return
logger.debug("file %s not found, skip delete", filename)
@override
def scan(self, path: str, files: bool = True, directories: bool = False) -> list[str]:
if not self.exists(path):
raise FileNotFoundError("Path not found")

View File

@ -1,4 +1,5 @@
from collections.abc import Generator
from typing import override
import boto3
from botocore.exceptions import ClientError
@ -22,9 +23,11 @@ class OracleOCIStorage(BaseStorage):
region_name=dify_config.OCI_REGION,
)
@override
def save(self, filename, data):
self.client.put_object(Bucket=self.bucket_name, Key=filename, Body=data)
@override
def load_once(self, filename: str) -> bytes:
try:
data: bytes = self.client.get_object(Bucket=self.bucket_name, Key=filename)["Body"].read()
@ -35,6 +38,7 @@ class OracleOCIStorage(BaseStorage):
raise
return data
@override
def load_stream(self, filename: str) -> Generator:
try:
response = self.client.get_object(Bucket=self.bucket_name, Key=filename)
@ -45,9 +49,11 @@ class OracleOCIStorage(BaseStorage):
else:
raise
@override
def download(self, filename, target_filepath):
self.client.download_file(self.bucket_name, filename, target_filepath)
@override
def exists(self, filename):
try:
self.client.head_object(Bucket=self.bucket_name, Key=filename)
@ -55,5 +61,6 @@ class OracleOCIStorage(BaseStorage):
except:
return False
@override
def delete(self, filename: str):
self.client.delete_object(Bucket=self.bucket_name, Key=filename)

View File

@ -1,6 +1,7 @@
import io
from collections.abc import Generator
from pathlib import Path
from typing import override
from supabase import Client
@ -28,29 +29,35 @@ class SupabaseStorage(BaseStorage):
if not self.bucket_exists():
self.client.storage.create_bucket(id=id, name=bucket_name)
@override
def save(self, filename, data):
self.client.storage.from_(self.bucket_name).upload(filename, data)
@override
def load_once(self, filename: str) -> bytes:
content: bytes = self.client.storage.from_(self.bucket_name).download(filename)
return content
@override
def load_stream(self, filename: str) -> Generator:
result = self.client.storage.from_(self.bucket_name).download(filename)
byte_stream = io.BytesIO(result)
while chunk := byte_stream.read(4096): # Read in chunks of 4KB
yield chunk
@override
def download(self, filename, target_filepath):
result = self.client.storage.from_(self.bucket_name).download(filename)
Path(target_filepath).write_bytes(result)
@override
def exists(self, filename):
result = self.client.storage.from_(self.bucket_name).list(path=filename)
if len(result) > 0:
return True
return False
@override
def delete(self, filename: str):
self.client.storage.from_(self.bucket_name).remove([filename])

View File

@ -1,4 +1,5 @@
from collections.abc import Generator
from typing import override
from qcloud_cos import CosConfig, CosS3Client
@ -29,23 +30,29 @@ class TencentCosStorage(BaseStorage):
)
self.client = CosS3Client(config)
@override
def save(self, filename, data):
self.client.put_object(Bucket=self.bucket_name, Body=data, Key=filename)
@override
def load_once(self, filename: str) -> bytes:
data: bytes = self.client.get_object(Bucket=self.bucket_name, Key=filename)["Body"].get_raw_stream().read()
return data
@override
def load_stream(self, filename: str) -> Generator:
response = self.client.get_object(Bucket=self.bucket_name, Key=filename)
yield from response["Body"].get_stream(chunk_size=4096)
@override
def download(self, filename, target_filepath):
response = self.client.get_object(Bucket=self.bucket_name, Key=filename)
response["Body"].get_stream_to_file(target_filepath)
@override
def exists(self, filename):
return self.client.object_exists(Bucket=self.bucket_name, Key=filename)
@override
def delete(self, filename: str):
self.client.delete_object(Bucket=self.bucket_name, Key=filename)

View File

@ -1,4 +1,5 @@
from collections.abc import Generator
from typing import override
import tos
@ -27,11 +28,13 @@ class VolcengineTosStorage(BaseStorage):
region=dify_config.VOLCENGINE_TOS_REGION,
)
@override
def save(self, filename, data):
if not self.bucket_name:
raise ValueError("VOLCENGINE_TOS_BUCKET_NAME is not set")
self.client.put_object(bucket=self.bucket_name, key=filename, content=data)
@override
def load_once(self, filename: str) -> bytes:
if not self.bucket_name:
raise FileNotFoundError("VOLCENGINE_TOS_BUCKET_NAME is not set")
@ -40,6 +43,7 @@ class VolcengineTosStorage(BaseStorage):
raise TypeError(f"Expected bytes, got {type(data).__name__}")
return data
@override
def load_stream(self, filename: str) -> Generator:
if not self.bucket_name:
raise FileNotFoundError("VOLCENGINE_TOS_BUCKET_NAME is not set")
@ -47,11 +51,13 @@ class VolcengineTosStorage(BaseStorage):
while chunk := response.read(4096):
yield chunk
@override
def download(self, filename, target_filepath):
if not self.bucket_name:
raise ValueError("VOLCENGINE_TOS_BUCKET_NAME is not set")
self.client.get_object_to_file(bucket=self.bucket_name, key=filename, file_path=target_filepath)
@override
def exists(self, filename):
if not self.bucket_name:
return False
@ -60,6 +66,7 @@ class VolcengineTosStorage(BaseStorage):
return False
return True
@override
def delete(self, filename: str):
if not self.bucket_name:
return

View File

@ -0,0 +1,298 @@
from __future__ import annotations
from unittest.mock import patch
from uuid import uuid4
import pytest
from werkzeug.exceptions import HTTPException
import services
from controllers.console.auth.error import MemberNotInTenantError
from controllers.console.workspace import members as members_module
from controllers.console.workspace.members import MemberCancelInviteApi, MemberUpdateRoleApi, OwnerTransfer
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus
def unwrap(func):
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
return func
class WorkspaceMembersIntegrationFactory:
@staticmethod
def create_tenant(db_session_with_containers) -> Tenant:
tenant = Tenant(name=f"Tenant {uuid4()}", plan="basic", status=TenantStatus.NORMAL)
db_session_with_containers.add(tenant)
db_session_with_containers.commit()
return tenant
@staticmethod
def create_account(
db_session_with_containers,
*,
email_prefix: str,
tenant: Tenant | None = None,
role: TenantAccountRole = TenantAccountRole.NORMAL,
current: bool = False,
) -> Account:
account = Account(
name=f"Account {uuid4()}",
email=f"{email_prefix}-{uuid4()}@example.com",
password="hashed-password",
password_salt="salt",
interface_language="en-US",
timezone="UTC",
)
db_session_with_containers.add(account)
db_session_with_containers.commit()
if tenant is not None:
join = TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
role=role,
current=current,
)
db_session_with_containers.add(join)
db_session_with_containers.commit()
account.current_tenant = tenant
return account
@staticmethod
def create_owner_workspace(db_session_with_containers) -> tuple[Tenant, Account]:
tenant = WorkspaceMembersIntegrationFactory.create_tenant(db_session_with_containers)
owner = WorkspaceMembersIntegrationFactory.create_account(
db_session_with_containers,
email_prefix="owner",
tenant=tenant,
role=TenantAccountRole.OWNER,
current=True,
)
return tenant, owner
@staticmethod
def create_owner_transfer_token(account: Account) -> str:
_, token = members_module.AccountService.generate_owner_transfer_token(
account.email,
account=account,
code="123456",
additional_data={},
)
return token
@staticmethod
def get_join(db_session_with_containers, *, tenant: Tenant, account: Account) -> TenantAccountJoin:
tenant_id = tenant.id
account_id = account.id
db_session_with_containers.expire_all()
join = (
db_session_with_containers.query(TenantAccountJoin)
.filter_by(tenant_id=tenant_id, account_id=account_id)
.one()
)
return join
class TestMemberCancelInviteApiWithContainers:
def test_cancel_success(self, flask_app_with_containers, db_session_with_containers):
api = MemberCancelInviteApi()
method = unwrap(api.delete)
factory = WorkspaceMembersIntegrationFactory
tenant, current_user = factory.create_owner_workspace(db_session_with_containers)
member = factory.create_account(db_session_with_containers, email_prefix="member")
with (
flask_app_with_containers.test_request_context("/"),
patch.object(members_module, "current_account_with_tenant", return_value=(current_user, tenant.id)),
patch.object(members_module.TenantService, "remove_member_from_tenant") as mock_remove_member,
):
result, status = method(api, member.id)
assert status == 200
assert result["result"] == "success"
mock_remove_member.assert_called_once()
called_tenant, called_member, called_current_user = mock_remove_member.call_args.args
assert called_tenant.id == tenant.id
assert called_member.id == member.id
assert called_current_user.id == current_user.id
def test_cancel_not_found(self, flask_app_with_containers, db_session_with_containers):
api = MemberCancelInviteApi()
method = unwrap(api.delete)
factory = WorkspaceMembersIntegrationFactory
tenant, current_user = factory.create_owner_workspace(db_session_with_containers)
with (
flask_app_with_containers.test_request_context("/"),
patch.object(members_module, "current_account_with_tenant", return_value=(current_user, tenant.id)),
):
with pytest.raises(HTTPException):
method(api, str(uuid4()))
def test_cancel_cannot_operate_self(self, flask_app_with_containers, db_session_with_containers):
api = MemberCancelInviteApi()
method = unwrap(api.delete)
factory = WorkspaceMembersIntegrationFactory
tenant, current_user = factory.create_owner_workspace(db_session_with_containers)
member = factory.create_account(db_session_with_containers, email_prefix="member")
with (
flask_app_with_containers.test_request_context("/"),
patch.object(members_module, "current_account_with_tenant", return_value=(current_user, tenant.id)),
patch.object(
members_module.TenantService,
"remove_member_from_tenant",
side_effect=services.errors.account.CannotOperateSelfError("x"),
),
):
result, status = method(api, member.id)
assert status == 400
assert result["code"] == "cannot-operate-self"
def test_cancel_no_permission(self, flask_app_with_containers, db_session_with_containers):
api = MemberCancelInviteApi()
method = unwrap(api.delete)
factory = WorkspaceMembersIntegrationFactory
tenant, current_user = factory.create_owner_workspace(db_session_with_containers)
member = factory.create_account(db_session_with_containers, email_prefix="member")
with (
flask_app_with_containers.test_request_context("/"),
patch.object(members_module, "current_account_with_tenant", return_value=(current_user, tenant.id)),
patch.object(
members_module.TenantService,
"remove_member_from_tenant",
side_effect=services.errors.account.NoPermissionError("x"),
),
):
result, status = method(api, member.id)
assert status == 403
assert result["code"] == "forbidden"
def test_cancel_member_not_in_tenant(self, flask_app_with_containers, db_session_with_containers):
api = MemberCancelInviteApi()
method = unwrap(api.delete)
factory = WorkspaceMembersIntegrationFactory
tenant, current_user = factory.create_owner_workspace(db_session_with_containers)
member = factory.create_account(db_session_with_containers, email_prefix="member")
with (
flask_app_with_containers.test_request_context("/"),
patch.object(members_module, "current_account_with_tenant", return_value=(current_user, tenant.id)),
patch.object(
members_module.TenantService,
"remove_member_from_tenant",
side_effect=services.errors.account.MemberNotInTenantError(),
),
):
result, status = method(api, member.id)
assert status == 404
assert result["code"] == "member-not-found"
class TestMemberUpdateRoleApiWithContainers:
def test_update_success(self, flask_app_with_containers, db_session_with_containers):
api = MemberUpdateRoleApi()
method = unwrap(api.put)
factory = WorkspaceMembersIntegrationFactory
tenant, current_user = factory.create_owner_workspace(db_session_with_containers)
member = factory.create_account(
db_session_with_containers,
email_prefix="member",
tenant=tenant,
role=TenantAccountRole.EDITOR,
)
with (
flask_app_with_containers.test_request_context("/", json={"role": "normal"}),
patch.object(members_module, "current_account_with_tenant", return_value=(current_user, tenant.id)),
):
result = method(api, member.id)
if isinstance(result, tuple):
result = result[0]
assert result["result"] == "success"
assert (
factory.get_join(db_session_with_containers, tenant=tenant, account=member).role == TenantAccountRole.NORMAL
)
def test_update_member_not_found(self, flask_app_with_containers, db_session_with_containers):
api = MemberUpdateRoleApi()
method = unwrap(api.put)
factory = WorkspaceMembersIntegrationFactory
tenant, current_user = factory.create_owner_workspace(db_session_with_containers)
with (
flask_app_with_containers.test_request_context("/", json={"role": "normal"}),
patch.object(members_module, "current_account_with_tenant", return_value=(current_user, tenant.id)),
):
with pytest.raises(HTTPException):
method(api, str(uuid4()))
class TestOwnerTransferApiWithContainers:
def test_member_not_in_tenant(self, flask_app_with_containers, db_session_with_containers):
api = OwnerTransfer()
method = unwrap(api.post)
factory = WorkspaceMembersIntegrationFactory
tenant, current_user = factory.create_owner_workspace(db_session_with_containers)
member = factory.create_account(db_session_with_containers, email_prefix="member")
token = factory.create_owner_transfer_token(current_user)
with (
flask_app_with_containers.test_request_context("/", json={"token": token}),
patch.object(members_module, "current_account_with_tenant", return_value=(current_user, tenant.id)),
):
with pytest.raises(MemberNotInTenantError):
method(api, member.id)
def test_member_not_found(self, flask_app_with_containers, db_session_with_containers):
api = OwnerTransfer()
method = unwrap(api.post)
factory = WorkspaceMembersIntegrationFactory
tenant, current_user = factory.create_owner_workspace(db_session_with_containers)
token = factory.create_owner_transfer_token(current_user)
with (
flask_app_with_containers.test_request_context("/", json={"token": token}),
patch.object(members_module, "current_account_with_tenant", return_value=(current_user, tenant.id)),
):
with pytest.raises(HTTPException):
method(api, str(uuid4()))
def test_transfer_success(self, flask_app_with_containers, db_session_with_containers):
api = OwnerTransfer()
method = unwrap(api.post)
factory = WorkspaceMembersIntegrationFactory
tenant, current_user = factory.create_owner_workspace(db_session_with_containers)
member = factory.create_account(
db_session_with_containers,
email_prefix="member",
tenant=tenant,
role=TenantAccountRole.NORMAL,
)
token = factory.create_owner_transfer_token(current_user)
with (
flask_app_with_containers.test_request_context("/", json={"token": token}),
patch.object(members_module, "current_account_with_tenant", return_value=(current_user, tenant.id)),
patch.object(members_module.AccountService, "send_new_owner_transfer_notify_email") as mock_new_owner_email,
patch.object(members_module.AccountService, "send_old_owner_transfer_notify_email") as mock_old_owner_email,
):
result = method(api, member.id)
assert result["result"] == "success"
assert (
factory.get_join(db_session_with_containers, tenant=tenant, account=member).role == TenantAccountRole.OWNER
)
assert (
factory.get_join(db_session_with_containers, tenant=tenant, account=current_user).role
== TenantAccountRole.ADMIN
)
mock_new_owner_email.assert_called_once()
mock_old_owner_email.assert_called_once()

View File

@ -3,22 +3,18 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from werkzeug.exceptions import HTTPException
import services
from controllers.console.auth.error import (
CannotTransferOwnerToSelfError,
EmailCodeError,
InvalidEmailError,
InvalidTokenError,
MemberNotInTenantError,
NotOwnerError,
OwnerTransferLimitError,
)
from controllers.console.error import EmailSendIpLimitError, WorkspaceMembersLimitExceeded
from controllers.console.workspace.members import (
DatasetOperatorMemberListApi,
MemberCancelInviteApi,
MemberInviteEmailApi,
MemberListApi,
MemberUpdateRoleApi,
@ -251,135 +247,7 @@ class TestMemberInviteEmailApi:
assert result["invitation_results"][0]["status"] == "failed"
class TestMemberCancelInviteApi:
def test_cancel_success(self, app: Flask):
api = MemberCancelInviteApi()
method = unwrap(api.delete)
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
member = MagicMock()
with (
app.test_request_context("/"),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.db.session.get") as get_mock,
patch("controllers.console.workspace.members.TenantService.remove_member_from_tenant"),
):
get_mock.return_value = member
result, status = method(api, member.id)
assert status == 200
assert result["result"] == "success"
def test_cancel_not_found(self, app: Flask):
api = MemberCancelInviteApi()
method = unwrap(api.delete)
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
with (
app.test_request_context("/"),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.db.session.get") as get_mock,
):
get_mock.return_value = None
with pytest.raises(HTTPException):
method(api, "x")
def test_cancel_cannot_operate_self(self, app: Flask):
api = MemberCancelInviteApi()
method = unwrap(api.delete)
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
member = MagicMock()
with (
app.test_request_context("/"),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.db.session.get") as get_mock,
patch(
"controllers.console.workspace.members.TenantService.remove_member_from_tenant",
side_effect=services.errors.account.CannotOperateSelfError("x"),
),
):
get_mock.return_value = member
result, status = method(api, member.id)
assert status == 400
def test_cancel_no_permission(self, app: Flask):
api = MemberCancelInviteApi()
method = unwrap(api.delete)
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
member = MagicMock()
with (
app.test_request_context("/"),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.db.session.get") as get_mock,
patch(
"controllers.console.workspace.members.TenantService.remove_member_from_tenant",
side_effect=services.errors.account.NoPermissionError("x"),
),
):
get_mock.return_value = member
result, status = method(api, member.id)
assert status == 403
def test_cancel_member_not_in_tenant(self, app: Flask):
api = MemberCancelInviteApi()
method = unwrap(api.delete)
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
member = MagicMock()
with (
app.test_request_context("/"),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.db.session.get") as get_mock,
patch(
"controllers.console.workspace.members.TenantService.remove_member_from_tenant",
side_effect=services.errors.account.MemberNotInTenantError(),
),
):
get_mock.return_value = member
result, status = method(api, member.id)
assert status == 404
class TestMemberUpdateRoleApi:
def test_update_success(self, app: Flask):
api = MemberUpdateRoleApi()
method = unwrap(api.put)
tenant = MagicMock()
user = MagicMock(current_tenant=tenant)
member = MagicMock()
payload = {"role": "normal"}
with (
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.db.session.get", return_value=member),
patch("controllers.console.workspace.members.TenantService.update_member_role"),
):
result = method(api, "id")
if isinstance(result, tuple):
result = result[0]
assert result["result"] == "success"
def test_update_invalid_role(self, app: Flask):
api = MemberUpdateRoleApi()
method = unwrap(api.put)
@ -391,23 +259,6 @@ class TestMemberUpdateRoleApi:
assert status == 400
def test_update_member_not_found(self, app: Flask):
api = MemberUpdateRoleApi()
method = unwrap(api.put)
payload = {"role": "normal"}
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.members.current_account_with_tenant",
return_value=(MagicMock(current_tenant=MagicMock()), "t1"),
),
patch("controllers.console.workspace.members.db.session.get", return_value=None),
):
with pytest.raises(HTTPException):
method(api, "id")
class TestDatasetOperatorMemberListApi:
def test_get_success(self, app: Flask):
@ -637,27 +488,3 @@ class TestOwnerTransferApi:
):
with pytest.raises(InvalidTokenError):
method(api, "2")
def test_member_not_in_tenant(self, app: Flask):
api = OwnerTransfer()
method = unwrap(api.post)
tenant = MagicMock()
user = MagicMock(id="1", email="a@test.com", current_tenant=tenant)
member = MagicMock()
payload = {"token": "t"}
with (
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.TenantService.is_owner", return_value=True),
patch(
"controllers.console.workspace.members.AccountService.get_owner_transfer_data",
return_value={"email": "a@test.com"},
),
patch("controllers.console.workspace.members.db.session.get", return_value=member),
patch("controllers.console.workspace.members.TenantService.is_member", return_value=False),
):
with pytest.raises(MemberNotInTenantError):
method(api, "2")

View File

@ -147,6 +147,11 @@
"count": 1
}
},
"web/app/(commonLayout)/snippets/[snippetId]/page.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/(humanInputLayout)/form/[token]/form.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -243,6 +248,11 @@
"count": 1
}
},
"web/app/components/app-sidebar/nav-link/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -3162,6 +3172,16 @@
"count": 2
}
},
"web/app/components/snippets/hooks/use-nodes-sync-draft.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/snippets/hooks/use-snippet-run.ts": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
"no-restricted-imports": {
"count": 1
@ -3332,6 +3352,11 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/blocks.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/hooks.ts": {
"react/set-state-in-effect": {
"count": 1
@ -5215,6 +5240,11 @@
"count": 1
}
},
"web/service/__tests__/use-snippet-workflows.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control.ts": {
"@tanstack/query/exhaustive-deps": {
"count": 1
@ -5480,6 +5510,11 @@
"count": 3
}
},
"web/service/use-snippet-workflows.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/use-tools.ts": {
"no-restricted-imports": {
"count": 1

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

@ -1,5 +1,5 @@
# base image
FROM node:22-alpine AS base
FROM node:22.22.1-alpine AS base
LABEL maintainer="takatost@gmail.com"
# if you located in China, you can use aliyun mirror to speed up

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

@ -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

@ -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

@ -28,12 +28,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) => {
@ -112,18 +116,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>
@ -152,18 +158,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,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

@ -85,8 +85,10 @@ export type AppPublisherProps = {
const PUBLISH_SHORTCUT = ['Mod', 'Shift', '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

@ -211,6 +211,12 @@ describe('ConfigModalFormFields', () => {
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
textInputView.unmount()
const hiddenFieldDisabledProps = createBaseProps()
const hiddenFieldDisabledView = render(<ConfigModalFormFields {...hiddenFieldDisabledProps} showHiddenField={false} />)
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument()
hiddenFieldDisabledView.unmount()
const singleFileProps = createBaseProps()
singleFileProps.tempPayload = {
...singleFileProps.tempPayload,

View File

@ -49,6 +49,7 @@ type ConfigModalFormFieldsProps = {
onVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
options?: string[]
selectOptions: SelectOptionItem[]
showHiddenField?: boolean
tempPayload: InputVar
t: Translate
}
@ -67,6 +68,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
onVarNameChange,
options,
selectOptions,
showHiddenField = true,
tempPayload,
t,
}) => {
@ -242,7 +244,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
</label>
{!isFileInput && (
{showHiddenField && !isFileInput && (
<div className="mt-5! flex h-6 items-center gap-2">
<label className="flex items-center gap-2">
<Checkbox

View File

@ -33,6 +33,7 @@ type IConfigModalProps = {
onClose: () => void
onConfirm: (newValue: InputVar, moreInfo?: MoreInfo) => void
supportFile?: boolean
showHiddenField?: boolean
}
const ConfigModal: FC<IConfigModalProps> = ({
@ -41,6 +42,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
isShow,
onClose,
onConfirm,
showHiddenField,
supportFile,
}) => {
const { modelConfig } = useContext(ConfigContext)
@ -173,6 +175,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
onVarNameChange={handleVarNameChange}
options={options}
selectOptions={selectOptions}
showHiddenField={showHiddenField}
tempPayload={tempPayload}
t={t}
/>

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

@ -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

@ -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

@ -346,29 +346,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

@ -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

@ -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

@ -102,6 +102,13 @@ describe('DatasetNav', () => {
icon_info: { icon: 'pipeline' },
provider: 'vendor',
},
{
id: 'dataset-5',
name: 'Null Icon Dataset',
runtime_mode: 'general',
icon_info: null,
provider: 'vendor',
},
],
},
],
@ -141,6 +148,16 @@ describe('DatasetNav', () => {
render(<DatasetNav />)
expect(screen.getByText('common.menus.datasets')).toBeInTheDocument()
})
it('should render current dataset when icon info is null', () => {
vi.mocked(useDatasetDetail).mockReturnValue({
data: { ...mockDataset, icon_info: null },
} as unknown as ReturnType<typeof useDatasetDetail>)
render(<DatasetNav />)
expect(screen.getByRole('button', { name: /Test Dataset/i })).toBeInTheDocument()
})
})
describe('Navigation Items logic', () => {
@ -154,6 +171,7 @@ describe('DatasetNav', () => {
expect(within(menu).getByText('Test Dataset')).toBeInTheDocument()
expect(within(menu).getByText('Pipeline Dataset')).toBeInTheDocument()
expect(within(menu).getByText('External Dataset')).toBeInTheDocument()
expect(within(menu).getByText('Null Icon Dataset')).toBeInTheDocument()
})
it('should navigate to correct link when an item is clicked', () => {

View File

@ -1,11 +1,7 @@
'use client'
import type { NavItem } from '../nav/nav-selector'
import type { DataSet } from '@/models/datasets'
import {
RiBook2Fill,
RiBook2Line,
} from '@remixicon/react'
import type { DataSet, IconInfo } from '@/models/datasets'
import { flatten } from 'es-toolkit/compat'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -14,6 +10,24 @@ import { useDatasetDetail, useDatasetList } from '@/service/knowledge/use-datase
import { basePath } from '@/utils/var'
import Nav from '../nav'
const DEFAULT_DATASET_ICON: IconInfo = {
icon_type: 'emoji',
icon: '📙',
icon_background: '#FFF4ED',
icon_url: '',
}
type NullableDatasetIconInfo = Partial<{
[Key in keyof IconInfo]: IconInfo[Key] | null
}>
const normalizeDatasetIconInfo = (iconInfo?: NullableDatasetIconInfo | null): IconInfo => ({
icon_type: iconInfo?.icon_type ?? DEFAULT_DATASET_ICON.icon_type,
icon: iconInfo?.icon ?? DEFAULT_DATASET_ICON.icon,
icon_background: iconInfo?.icon_background ?? DEFAULT_DATASET_ICON.icon_background,
icon_url: iconInfo?.icon_url ?? DEFAULT_DATASET_ICON.icon_url,
})
const DatasetNav = () => {
const { t } = useTranslation()
const router = useRouter()
@ -33,15 +47,16 @@ const DatasetNav = () => {
const curNav = useMemo(() => {
if (!currentDataset)
return
const iconInfo = normalizeDatasetIconInfo(currentDataset.icon_info)
return {
id: currentDataset.id,
name: currentDataset.name,
icon: currentDataset.icon_info.icon,
icon_type: currentDataset.icon_info.icon_type,
icon_background: currentDataset.icon_info.icon_background,
icon_url: currentDataset.icon_info.icon_url,
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background ?? null,
icon_url: iconInfo.icon_url ?? null,
} as Omit<NavItem, 'link'>
}, [currentDataset?.id, currentDataset?.name, currentDataset?.icon_info])
}, [currentDataset])
const getDatasetLink = useCallback((dataset: DataSet) => {
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
@ -56,14 +71,15 @@ const DatasetNav = () => {
const navigationItems = useMemo(() => {
return datasetItems.map((dataset) => {
const link = getDatasetLink(dataset)
const iconInfo = normalizeDatasetIconInfo(dataset.icon_info)
return {
id: dataset.id,
name: dataset.name,
link,
icon: dataset.icon_info.icon,
icon_type: dataset.icon_info.icon_type,
icon_background: dataset.icon_info.icon_background,
icon_url: dataset.icon_info.icon_url,
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background ?? null,
icon_url: iconInfo.icon_url ?? null,
}
}) as NavItem[]
}, [datasetItems, getDatasetLink])
@ -84,8 +100,8 @@ const DatasetNav = () => {
return (
<Nav
isApp={false}
icon={<RiBook2Line className="size-4" />}
activeIcon={<RiBook2Fill className="size-4" />}
icon={<span className="i-ri-book-2-line size-4" />}
activeIcon={<span className="i-ri-book-2-fill size-4" />}
text={t('menus.datasets', { ns: 'common' })}
activeSegment="datasets"
link="/datasets"

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

@ -0,0 +1,278 @@
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(),
}),
useImportSnippetDSLMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
useConfirmSnippetImportMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
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,198 @@
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('Updater')).toBeInTheDocument()
expect(screen.getByText('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('Creator')).toBeInTheDocument()
expect(screen.getByText('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,111 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SnippetCreateButton from '../snippet-create-button'
const { mockPush, mockCreateMutate, mockImportMutateAsync, mockConfirmImportMutateAsync, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockCreateMutate: vi.fn(),
mockImportMutateAsync: vi.fn(),
mockConfirmImportMutateAsync: 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,
}),
useImportSnippetDSLMutation: () => ({
mutateAsync: mockImportMutateAsync,
isPending: false,
}),
useConfirmSnippetImportMutation: () => ({
mutateAsync: mockConfirmImportMutateAsync,
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' }))
fireEvent.click(screen.getByRole('button', { name: 'snippet.createFromBlank' }))
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),
}))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
})
it('should import a snippet from a DSL URL', async () => {
mockImportMutateAsync.mockResolvedValue({
id: 'import-1',
status: 'completed',
snippet_id: 'snippet-imported',
error: '',
})
render(<SnippetCreateButton />)
fireEvent.click(screen.getByRole('button', { name: 'snippet.create' }))
fireEvent.click(screen.getByRole('button', { name: 'snippet.importDSLFile' }))
expect(screen.getByText('snippet.importDialogTitle')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'snippet.importFromDSLUrl' }))
fireEvent.change(screen.getByPlaceholderText('snippet.importFromDSLUrlPlaceholder'), {
target: { value: 'https://example.com/snippet.yml' },
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
await waitFor(() => {
expect(mockImportMutateAsync).toHaveBeenCalledWith({
mode: 'yaml-url',
yamlContent: undefined,
yamlUrl: 'https://example.com/snippet.yml',
})
})
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.importSuccess')
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-imported/orchestrate')
})
})

View File

@ -0,0 +1,259 @@
'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 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-40 w-full cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg">
<Link href={`/snippets/${snippet.id}/orchestrate`} className="flex min-h-0 flex-1 flex-col">
<div className="flex h-16.5 shrink-0 grow-0 flex-col justify-center px-3.5 pt-3.5 pb-3">
<div className="flex items-center text-sm/5 font-semibold text-text-secondary">
<div className="truncate" title={snippet.name}>{snippet.name}</div>
</div>
<div className="flex items-center gap-1 text-2xs leading-4.5 font-medium text-text-tertiary">
<div className="truncate" title={updatedByName}>{updatedByName}</div>
<div>·</div>
<div className="truncate" title={updatedAtText}>{updatedAtText}</div>
</div>
</div>
<div className="h-22.5 px-3.5 text-xs leading-normal text-text-tertiary">
<div className="line-clamp-2" title={snippet.description}>
{snippet.description}
</div>
</div>
</Link>
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-1.5 pb-1.5 pl-3.5">
<div
className="flex w-0 grow items-center gap-1"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="mr-10.25 min-w-0 grow overflow-hidden">
<TagSelector
placement="bottom-start"
type="snippet"
targetId={snippet.id}
value={snippet.tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onTagsChange}
/>
</div>
</div>
{isCurrentWorkspaceEditor && (
<div
className={cn(
'absolute top-1/2 right-1.5 flex -translate-y-1/2 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',
)}
>
<div className="mx-1 h-3.5 w-px shrink-0 bg-divider-regular" />
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={tCommon('operation.more', { ns: 'common' })}
className="flex size-8 items-center justify-center rounded-md border-none bg-transparent p-2 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 data-popup-open:shadow-none"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex size-8 cursor-pointer items-center justify-center rounded-md">
<span className="sr-only">{tCommon('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</div>
</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>
)}
</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,111 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
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 ImportSnippetDSLDialog from '@/app/components/snippets/import-snippet-dsl-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 [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [isMenuOpen, setIsMenuOpen] = 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`)
},
})
}
return (
<>
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverTrigger
render={(
<Button disabled={createSnippetMutation.isPending}>
<span aria-hidden className="mr-0.5 i-ri-add-line size-4" />
<span>{t('create')}</span>
<span aria-hidden className="ml-0.5 i-ri-arrow-down-s-line size-4" />
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={6}
popupClassName="w-[228px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
>
<div className="px-2 pt-2 pb-1 text-xs leading-4.5 font-medium text-text-tertiary">
{t('createFrom')}
</div>
<button
type="button"
className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-2 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => {
setIsMenuOpen(false)
setIsCreateDialogOpen(true)
}}
>
<span aria-hidden className="mr-2 i-custom-vender-line-files-file-plus-01 size-4 shrink-0" />
<span>{t('createFromBlank')}</span>
</button>
<button
type="button"
className="flex w-full cursor-pointer items-center rounded-lg px-2 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={() => {
setIsMenuOpen(false)
setIsImportDialogOpen(true)
}}
>
<span aria-hidden className="mr-2 i-custom-vender-line-files-file-arrow-01 size-4 shrink-0" />
<span>{t('importDSLFile')}</span>
</button>
</PopoverContent>
</Popover>
{isCreateDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateDialogOpen}
isSubmitting={createSnippetMutation.isPending}
onClose={() => setIsCreateDialogOpen(false)}
onConfirm={handleCreateSnippet}
/>
)}
{isImportDialogOpen && (
<ImportSnippetDSLDialog
isOpen={isImportDialogOpen}
onClose={() => setIsImportDialogOpen(false)}
/>
)}
</>
)
}
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()
})
})

View File

@ -0,0 +1,60 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import SnippetLayout from '../snippet-layout'
const mockUseDocumentTitle = vi.fn()
vi.mock('@/hooks/use-document-title', () => ({
default: (title: string) => mockUseDocumentTitle(title),
}))
const createSnippet = (overrides: Partial<SnippetDetail> = {}): SnippetDetail => ({
id: 'snippet-1',
name: 'Snippet Title',
description: 'Snippet description',
updatedAt: '2026-04-15',
usage: '42',
tags: [],
is_published: true,
...overrides,
})
describe('SnippetLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
describe('Document title', () => {
it('should set the document title to the snippet name when snippet detail is available', () => {
render(
<SnippetLayout
snippetId="snippet-1"
snippet={createSnippet()}
section="orchestrate"
>
<div>content</div>
</SnippetLayout>,
)
expect(mockUseDocumentTitle).toHaveBeenCalledWith('Snippet Title')
})
})
describe('Layout', () => {
it('should render the detail content without the app detail sidebar navigation', () => {
render(
<SnippetLayout
snippetId="snippet-1"
snippet={createSnippet()}
section="orchestrate"
>
<div>content</div>
</SnippetLayout>,
)
expect(screen.getByText('content')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'snippet.sectionOrchestrate' })).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,406 @@
import type { ReactNode } from 'react'
import type { WorkflowProps } from '@/app/components/workflow'
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetMain from '../snippet-main'
const mockSyncInputFieldsDraft = vi.fn()
const mockReset = vi.fn()
const mockSetFields = vi.fn()
const mockPublishSnippetMutateAsync = vi.fn()
const mockUseSnippetPublishedWorkflow = vi.fn()
const mockFetchInspectVars = vi.fn()
const mockHandleBackupDraft = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
const mockHandleRun = vi.fn()
const mockHandleStartWorkflowRun = vi.fn()
const mockHandleStopRun = vi.fn()
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleCheckBeforePublish = vi.fn()
const mockUseAvailableNodesMetaData = vi.hoisted(() => vi.fn())
const mockInspectVarsCrud = {
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
renameInspectVarName: vi.fn(),
appendNodeInspectVars: vi.fn(),
deleteInspectVar: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
deleteAllInspectorVars: vi.fn(),
isInspectVarEdited: vi.fn(),
resetToLastRunVar: vi.fn(),
invalidateSysVarValues: vi.fn(),
resetConversationVar: vi.fn(),
invalidateConversationVarValues: vi.fn(),
}
let capturedHooksStore: Record<string, unknown> | undefined
let snippetDetailStoreState: {
fields: SnippetInputField[]
reset: typeof mockReset
setFields: typeof mockSetFields
}
vi.mock('@/app/components/snippets/store', () => ({
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
usePublishSnippetWorkflowMutation: () => ({
mutateAsync: mockPublishSnippetMutateAsync,
isPending: false,
}),
useSnippetPublishedWorkflow: () => mockUseSnippetPublishedWorkflow(),
}))
vi.mock('@/app/components/snippets/hooks/use-configs-map', () => ({
useConfigsMap: () => ({
flowId: 'snippet-1',
flowType: 'snippet',
fileSettings: {},
}),
}))
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: mockFetchInspectVars,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({
useChecklistBeforePublish: () => ({
handleCheckBeforePublish: mockHandleCheckBeforePublish,
}),
}))
vi.mock('@/app/components/workflow-app/hooks', () => ({
useAvailableNodesMetaData: () => mockUseAvailableNodesMetaData(),
}))
vi.mock('@/app/components/snippets/hooks/use-inspect-vars-crud', () => ({
useInspectVarsCrud: () => mockInspectVarsCrud,
}))
vi.mock('@/app/components/snippets/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: vi.fn(),
syncInputFieldsDraft: mockSyncInputFieldsDraft,
syncWorkflowDraftWhenPageClose: vi.fn(),
}),
}))
vi.mock('@/app/components/snippets/hooks/use-snippet-refresh-draft', () => ({
useSnippetRefreshDraft: () => ({
handleRefreshWorkflowDraft: vi.fn(),
}),
}))
vi.mock('@/app/components/snippets/hooks/use-snippet-run', () => ({
useSnippetRun: () => ({
handleBackupDraft: mockHandleBackupDraft,
handleLoadBackupDraft: mockHandleLoadBackupDraft,
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
handleRun: mockHandleRun,
handleStopRun: mockHandleStopRun,
}),
}))
vi.mock('@/app/components/snippets/hooks/use-snippet-start-run', () => ({
useSnippetStartRun: () => ({
handleStartWorkflowRun: mockHandleStartWorkflowRun,
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
}),
}))
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({
children,
hooksStore,
}: {
children: ReactNode
hooksStore?: Record<string, unknown>
}) => {
capturedHooksStore = hooksStore
return (
<div data-testid="workflow-inner-context">{children}</div>
)
},
}))
vi.mock('@/app/components/snippets/components/snippet-children', () => ({
default: ({
onCancel,
onEdit,
onPublish,
isEditing,
}: {
isEditing: boolean
onCancel: () => void
onEdit: () => void
onPublish: () => void
}) => (
<div>
{!isEditing && <button type="button" onClick={onEdit}>edit</button>}
<button type="button" onClick={onPublish}>publish</button>
<button type="button" onClick={onCancel}>cancel</button>
</div>
),
}))
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
default: ({
fields,
onFieldsChange,
}: {
fields: SnippetInputField[]
onFieldsChange: (fields: SnippetInputField[]) => void
}) => (
<div>
<button type="button" onClick={() => onFieldsChange([])}>remove</button>
<button
type="button"
onClick={() => onFieldsChange([
...fields,
{
type: PipelineInputVarType.textInput,
label: 'New Field',
variable: 'new_field',
required: true,
},
])}
>
submit
</button>
</div>
),
}))
const payload: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Snippet',
description: 'desc',
updatedAt: '2026-03-29 10:00',
usage: '0',
tags: [],
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
inputFields: [
{
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
},
],
uiMeta: {
inputFieldCount: 1,
checklistCount: 0,
autoSavedAt: '2026-03-29 10:00',
},
}
const renderSnippetMain = () => {
return renderWorkflowComponent(
<SnippetMain
payload={payload}
draftPayload={payload}
hasInitialDraftChanges={false}
snippetId="snippet-1"
nodes={[] as WorkflowProps['nodes']}
edges={[] as WorkflowProps['edges']}
viewport={{ x: 0, y: 0, zoom: 1 }}
draftNodes={[] as WorkflowProps['nodes']}
draftEdges={[] as WorkflowProps['edges']}
draftViewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
}
const createNodeMetadata = (type: BlockEnum) => ({
metaData: {
type,
},
defaultValue: {},
checkValid: vi.fn(),
})
describe('SnippetMain', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
mockPublishSnippetMutateAsync.mockResolvedValue({ created_at: 1_744_000_000 })
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: payload.graph,
input_fields: payload.inputFields,
},
refetch: vi.fn(),
})
const llmNodeMetadata = createNodeMetadata(BlockEnum.LLM)
const humanInputNodeMetadata = createNodeMetadata(BlockEnum.HumanInput)
const endNodeMetadata = createNodeMetadata(BlockEnum.End)
const knowledgeRetrievalNodeMetadata = createNodeMetadata(BlockEnum.KnowledgeRetrieval)
mockUseAvailableNodesMetaData.mockReturnValue({
nodes: [
llmNodeMetadata,
humanInputNodeMetadata,
endNodeMetadata,
knowledgeRetrievalNodeMetadata,
],
nodesMap: {
[BlockEnum.LLM]: llmNodeMetadata,
[BlockEnum.HumanInput]: humanInputNodeMetadata,
[BlockEnum.End]: endNodeMetadata,
[BlockEnum.KnowledgeRetrieval]: knowledgeRetrievalNodeMetadata,
},
})
mockHandleCheckBeforePublish.mockResolvedValue(true)
capturedHooksStore = undefined
snippetDetailStoreState = {
fields: [...payload.inputFields],
reset: mockReset,
setFields: mockSetFields,
}
})
describe('Input Fields Sync', () => {
it('should sync draft input_fields when removing a field from the panel', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
onRefresh: expect.any(Function),
})
})
})
it('should sync draft input_fields when adding a field from the sidebar', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'submit' }))
await waitFor(() => {
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
payload.inputFields[0],
{
type: PipelineInputVarType.textInput,
label: 'New Field',
variable: 'new_field',
required: true,
},
], {
onRefresh: expect.any(Function),
})
})
})
})
describe('Publish', () => {
it('should call the publish mutation', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'publish' }))
await waitFor(() => {
expect(mockPublishSnippetMutateAsync).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
})
})
})
})
describe('Cancel', () => {
it('should restore from the published workflow and reset published input fields', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))
await waitFor(() => {
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalledWith({
graph: payload.graph,
input_fields: payload.inputFields,
})
expect(mockSetFields).toHaveBeenCalledWith(payload.inputFields)
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith(payload.inputFields, {
onRefresh: expect.any(Function),
})
})
})
})
describe('Inspect Vars', () => {
it('should pass inspect vars handlers to WorkflowWithInnerContext', () => {
renderSnippetMain()
expect(capturedHooksStore?.fetchInspectVars).toBe(mockFetchInspectVars)
expect(capturedHooksStore?.hasNodeInspectVars).toBe(mockInspectVarsCrud.hasNodeInspectVars)
expect(capturedHooksStore?.hasSetInspectVar).toBe(mockInspectVarsCrud.hasSetInspectVar)
expect(capturedHooksStore?.fetchInspectVarValue).toBe(mockInspectVarsCrud.fetchInspectVarValue)
expect(capturedHooksStore?.editInspectVarValue).toBe(mockInspectVarsCrud.editInspectVarValue)
expect(capturedHooksStore?.renameInspectVarName).toBe(mockInspectVarsCrud.renameInspectVarName)
expect(capturedHooksStore?.appendNodeInspectVars).toBe(mockInspectVarsCrud.appendNodeInspectVars)
expect(capturedHooksStore?.deleteInspectVar).toBe(mockInspectVarsCrud.deleteInspectVar)
expect(capturedHooksStore?.deleteNodeInspectorVars).toBe(mockInspectVarsCrud.deleteNodeInspectorVars)
expect(capturedHooksStore?.deleteAllInspectorVars).toBe(mockInspectVarsCrud.deleteAllInspectorVars)
expect(capturedHooksStore?.isInspectVarEdited).toBe(mockInspectVarsCrud.isInspectVarEdited)
expect(capturedHooksStore?.resetToLastRunVar).toBe(mockInspectVarsCrud.resetToLastRunVar)
expect(capturedHooksStore?.invalidateSysVarValues).toBe(mockInspectVarsCrud.invalidateSysVarValues)
expect(capturedHooksStore?.resetConversationVar).toBe(mockInspectVarsCrud.resetConversationVar)
expect(capturedHooksStore?.invalidateConversationVarValues).toBe(mockInspectVarsCrud.invalidateConversationVarValues)
})
})
describe('Block Selector', () => {
it('should filter unsupported snippet block types from available node metadata', () => {
renderSnippetMain()
const availableNodesMetaData = capturedHooksStore?.availableNodesMetaData as {
nodes: Array<{ metaData: { type: BlockEnum } }>
nodesMap: Partial<Record<BlockEnum, unknown>>
}
expect(availableNodesMetaData.nodes.map(node => node.metaData.type)).toEqual([BlockEnum.LLM])
expect(availableNodesMetaData.nodesMap[BlockEnum.LLM]).toBeDefined()
expect(availableNodesMetaData.nodesMap[BlockEnum.HumanInput]).toBeUndefined()
expect(availableNodesMetaData.nodesMap[BlockEnum.End]).toBeUndefined()
expect(availableNodesMetaData.nodesMap[BlockEnum.KnowledgeRetrieval]).toBeUndefined()
})
})
describe('Run Hooks', () => {
it('should pass snippet run handlers to WorkflowWithInnerContext', () => {
renderSnippetMain()
expect(capturedHooksStore?.handleBackupDraft).toBe(mockHandleBackupDraft)
expect(capturedHooksStore?.handleLoadBackupDraft).toBe(mockHandleLoadBackupDraft)
expect(capturedHooksStore?.handleRestoreFromPublishedWorkflow).toBe(mockHandleRestoreFromPublishedWorkflow)
expect(capturedHooksStore?.handleRun).toBe(mockHandleRun)
expect(capturedHooksStore?.handleStopRun).toBe(mockHandleStopRun)
expect(capturedHooksStore?.handleStartWorkflowRun).toBe(mockHandleStartWorkflowRun)
expect(capturedHooksStore?.handleWorkflowStartRunInWorkflow).toBe(mockHandleWorkflowStartRunInWorkflow)
})
it('should pass snippet workflow run detail urls to WorkflowWithInnerContext', () => {
renderSnippetMain()
const getWorkflowRunAndTraceUrl = capturedHooksStore?.getWorkflowRunAndTraceUrl as ((runId?: string) => { runUrl: string, traceUrl: string }) | undefined
expect(getWorkflowRunAndTraceUrl?.('run-1')).toEqual({
runUrl: '/snippets/snippet-1/workflow-runs/run-1',
traceUrl: '/snippets/snippet-1/workflow-runs/run-1/node-executions',
})
})
})
})

View File

@ -0,0 +1,43 @@
import type { PanelProps } from '@/app/components/workflow/panel'
import type { SnippetInputField } from '@/models/snippet'
import { render, waitFor } from '@testing-library/react'
import SnippetWorkflowPanel from '../workflow-panel'
let capturedPanelProps: PanelProps | null = null
vi.mock('@/app/components/workflow/panel', () => ({
default: (props: PanelProps) => {
capturedPanelProps = props
return <div data-testid="workflow-panel" />
},
}))
const defaultFields: SnippetInputField[] = []
describe('SnippetWorkflowPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedPanelProps = null
})
// Verifies snippet panel wires version history support into the shared workflow panel.
describe('Rendering', () => {
it('should pass snippet version history panel props to the shared workflow panel', async () => {
render(
<SnippetWorkflowPanel
snippetId="snippet-1"
fields={defaultFields}
/>,
)
await waitFor(() => {
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe('/snippets/snippet-1/workflows')
expect(capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1')
expect(capturedPanelProps?.versionHistoryPanelProps?.restoreVersionUrl('version-1')).toBe('/snippets/snippet-1/workflows/version-1/restore')
expect(capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1')
expect(capturedPanelProps?.versionHistoryPanelProps?.latestVersionId).toBe('')
expect(capturedPanelProps?.components?.right).toBeTruthy()
})
})
})
})

View File

@ -0,0 +1,70 @@
import type { SnippetInputField } from '@/models/snippet'
import { act, renderHook } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetInputFieldActions } from '../use-snippet-input-field-actions'
const mockSyncInputFieldsDraft = vi.fn()
const mockSetFields = vi.fn()
let snippetDetailStoreState: {
fields: SnippetInputField[]
setFields: typeof mockSetFields
}
vi.mock('../../../hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
syncInputFieldsDraft: mockSyncInputFieldsDraft,
}),
}))
vi.mock('../../../store', () => ({
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
}))
const createField = (overrides: Partial<SnippetInputField> = {}): SnippetInputField => ({
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
...overrides,
})
describe('useSnippetInputFieldActions', () => {
beforeEach(() => {
vi.clearAllMocks()
snippetDetailStoreState = {
fields: [],
setFields: mockSetFields,
}
mockSetFields.mockImplementation((fields: SnippetInputField[]) => {
snippetDetailStoreState.fields = fields
})
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
})
describe('Field sync', () => {
it('should update fields and sync the draft', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
}))
const nextFields = [
createField(),
createField({
label: 'Topic',
variable: 'topic',
}),
]
act(() => {
result.current.handleFieldsChange(nextFields)
})
expect(result.current.fields).toEqual([createField()])
expect(mockSetFields).toHaveBeenCalledWith(nextFields)
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith(nextFields, {
onRefresh: expect.any(Function),
})
})
})
})

View File

@ -0,0 +1,119 @@
import { toast } from '@langgenius/dify-ui/toast'
import { act, renderHook } from '@testing-library/react'
import { useSnippetPublish } from '../use-snippet-publish'
const mockMutateAsync = vi.fn()
const mockSetPublishedAt = vi.fn()
const mockSetQueryData = vi.fn()
const mockHandleCheckBeforePublish = vi.fn<() => Promise<boolean>>()
let isPending = false
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
setQueryData: mockSetQueryData,
}),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
usePublishSnippetWorkflowMutation: () => ({
mutateAsync: mockMutateAsync,
isPending,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setPublishedAt: mockSetPublishedAt,
}),
}),
}))
vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({
useChecklistBeforePublish: () => ({
handleCheckBeforePublish: mockHandleCheckBeforePublish,
}),
}))
describe('useSnippetPublish', () => {
beforeEach(() => {
vi.clearAllMocks()
isPending = false
mockHandleCheckBeforePublish.mockResolvedValue(true)
mockMutateAsync.mockResolvedValue({ created_at: 1_712_345_678 })
})
describe('Publish action', () => {
it('should publish the snippet and show success feedback', async () => {
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
await act(async () => {
await result.current.handlePublish()
})
expect(mockHandleCheckBeforePublish).toHaveBeenCalledTimes(1)
expect(mockMutateAsync).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
})
expect(mockSetQueryData).toHaveBeenCalledTimes(1)
const setQueryDataCall = mockSetQueryData.mock.calls[0]
expect(setQueryDataCall).toBeDefined()
const updateSnippetDetail = setQueryDataCall![1] as (old: { is_published: boolean }) => { is_published: boolean }
expect(updateSnippetDetail({ is_published: false })).toEqual({ is_published: true })
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
expect(toast.success).toHaveBeenCalledWith('snippet.saveSuccess')
})
it('should not publish the snippet when checklist validation fails', async () => {
mockHandleCheckBeforePublish.mockResolvedValue(false)
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
await act(async () => {
await result.current.handlePublish()
})
expect(mockHandleCheckBeforePublish).toHaveBeenCalledTimes(1)
expect(mockMutateAsync).not.toHaveBeenCalled()
expect(mockSetQueryData).not.toHaveBeenCalled()
expect(mockSetPublishedAt).not.toHaveBeenCalled()
expect(toast.success).not.toHaveBeenCalled()
})
it('should surface publish errors through toast feedback', async () => {
mockMutateAsync.mockRejectedValue(new Error('publish failed'))
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
await act(async () => {
await result.current.handlePublish()
})
expect(toast.error).toHaveBeenCalledWith('publish failed')
})
})
it('should expose publishing pending state', () => {
isPending = true
const { result } = renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
expect(result.current.isPublishing).toBe(true)
})
})

View File

@ -0,0 +1,34 @@
import type { SnippetInputField } from '@/models/snippet'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
import { useSnippetDetailStore } from '../../store'
type UseSnippetInputFieldActionsOptions = {
snippetId: string
}
export const useSnippetInputFieldActions = ({
snippetId,
}: UseSnippetInputFieldActionsOptions) => {
const { syncInputFieldsDraft } = useNodesSyncDraft(snippetId)
const {
fields,
setFields,
} = useSnippetDetailStore(useShallow(state => ({
fields: state.fields,
setFields: state.setFields,
})))
const handleFieldsChange = useCallback((newFields: SnippetInputField[]) => {
setFields(newFields)
void syncInputFieldsDraft(newFields, {
onRefresh: setFields,
})
}, [setFields, syncInputFieldsDraft])
return {
fields,
handleFieldsChange,
}
}

View File

@ -0,0 +1,55 @@
import type { Snippet as SnippetContract } from '@/types/snippet'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useChecklistBeforePublish } from '@/app/components/workflow/hooks/use-checklist'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { consoleQuery } from '@/service/client'
import { usePublishSnippetWorkflowMutation } from '@/service/use-snippet-workflows'
type UseSnippetPublishOptions = {
snippetId: string
}
export const useSnippetPublish = ({
snippetId,
}: UseSnippetPublishOptions) => {
const { t } = useTranslation('snippet')
const workflowStore = useWorkflowStore()
const queryClient = useQueryClient()
const publishSnippetMutation = usePublishSnippetWorkflowMutation(snippetId)
const { handleCheckBeforePublish } = useChecklistBeforePublish()
const handlePublish = useCallback(async () => {
try {
const canPublish = await handleCheckBeforePublish()
if (!canPublish)
return
const publishedWorkflow = await publishSnippetMutation.mutateAsync({
params: { snippetId },
})
queryClient.setQueryData<SnippetContract | undefined>(
consoleQuery.snippets.detail.queryKey({
input: {
params: { snippetId },
},
}),
old => old ? { ...old, is_published: true } : old,
)
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
toast.success(t('saveSuccess'))
return true
}
catch (error) {
toast.error(error instanceof Error ? error.message : t('publishFailed'))
return false
}
}, [handleCheckBeforePublish, publishSnippetMutation, queryClient, snippetId, t, workflowStore])
return {
handlePublish,
isPublishing: publishSnippetMutation.isPending,
}
}

View File

@ -0,0 +1,59 @@
'use client'
import type { SnippetInputField } from '@/models/snippet'
import SnippetHeader from './snippet-header'
import SnippetWorkflowPanel from './workflow-panel'
type SnippetChildrenProps = {
snippetId: string
fields: SnippetInputField[]
hasDraftChanges: boolean
isEditing: boolean
isPublishing: boolean
onCancel: () => void
onDiscardAndExitEditing: () => void | Promise<void>
onEdit: () => void
onExitEditing: () => void | Promise<void>
onPublish: () => void
onSaveAndExitEditing: () => void | Promise<void>
}
const SnippetChildren = ({
snippetId,
fields,
hasDraftChanges,
isEditing,
isPublishing,
onCancel,
onDiscardAndExitEditing,
onEdit,
onExitEditing,
onPublish,
onSaveAndExitEditing,
}: SnippetChildrenProps) => {
return (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-linear-to-b from-background-body to-transparent" />
<SnippetHeader
snippetId={snippetId}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onCancel={onCancel}
onDiscardAndExitEditing={onDiscardAndExitEditing}
onEdit={onEdit}
onExitEditing={onExitEditing}
onPublish={onPublish}
onSaveAndExitEditing={onSaveAndExitEditing}
/>
<SnippetWorkflowPanel
snippetId={snippetId}
fields={fields}
/>
</>
)
}
export default SnippetChildren

View File

@ -0,0 +1,102 @@
import type { ReactNode } from 'react'
import type { HeaderProps } from '@/app/components/workflow/header'
import { fireEvent, render, screen } from '@testing-library/react'
import SnippetHeader from '..'
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTrigger: ({ children, render }: { children?: ReactNode, render?: ReactNode }) => render ?? <button type="button">{children}</button>,
}))
vi.mock('@/app/components/workflow/header', () => ({
default: (props: HeaderProps) => {
return (
<div
data-testid="workflow-header"
data-show-env={String(props.normal?.controls?.showEnvButton ?? true)}
data-show-global-variable={String(props.normal?.controls?.showGlobalVariableButton ?? true)}
data-history-url={props.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl ?? ''}
>
{props.normal?.components?.title}
{props.normal?.components?.left}
<button type="button">
{props.normal?.runAndHistoryProps?.runButtonText ?? 'snippet.testRunButton'}
</button>
{props.normal?.components?.middle}
</div>
)
},
}))
describe('SnippetHeader', () => {
const mockCancel = vi.fn()
const mockDiscardAndExit = vi.fn()
const mockEdit = vi.fn()
const mockExitEditing = vi.fn()
const mockPublish = vi.fn()
const mockSaveAndExit = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// Verifies the wrapper passes the expected workflow header configuration.
describe('Rendering', () => {
it('should configure workflow header slots and hide workflow-only controls', () => {
render(
<SnippetHeader
snippetId="snippet-1"
hasDraftChanges={false}
isEditing={false}
isPublishing={false}
onCancel={mockCancel}
onDiscardAndExitEditing={mockDiscardAndExit}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
const header = screen.getByTestId('workflow-header')
expect(header).toHaveAttribute('data-show-env', 'false')
expect(header).toHaveAttribute('data-show-global-variable', 'false')
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
expect(screen.getByText('snippet.viewOnly')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /snippet\.edit/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /snippet\.testRunButton/i })).toBeInTheDocument()
})
})
// Verifies forwarded callbacks still drive the snippet-specific controls.
describe('User Interactions', () => {
it('should invoke the snippet callbacks when save and discard are clicked in editing mode', () => {
render(
<SnippetHeader
snippetId="snippet-1"
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onDiscardAndExitEditing={mockDiscardAndExit}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /^snippet\.save$/i }))
fireEvent.click(screen.getByRole('button', { name: /snippet\.discardChanges/i }))
expect(mockPublish).toHaveBeenCalledTimes(1)
expect(mockCancel).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,75 @@
'use client'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogTrigger,
} from '@langgenius/dify-ui/alert-dialog'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
type CancelChangesProps = {
onCancel: () => void | Promise<void>
}
const CancelChanges = ({
onCancel,
}: CancelChangesProps) => {
const { t } = useTranslation('snippet')
const [open, setOpen] = useState(false)
const [isDiscarding, setIsDiscarding] = useState(false)
const handleDiscardChanges = useCallback(async () => {
setIsDiscarding(true)
try {
await onCancel()
setOpen(false)
}
finally {
setIsDiscarding(false)
}
}, [onCancel])
return (
<div className="flex items-center gap-2 system-sm-regular">
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
className="system-sm-semibold text-text-accent hover:text-text-accent-secondary"
>
{t('discardDraft')}
</AlertDialogTrigger>
<AlertDialogContent className="w-160">
<div className="space-y-2 p-8 pb-12">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('discardChangesTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-secondary">
{t('discardChangesDescription')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="px-8 pt-0">
<AlertDialogCancelButton disabled={isDiscarding}>
{t('continueEditing')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isDiscarding}
disabled={isDiscarding}
onClick={handleDiscardChanges}
>
{t('discardChanges')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<span className="text-text-quaternary">·</span>
<span className="text-text-tertiary">{t('editingDraft')}</span>
</div>
)
}
export default memo(CancelChanges)

View File

@ -0,0 +1,200 @@
'use client'
import type { HeaderProps } from '@/app/components/workflow/header'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogTrigger,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import {
memo,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Header from '@/app/components/workflow/header'
import CancelChanges from './cancel-changes'
import RunMode from './run-mode'
type SnippetHeaderProps = {
snippetId: string
hasDraftChanges: boolean
isEditing: boolean
isPublishing: boolean
onCancel: () => void
onDiscardAndExitEditing: () => void | Promise<void>
onEdit: () => void
onExitEditing: () => void | Promise<void>
onPublish: () => void
onSaveAndExitEditing: () => void | Promise<void>
}
const ViewOnlyBadge = () => {
const { t } = useTranslation('snippet')
return (
<div className="rounded-md border border-components-badge-status-light-normal-border-inner bg-components-badge-bg-blue-light-soft px-1.5 py-0.5 system-xs-semibold-uppercase text-text-accent">
{t('viewOnly')}
</div>
)
}
const EditActions = ({
hasDraftChanges,
isEditing,
isPublishing,
onEdit,
onExitEditing,
onDiscardAndExitEditing,
onPublish,
onSaveAndExitEditing,
}: Pick<SnippetHeaderProps, 'hasDraftChanges' | 'isEditing' | 'isPublishing' | 'onDiscardAndExitEditing' | 'onEdit' | 'onExitEditing' | 'onPublish' | 'onSaveAndExitEditing'>) => {
const { t } = useTranslation('snippet')
const [exitConfirmOpen, setExitConfirmOpen] = useState(false)
if (!isEditing) {
return (
<Button variant="primary" onClick={onEdit}>
{t('edit')}
</Button>
)
}
return (
<>
<AlertDialog open={exitConfirmOpen} onOpenChange={setExitConfirmOpen}>
<AlertDialogTrigger
render={(
<Button
disabled={isPublishing}
onClick={(event) => {
if (!hasDraftChanges) {
event.preventDefault()
void onExitEditing()
return
}
setExitConfirmOpen(true)
}}
>
{t('exitEditing')}
</Button>
)}
/>
<AlertDialogContent className="w-165">
<div className="space-y-2 p-8 pb-12">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('saveBeforeLeavingTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-secondary">
{t('saveBeforeLeavingDescription')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="px-8 pt-0">
<AlertDialogCancelButton disabled={isPublishing}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone="destructive"
disabled={isPublishing}
onClick={async () => {
await onDiscardAndExitEditing()
setExitConfirmOpen(false)
}}
>
{t('doNotSave')}
</AlertDialogConfirmButton>
<AlertDialogConfirmButton
tone="default"
loading={isPublishing}
disabled={isPublishing}
onClick={async () => {
await onSaveAndExitEditing()
setExitConfirmOpen(false)
}}
>
{t('saveAndExit')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<Button
variant="primary"
loading={isPublishing}
disabled={isPublishing}
onClick={onPublish}
>
{t('save')}
</Button>
</>
)
}
const SnippetHeader = ({
snippetId,
hasDraftChanges,
isEditing,
isPublishing,
onCancel,
onDiscardAndExitEditing,
onEdit,
onExitEditing,
onPublish,
onSaveAndExitEditing,
}: SnippetHeaderProps) => {
const { t } = useTranslation('snippet')
const viewHistoryProps = useMemo(() => {
return {
historyUrl: `/snippets/${snippetId}/workflow-runs`,
}
}, [snippetId])
const headerProps: HeaderProps = useMemo(() => {
return {
normal: {
components: {
title: isEditing
? (hasDraftChanges ? <CancelChanges onCancel={onCancel} /> : <></>)
: <ViewOnlyBadge />,
left: (
<EditActions
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onDiscardAndExitEditing={onDiscardAndExitEditing}
onEdit={onEdit}
onExitEditing={onExitEditing}
onPublish={onPublish}
onSaveAndExitEditing={onSaveAndExitEditing}
/>
),
},
controls: {
showEnvButton: false,
showGlobalVariableButton: false,
},
runAndHistoryProps: {
showRunButton: true,
runButtonText: t('testRunButton'),
viewHistoryProps,
components: {
RunMode,
},
},
},
viewHistory: {
viewHistoryProps,
},
}
}, [hasDraftChanges, isEditing, isPublishing, onCancel, onDiscardAndExitEditing, onEdit, onExitEditing, onPublish, onSaveAndExitEditing, t, viewHistoryProps])
return <Header {...headerProps} />
}
export default memo(SnippetHeader)

View File

@ -0,0 +1,30 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
type PublisherProps = {
isPublishing: boolean
onPublish: () => void
}
const Publisher = ({
isPublishing,
onPublish,
}: PublisherProps) => {
const { t } = useTranslation('snippet')
return (
<Button
variant="primary"
loading={isPublishing}
disabled={isPublishing}
onClick={onPublish}
>
{t('save')}
</Button>
)
}
export default memo(Publisher)

View File

@ -0,0 +1,78 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
import { useStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
type RunModeProps = {
text?: string
}
const RunMode = ({
text,
}: RunModeProps) => {
const { t } = useTranslation('snippet')
const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun()
const { handleStopRun } = useWorkflowRun()
const workflowRunningData = useStore(s => s.workflowRunningData)
const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running
const handleStop = useCallback(() => {
handleStopRun(workflowRunningData?.task_id || '')
}, [handleStopRun, workflowRunningData?.task_id])
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v) => {
if (typeof v !== 'string' && v.type === EVENT_WORKFLOW_STOP)
handleStop()
})
return (
<div className="flex items-center gap-x-px">
<button
type="button"
className={cn(
'flex h-7 items-center gap-x-1 rounded-md px-1.5 system-xs-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover',
isRunning && 'cursor-not-allowed rounded-l-md bg-state-accent-hover',
)}
onClick={handleWorkflowStartRunInWorkflow}
disabled={isRunning}
>
{isRunning
? (
<>
<span aria-hidden className="mr-1 i-ri-loader-2-line size-4 animate-spin" />
{t('common.running', { ns: 'workflow' })}
</>
)
: (
<>
<span aria-hidden className="mr-1 i-ri-play-large-line size-4" />
{text ?? t('common.run', { ns: 'workflow' })}
<ShortcutKbd shortcut="workflow.open-test-run-menu" textColor="secondary" />
</>
)}
</button>
{isRunning && (
<button
type="button"
className="flex size-7 items-center justify-center rounded-r-md bg-state-accent-active"
onClick={handleStop}
>
<span aria-hidden className="i-ri-stop-circle-line size-4 text-text-accent" />
</button>
)}
</div>
)
}
export default React.memo(RunMode)

View File

@ -0,0 +1,34 @@
'use client'
import type { ReactNode } from 'react'
import type { SnippetDetail, SnippetSection } from '@/models/snippet'
import { useTranslation } from 'react-i18next'
import useDocumentTitle from '@/hooks/use-document-title'
type SnippetLayoutProps = {
children: ReactNode
section: SnippetSection
snippet: SnippetDetail
snippetId: string
}
const SnippetLayout = ({
children,
snippet,
}: SnippetLayoutProps) => {
const { t } = useTranslation('snippet')
useDocumentTitle(snippet.name || t('typeLabel'))
return (
<div className="relative flex h-full overflow-hidden bg-background-body">
<div className="relative min-h-0 min-w-0 grow overflow-hidden">
<div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden">
{children}
</div>
</div>
</div>
)
}
export default SnippetLayout

View File

@ -0,0 +1,403 @@
'use client'
import type { WorkflowProps } from '@/app/components/workflow'
import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store'
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { useShallow } from 'zustand/react/shallow'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
import { useAvailableNodesMetaData } from '@/app/components/workflow-app/hooks'
import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useConfigsMap } from '../hooks/use-configs-map'
import { useGetRunAndTraceUrl } from '../hooks/use-get-run-and-trace-url'
import { useInspectVarsCrud } from '../hooks/use-inspect-vars-crud'
import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft'
import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft'
import { useSnippetRun } from '../hooks/use-snippet-run'
import { useSnippetStartRun } from '../hooks/use-snippet-start-run'
import { useSnippetDetailStore } from '../store'
import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-actions'
import { useSnippetPublish } from './hooks/use-snippet-publish'
import SnippetChildren from './snippet-children'
import SnippetSidebar from './snippet-sidebar'
type SnippetMainProps = {
payload: SnippetDetailPayload
draftPayload: SnippetDetailPayload
hasInitialDraftChanges: boolean
publishedWorkflowHash?: string
draftWorkflowHash?: string
snippetId: string
draftNodes: WorkflowProps['nodes']
draftEdges: WorkflowProps['edges']
draftViewport?: WorkflowProps['viewport']
} & Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
type SnippetMainContentProps = {
snippetId: string
fields: SnippetInputField[]
hasDraftChanges: boolean
isEditing: boolean
onCancel: () => void | Promise<void>
onDiscardAndExitEditing: () => void | Promise<void>
onEdit: () => void
onExitEditing: () => void | Promise<void>
onSaved: () => void
onSavedAndExitEditing: () => void
}
const unsupportedSnippetBlockTypes = new Set([
BlockEnum.HumanInput,
BlockEnum.End,
BlockEnum.KnowledgeRetrieval,
])
const SnippetMainContent = ({
snippetId,
fields,
hasDraftChanges,
isEditing,
onCancel,
onDiscardAndExitEditing,
onEdit,
onExitEditing,
onSaved,
onSavedAndExitEditing,
}: SnippetMainContentProps) => {
const {
handlePublish,
isPublishing,
} = useSnippetPublish({
snippetId,
})
const handlePublishSnippet = useCallback(async () => {
const didSave = await handlePublish()
if (didSave)
onSaved()
return didSave
}, [handlePublish, onSaved])
const handleSaveAndExitEditing = useCallback(async () => {
const didSave = await handlePublishSnippet()
if (didSave)
onSavedAndExitEditing()
}, [handlePublishSnippet, onSavedAndExitEditing])
return (
<SnippetChildren
snippetId={snippetId}
fields={fields}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onCancel={onCancel}
onDiscardAndExitEditing={onDiscardAndExitEditing}
onEdit={onEdit}
onExitEditing={onExitEditing}
onPublish={handlePublishSnippet}
onSaveAndExitEditing={handleSaveAndExitEditing}
/>
)
}
const SnippetMain = ({
payload,
draftPayload,
hasInitialDraftChanges,
publishedWorkflowHash,
draftWorkflowHash,
snippetId,
nodes,
edges,
viewport,
draftNodes,
draftEdges,
draftViewport,
}: SnippetMainProps) => {
const [isEditing, setIsEditing] = useState(false)
const [draftChangeState, setDraftChangeState] = useState({
initial: hasInitialDraftChanges,
snippetId,
value: hasInitialDraftChanges,
})
if (draftChangeState.snippetId !== snippetId || draftChangeState.initial !== hasInitialDraftChanges) {
setDraftChangeState({
initial: hasInitialDraftChanges,
snippetId,
value: hasInitialDraftChanges,
})
}
const hasDraftChanges = draftChangeState.value
const setHasDraftChanges = useCallback((value: boolean) => {
setDraftChangeState(prev => ({
...prev,
value,
}))
}, [])
const displayPayload = isEditing ? draftPayload : payload
const displayNodes = isEditing ? draftNodes : nodes
const displayEdges = isEditing ? draftEdges : edges
const displayViewport = isEditing ? draftViewport : viewport
const displayWorkflowHash = isEditing ? draftWorkflowHash : publishedWorkflowHash
const { graph, snippet } = displayPayload
const {
doSyncWorkflowDraft: syncWorkflowDraft,
syncInputFieldsDraft,
syncWorkflowDraftWhenPageClose,
} = useNodesSyncDraft(snippetId)
const workflowStore = useWorkflowStore()
const publishedWorkflowQuery = useSnippetPublishedWorkflow(snippetId)
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
const {
handleBackupDraft,
handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow,
handleRun,
handleStopRun,
} = useSnippetRun(snippetId)
const configsMap = useConfigsMap(snippetId)
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
...configsMap,
})
const {
hasNodeInspectVars,
hasSetInspectVar,
fetchInspectVarValue,
editInspectVarValue,
renameInspectVarName,
appendNodeInspectVars,
deleteInspectVar,
deleteNodeInspectorVars,
deleteAllInspectorVars,
isInspectVarEdited,
resetToLastRunVar,
invalidateSysVarValues,
resetConversationVar,
invalidateConversationVarValues,
} = useInspectVarsCrud(snippetId)
const workflowAvailableNodesMetaData = useAvailableNodesMetaData()
const {
data: publishedWorkflow,
refetch: refetchPublishedWorkflow,
} = publishedWorkflowQuery
const availableNodesMetaData = useMemo(() => {
const nodes = workflowAvailableNodesMetaData.nodes.filter(node =>
!unsupportedSnippetBlockTypes.has(node.metaData.type))
if (!workflowAvailableNodesMetaData.nodesMap)
return { nodes }
const {
[BlockEnum.HumanInput]: _humanInput,
[BlockEnum.End]: _end,
[BlockEnum.KnowledgeRetrieval]: _knowledgeRetrieval,
...nodesMap
} = workflowAvailableNodesMetaData.nodesMap
return {
nodes,
nodesMap,
}
}, [workflowAvailableNodesMetaData])
const {
reset,
setFields,
} = useSnippetDetailStore(useShallow(state => ({
reset: state.reset,
setFields: state.setFields,
})))
const {
fields,
handleFieldsChange,
} = useSnippetInputFieldActions({
snippetId,
})
const {
handleStartWorkflowRun,
handleWorkflowStartRunInWorkflow,
} = useSnippetStartRun({
handleRun,
inputFields: fields,
})
const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl(snippetId)
useEffect(() => {
reset()
}, [reset, snippetId])
useEffect(() => {
setFields(displayPayload.inputFields)
}, [displayPayload.inputFields, setFields, snippetId])
useEffect(() => {
workflowStore.setState({ canvasReadOnly: !isEditing })
return () => {
workflowStore.setState({ canvasReadOnly: false })
}
}, [isEditing, workflowStore])
useEffect(() => {
workflowStore.temporal.getState().pause()
workflowStore.getState().setWorkflowHistory({
nodes: displayNodes,
edges: displayEdges,
workflowHistoryEvent: undefined,
workflowHistoryEventMeta: undefined,
})
workflowStore.temporal.getState().clear()
workflowStore.temporal.getState().resume()
}, [displayEdges, displayNodes, workflowStore])
const doSyncWorkflowDraft = useCallback((
...args: Parameters<typeof syncWorkflowDraft>
) => {
if (isEditing)
setHasDraftChanges(true)
return syncWorkflowDraft(...args)
}, [isEditing, setHasDraftChanges, syncWorkflowDraft])
const handleCancelChanges = useCallback(async () => {
const workflow = publishedWorkflow ?? (await refetchPublishedWorkflow()).data
if (!workflow)
return
handleRestoreFromPublishedWorkflow(workflow as never)
const publishedInputFields = Array.isArray(workflow.input_fields)
? workflow.input_fields as SnippetInputField[]
: []
setFields(publishedInputFields)
void syncInputFieldsDraft(publishedInputFields, {
onRefresh: setFields,
})
setHasDraftChanges(false)
}, [handleRestoreFromPublishedWorkflow, publishedWorkflow, refetchPublishedWorkflow, setFields, setHasDraftChanges, syncInputFieldsDraft])
const handleFieldsChangeInEditing = useCallback((nextFields: SnippetInputField[]) => {
handleFieldsChange(nextFields)
setHasDraftChanges(true)
}, [handleFieldsChange, setHasDraftChanges])
const handleExitEditing = useCallback(async () => {
if (hasDraftChanges)
return
setIsEditing(false)
}, [hasDraftChanges])
const handleDiscardAndExitEditing = useCallback(async () => {
await handleCancelChanges()
setIsEditing(false)
}, [handleCancelChanges])
const hooksStore = useMemo(() => {
return {
doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose,
handleRefreshWorkflowDraft,
handleBackupDraft,
handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow,
handleRun,
handleStopRun,
handleStartWorkflowRun,
handleWorkflowStartRunInWorkflow,
getWorkflowRunAndTraceUrl,
availableNodesMetaData,
fetchInspectVars,
hasNodeInspectVars,
hasSetInspectVar,
fetchInspectVarValue,
editInspectVarValue,
renameInspectVarName,
appendNodeInspectVars,
deleteInspectVar,
deleteNodeInspectorVars,
deleteAllInspectorVars,
isInspectVarEdited,
resetToLastRunVar,
invalidateSysVarValues,
resetConversationVar,
invalidateConversationVarValues,
configsMap,
}
}, [
appendNodeInspectVars,
availableNodesMetaData,
configsMap,
deleteAllInspectorVars,
deleteInspectVar,
deleteNodeInspectorVars,
doSyncWorkflowDraft,
editInspectVarValue,
fetchInspectVarValue,
fetchInspectVars,
handleBackupDraft,
handleRefreshWorkflowDraft,
handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow,
handleRun,
handleStartWorkflowRun,
handleStopRun,
handleWorkflowStartRunInWorkflow,
getWorkflowRunAndTraceUrl,
hasNodeInspectVars,
hasSetInspectVar,
invalidateConversationVarValues,
invalidateSysVarValues,
isInspectVarEdited,
renameInspectVarName,
resetConversationVar,
resetToLastRunVar,
syncWorkflowDraftWhenPageClose,
])
return (
<div className="relative flex h-full min-h-0 min-w-0">
<SnippetSidebar
snippet={snippet}
fields={fields}
readonly={!isEditing}
onFieldsChange={handleFieldsChangeInEditing}
/>
<div className="relative min-h-0 min-w-0 grow">
<WorkflowWithInnerContext
key={`${snippetId}-${isEditing ? 'draft' : 'published'}-${displayWorkflowHash ?? ''}`}
nodes={displayNodes}
edges={displayEdges}
viewport={displayViewport ?? graph.viewport}
hooksStore={hooksStore as unknown as Partial<HooksStoreShape>}
>
<SnippetMainContent
snippetId={snippetId}
fields={fields}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
onCancel={handleCancelChanges}
onDiscardAndExitEditing={handleDiscardAndExitEditing}
onEdit={() => setIsEditing(true)}
onExitEditing={handleExitEditing}
onSaved={() => setHasDraftChanges(false)}
onSavedAndExitEditing={() => {
setHasDraftChanges(false)
setIsEditing(false)
}}
/>
</WorkflowWithInnerContext>
</div>
</div>
)
}
export default SnippetMain

View File

@ -0,0 +1,293 @@
'use client'
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type { InputVar as WorkflowInputVar } from '@/app/components/workflow/types'
import type { SnippetInputField } from '@/models/snippet'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import copy from 'copy-to-clipboard'
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useCheckInputsForms } from '@/app/components/base/chat/chat/check-input-forms-hooks'
import { getProcessedInputs } from '@/app/components/base/chat/chat/utils'
import Loading from '@/app/components/base/loading'
import {
useWorkflowInteractions,
useWorkflowRun,
} from '@/app/components/workflow/hooks'
import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item'
import ResultPanel from '@/app/components/workflow/run/result-panel'
import ResultText from '@/app/components/workflow/run/result-text'
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
import { useStore } from '@/app/components/workflow/store'
import {
InputVarType,
WorkflowRunningStatus,
} from '@/app/components/workflow/types'
import { formatWorkflowRunIdentifier } from '@/app/components/workflow/utils'
import { PipelineInputVarType } from '@/models/pipeline'
type SnippetRunPanelProps = {
fields: SnippetInputField[]
}
type SnippetRunField = WorkflowInputVar & InputForm
const PIPELINE_TO_WORKFLOW_INPUT_VAR_TYPE: Record<PipelineInputVarType, InputVarType> = {
[PipelineInputVarType.textInput]: InputVarType.textInput,
[PipelineInputVarType.paragraph]: InputVarType.paragraph,
[PipelineInputVarType.select]: InputVarType.select,
[PipelineInputVarType.number]: InputVarType.number,
[PipelineInputVarType.singleFile]: InputVarType.singleFile,
[PipelineInputVarType.multiFiles]: InputVarType.multiFiles,
[PipelineInputVarType.checkbox]: InputVarType.checkbox,
}
const buildPreviewFields = (fields: SnippetInputField[]): SnippetRunField[] => {
return fields.map(field => ({
type: PIPELINE_TO_WORKFLOW_INPUT_VAR_TYPE[field.type],
label: field.label,
variable: field.variable,
max_length: field.max_length,
default: field.default_value,
required: field.required,
options: field.options,
placeholder: field.placeholder,
unit: field.unit,
hide: false,
allowed_file_upload_methods: field.allowed_file_upload_methods,
allowed_file_types: field.allowed_file_types,
allowed_file_extensions: field.allowed_file_extensions,
}))
}
const buildInitialInputs = (fields: SnippetRunField[]) => {
return fields.reduce<Record<string, unknown>>((acc, field) => {
if (field.default !== undefined)
acc[field.variable] = field.default
return acc
}, {})
}
const SnippetRunPanel = ({
fields,
}: SnippetRunPanelProps) => {
const { t } = useTranslation()
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
const { handleRun } = useWorkflowRun()
const { checkInputsForm } = useCheckInputsForms()
const workflowRunningData = useStore(s => s.workflowRunningData)
const showInputsPanel = useStore(s => s.showInputsPanel)
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
const panelWidth = useStore(s => s.previewPanelWidth)
const setPreviewPanelWidth = useStore(s => s.setPreviewPanelWidth)
const previewFields = useMemo(() => buildPreviewFields(fields), [fields])
const initialInputs = useMemo(() => buildInitialInputs(previewFields), [previewFields])
const [inputOverrides, setInputOverrides] = useState<Record<string, unknown> | null>(null)
const [selectedTab, setSelectedTab] = useState<string | null>(null)
const [isResizing, setIsResizing] = useState(false)
const inputs = inputOverrides ?? initialInputs
const hasInputTab = showInputsPanel && previewFields.length > 0
const defaultTab = hasInputTab ? 'INPUT' : 'RESULT'
const shouldShowDetailByDefault = !!workflowRunningData
&& (workflowRunningData.result.status === WorkflowRunningStatus.Succeeded || workflowRunningData.result.status === WorkflowRunningStatus.Failed)
&& !workflowRunningData.resultText
&& !workflowRunningData.result.files?.length
const currentTab = selectedTab ?? (shouldShowDetailByDefault ? 'DETAIL' : defaultTab)
const handleValueChange = useCallback((variable: string, value: unknown) => {
setInputOverrides(prev => ({
...(prev ?? initialInputs),
[variable]: value,
}))
}, [initialInputs])
const handleSubmit = useCallback(() => {
if (!checkInputsForm(inputs, previewFields))
return
setSelectedTab('RESULT')
handleRun({
inputs: getProcessedInputs(inputs, previewFields),
})
}, [checkInputsForm, handleRun, inputs, previewFields])
const startResizing = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
}, [])
const stopResizing = useCallback(() => {
setIsResizing(false)
}, [])
const resize = useCallback((e: MouseEvent) => {
if (!isResizing)
return
const newWidth = window.innerWidth - e.clientX
const reservedCanvasWidth = 400
const maxAllowed = workflowCanvasWidth ? (workflowCanvasWidth - reservedCanvasWidth) : 1024
if (newWidth >= 400 && newWidth <= maxAllowed)
setPreviewPanelWidth(newWidth)
}, [isResizing, setPreviewPanelWidth, workflowCanvasWidth])
useEffect(() => {
window.addEventListener('mousemove', resize)
window.addEventListener('mouseup', stopResizing)
return () => {
window.removeEventListener('mousemove', resize)
window.removeEventListener('mouseup', stopResizing)
}
}, [resize, stopResizing])
return (
<div
className="relative flex h-full flex-col rounded-l-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
style={{ width: `${panelWidth}px` }}
>
<div
className="absolute top-1/2 bottom-0 left-[3px] z-50 h-6 w-[3px] cursor-col-resize rounded bg-gray-300"
onMouseDown={startResizing}
/>
<div className="flex items-center justify-between p-4 pb-1 text-base font-semibold text-text-primary">
{`Test Run${formatWorkflowRunIdentifier(workflowRunningData?.result.finished_at, workflowRunningData?.result.status)}`}
<div className="cursor-pointer p-1" onClick={handleCancelDebugAndPreviewPanel}>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</div>
</div>
<div className="relative flex grow flex-col">
<div className="flex shrink-0 items-center border-b-[0.5px] border-divider-subtle px-4">
{hasInputTab && (
<div
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] leading-[18px] font-semibold ${currentTab === 'INPUT' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'}`}
onClick={() => setSelectedTab('INPUT')}
>
{t('input', { ns: 'runLog' })}
</div>
)}
<div
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] leading-[18px] font-semibold ${currentTab === 'RESULT' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`}
onClick={() => workflowRunningData && setSelectedTab('RESULT')}
>
{t('result', { ns: 'runLog' })}
</div>
<div
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] leading-[18px] font-semibold ${currentTab === 'DETAIL' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`}
onClick={() => workflowRunningData && setSelectedTab('DETAIL')}
>
{t('detail', { ns: 'runLog' })}
</div>
<div
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] leading-[18px] font-semibold ${currentTab === 'TRACING' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`}
onClick={() => workflowRunningData && setSelectedTab('TRACING')}
>
{t('tracing', { ns: 'runLog' })}
</div>
</div>
<div className={`h-0 grow overflow-y-auto rounded-b-2xl ${(currentTab === 'RESULT' || currentTab === 'TRACING') ? '!bg-background-section-burn' : 'bg-components-panel-bg'}`}>
{currentTab === 'INPUT' && hasInputTab && (
<>
<div className="px-4 pt-3 pb-2">
{previewFields.map((field, index) => (
<div
key={field.variable}
className="mb-2 last-of-type:mb-0"
>
<FormItem
autoFocus={index === 0}
className="!block"
payload={field}
value={inputs[field.variable]}
onChange={value => handleValueChange(field.variable, value)}
/>
</div>
))}
</div>
<div className="flex items-center justify-between px-4 py-2">
<Button
variant="primary"
className="w-full"
disabled={workflowRunningData?.result?.status === WorkflowRunningStatus.Running}
onClick={handleSubmit}
>
{t('singleRun.startRun', { ns: 'workflow' })}
</Button>
</div>
</>
)}
{currentTab === 'RESULT' && (
<div className="p-2">
<ResultText
isRunning={workflowRunningData?.result?.status === WorkflowRunningStatus.Running || !workflowRunningData?.result}
outputs={workflowRunningData?.resultText}
allFiles={workflowRunningData?.result?.files}
error={workflowRunningData?.result?.error}
onClick={() => setSelectedTab('DETAIL')}
/>
{(workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded && workflowRunningData?.resultText && typeof workflowRunningData.resultText === 'string') && (
<Button
className="mb-4 ml-4 space-x-1"
onClick={() => {
copy(workflowRunningData?.resultText || '')
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}}
>
<span className="i-ri-clipboard-line h-3.5 w-3.5" />
<div>{t('operation.copy', { ns: 'common' })}</div>
</Button>
)}
</div>
)}
{currentTab === 'DETAIL' && workflowRunningData?.result && (
<ResultPanel
inputs={workflowRunningData.result?.inputs}
inputs_truncated={workflowRunningData.result?.inputs_truncated}
process_data={workflowRunningData.result?.process_data}
process_data_truncated={workflowRunningData.result?.process_data_truncated}
outputs={workflowRunningData.result?.outputs}
outputs_truncated={workflowRunningData.result?.outputs_truncated}
outputs_full_content={workflowRunningData.result?.outputs_full_content}
status={workflowRunningData.result?.status || ''}
error={workflowRunningData.result?.error}
elapsed_time={workflowRunningData.result?.elapsed_time}
total_tokens={workflowRunningData.result?.total_tokens}
created_at={workflowRunningData.result?.created_at}
created_by={(workflowRunningData.result?.created_by as unknown as { name: string })?.name}
steps={workflowRunningData.result?.total_steps}
exceptionCounts={workflowRunningData.result?.exceptions_count}
/>
)}
{currentTab === 'DETAIL' && !workflowRunningData?.result && (
<div className="flex h-full items-center justify-center bg-components-panel-bg">
<Loading />
</div>
)}
{currentTab === 'TRACING' && (
<TracingPanel
className="bg-background-section-burn"
list={workflowRunningData?.tracing || []}
/>
)}
{currentTab === 'TRACING' && !workflowRunningData?.tracing?.length && (
<div className="flex h-full items-center justify-center !bg-background-section-burn">
<Loading />
</div>
)}
</div>
</div>
</div>
)
}
export default memo(SnippetRunPanel)

View File

@ -0,0 +1,153 @@
'use client'
import type { InputVar } from '@/app/components/workflow/types'
import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SnippetInfoDropdown from '@/app/components/app-sidebar/snippet-info/dropdown'
import ConfigVarModal from '@/app/components/app/configuration/config-var/config-modal'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import VarList from '@/app/components/workflow/nodes/start/components/var-list'
import Link from '@/next/link'
import { hasDuplicateStr } from '@/utils/var'
type SnippetSidebarProps = {
snippet: SnippetDetail
fields: SnippetInputField[]
readonly: boolean
onFieldsChange: (fields: SnippetInputField[]) => void
}
const toWorkflowInputVar = (field: SnippetInputField): InputVar => ({
...field,
type: field.type as unknown as InputVar['type'],
})
const toSnippetInputField = (field: InputVar): SnippetInputField => ({
...field,
label: typeof field.label === 'string' ? field.label : field.label.variable,
type: field.type as unknown as SnippetInputField['type'],
})
const SnippetSidebar = ({
snippet,
fields,
readonly,
onFieldsChange,
}: SnippetSidebarProps) => {
const { t } = useTranslation()
const [isShowAddVarModal, setIsShowAddVarModal] = useState(false)
const workflowInputVars = useMemo(() => fields.map(toWorkflowInputVar), [fields])
const showAddVarModal = useCallback(() => {
setIsShowAddVarModal(true)
}, [])
const hideAddVarModal = useCallback(() => {
setIsShowAddVarModal(false)
}, [])
const validateFields = useCallback((nextFields: SnippetInputField[]) => {
let errorMsgKey: 'varKeyError.keyAlreadyExists' | '' = ''
let typeName: 'variableConfig.varName' | 'variableConfig.labelName' | '' = ''
if (hasDuplicateStr(nextFields.map(item => item.variable))) {
errorMsgKey = 'varKeyError.keyAlreadyExists'
typeName = 'variableConfig.varName'
}
else if (hasDuplicateStr(nextFields.map(item => item.label as string))) {
errorMsgKey = 'varKeyError.keyAlreadyExists'
typeName = 'variableConfig.labelName'
}
if (errorMsgKey && typeName) {
toast.error(t(errorMsgKey, { ns: 'appDebug', key: t(typeName, { ns: 'appDebug' }) }))
return false
}
return true
}, [t])
const handleAddVarConfirm = useCallback((payload: InputVar) => {
const nextFields = [...fields, toSnippetInputField(payload)]
if (!validateFields(nextFields))
return
onFieldsChange(nextFields)
hideAddVarModal()
}, [fields, hideAddVarModal, onFieldsChange, validateFields])
const handleVarListChange = useCallback((list: InputVar[]) => {
onFieldsChange(list.map(toSnippetInputField))
}, [onFieldsChange])
return (
<aside className="flex h-full w-90 shrink-0 flex-col overflow-hidden rounded-tl-2xl border-r border-divider-subtle bg-background-default">
<div className="shrink-0 px-6 pt-7">
<Link
href="/snippets"
className="inline-flex items-center gap-2 system-sm-semibold-uppercase text-text-primary hover:text-text-accent"
>
<span aria-hidden className="i-ri-arrow-left-line h-4 w-4" />
{t('management', { ns: 'snippet' })}
</Link>
<div className="mt-12 flex items-start gap-3">
<div className="min-w-0 grow">
<div className="system-xl-semibold text-text-primary">{snippet.name}</div>
{!!snippet.description && (
<div className="mt-3 system-sm-regular text-text-tertiary">
{snippet.description}
</div>
)}
</div>
<SnippetInfoDropdown snippet={snippet} />
</div>
</div>
<div className="mx-6 mt-7 h-px shrink-0 bg-divider-subtle" />
<div className="flex min-h-0 grow flex-col px-6 pt-7">
<Field
title={t('inputVariables', { ns: 'snippet' })}
operations={!readonly
? (
<button
type="button"
aria-label={`${t('operation.add', { ns: 'common' })} ${t('inputVariables', { ns: 'snippet' })}`}
className={cn(
'rounded-md border-none bg-transparent p-1 select-none focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden',
'cursor-pointer hover:bg-state-base-hover',
)}
onClick={showAddVarModal}
>
<span className="i-ri-add-line size-4 text-text-tertiary" aria-hidden="true" />
</button>
)
: undefined}
>
<VarList
readonly={readonly}
list={workflowInputVars}
onChange={handleVarListChange}
/>
</Field>
</div>
{isShowAddVarModal && (
<ConfigVarModal
isCreate
supportFile
isShow={isShowAddVarModal}
onClose={hideAddVarModal}
onConfirm={handleAddVarConfirm}
showHiddenField={false}
varKeys={fields.map(v => v.variable)}
/>
)}
</aside>
)
}
export default memo(SnippetSidebar)

View File

@ -0,0 +1,62 @@
'use client'
import type { PanelProps } from '@/app/components/workflow/panel'
import type { SnippetInputField } from '@/models/snippet'
import { memo, useMemo } from 'react'
import Panel from '@/app/components/workflow/panel'
import { useStore } from '@/app/components/workflow/store'
import dynamic from '@/next/dynamic'
const Record = dynamic(() => import('@/app/components/workflow/panel/record'), {
ssr: false,
})
const SnippetRunPanel = dynamic(() => import('./snippet-run-panel'), {
ssr: false,
})
type SnippetWorkflowPanelProps = {
snippetId: string
fields: SnippetInputField[]
}
const SnippetPanelOnRight = ({
fields,
}: Pick<SnippetWorkflowPanelProps, 'fields'>) => {
const historyWorkflowData = useStore(s => s.historyWorkflowData)
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
return (
<>
{historyWorkflowData && <Record />}
{showDebugAndPreviewPanel && <SnippetRunPanel fields={fields} />}
</>
)
}
const SnippetWorkflowPanel = ({
snippetId,
fields,
}: SnippetWorkflowPanelProps) => {
const versionHistoryPanelProps = useMemo(() => {
return {
getVersionListUrl: `/snippets/${snippetId}/workflows`,
deleteVersionUrl: (versionId: string) => `/snippets/${snippetId}/workflows/${versionId}`,
restoreVersionUrl: (versionId: string) => `/snippets/${snippetId}/workflows/${versionId}/restore`,
updateVersionUrl: (versionId: string) => `/snippets/${snippetId}/workflows/${versionId}`,
latestVersionId: '',
}
}, [snippetId])
const panelProps: PanelProps = useMemo(() => {
return {
components: {
right: <SnippetPanelOnRight fields={fields} />,
},
versionHistoryPanelProps,
}
}, [fields, versionHistoryPanelProps])
return <Panel {...panelProps} />
}
export default memo(SnippetWorkflowPanel)

View File

@ -0,0 +1,153 @@
'use client'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useKeyPress } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
export type CreateSnippetDialogPayload = {
name: string
description: string
graph: SnippetCanvasData
input_fields?: SnippetInputField[]
}
type CreateSnippetDialogInitialValue = {
name?: string
description?: string
}
type CreateSnippetDialogProps = {
isOpen: boolean
selectedGraph?: SnippetCanvasData
inputFields?: SnippetInputField[]
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
isSubmitting?: boolean
title?: string
confirmText?: string
initialValue?: CreateSnippetDialogInitialValue
}
const defaultGraph: SnippetCanvasData = {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
}
function CreateSnippetDialog({
isOpen,
selectedGraph,
inputFields,
onClose,
onConfirm,
isSubmitting = false,
title,
confirmText,
initialValue,
}: CreateSnippetDialogProps) {
const { t } = useTranslation()
const [name, setName] = useState(initialValue?.name ?? '')
const [description, setDescription] = useState(initialValue?.description ?? '')
const resetForm = useCallback(() => {
setName('')
setDescription('')
}, [])
const handleClose = useCallback(() => {
resetForm()
onClose()
}, [onClose, resetForm])
const handleConfirm = useCallback(() => {
const trimmedName = name.trim()
const trimmedDescription = description.trim()
if (!trimmedName)
return
const payload = {
name: trimmedName,
description: trimmedDescription,
graph: selectedGraph ?? defaultGraph,
input_fields: inputFields,
}
onConfirm(payload)
}, [description, inputFields, name, onConfirm, selectedGraph])
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (!isOpen)
return
if (isSubmitting)
return
handleConfirm()
})
return (
<>
<Dialog open={isOpen} onOpenChange={open => !open && handleClose()}>
<DialogContent className="w-120 max-w-120 p-0">
<DialogCloseButton />
<div className="px-6 pt-6 pb-3">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{title || t('snippet.createDialogTitle', { ns: 'workflow' })}
</DialogTitle>
</div>
<div className="space-y-4 px-6 py-2">
<div>
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t('snippet.nameLabel', { ns: 'workflow' })}
</div>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('snippet.namePlaceholder', { ns: 'workflow' }) || ''}
disabled={isSubmitting}
autoFocus
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t('snippet.descriptionLabel', { ns: 'workflow' })}
</div>
<Textarea
className="resize-none"
value={description}
onValueChange={value => setDescription(value)}
placeholder={t('snippet.descriptionPlaceholder', { ns: 'workflow' }) || ''}
disabled={isSubmitting}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 pb-6">
<Button disabled={isSubmitting} onClick={handleClose}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
disabled={!name.trim() || isSubmitting}
loading={isSubmitting}
onClick={handleConfirm}
>
{confirmText || t('snippet.confirm', { ns: 'workflow' })}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}
export default CreateSnippetDialog

View File

@ -0,0 +1,149 @@
import { act, renderHook } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import { useCreateSnippet } from '../use-create-snippet'
const mockPush = vi.fn()
const mockMutateAsync = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockSyncDraftWorkflow = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutateAsync: mockMutateAsync,
isPending: false,
}),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
snippets: {
syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
},
},
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
describe('useCreateSnippet', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('State', () => {
it('should open and close create snippet dialog', () => {
const { result } = renderHook(() => useCreateSnippet())
act(() => {
result.current.handleOpenCreateSnippetDialog()
})
expect(result.current.isCreateSnippetDialogOpen).toBe(true)
act(() => {
result.current.handleCloseCreateSnippetDialog()
})
expect(result.current.isCreateSnippetDialogOpen).toBe(false)
})
})
describe('Create Flow', () => {
it('should create snippet, sync draft workflow, and navigate on success', async () => {
mockMutateAsync.mockResolvedValue({ id: 'snippet-123' })
mockSyncDraftWorkflow.mockResolvedValue(undefined)
const { result } = renderHook(() => useCreateSnippet())
act(() => {
result.current.handleOpenCreateSnippetDialog()
})
await act(async () => {
await result.current.handleCreateSnippet({
name: 'My snippet',
description: 'desc',
input_fields: [
{
label: 'topic',
variable: 'topic',
type: PipelineInputVarType.textInput,
required: true,
},
],
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
})
expect(mockMutateAsync).toHaveBeenCalledWith({
body: {
name: 'My snippet',
description: 'desc',
input_fields: [
{
label: 'topic',
variable: 'topic',
type: PipelineInputVarType.textInput,
required: true,
},
],
},
})
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
params: { snippetId: 'snippet-123' },
body: {
input_fields: [
{
label: 'topic',
variable: 'topic',
type: PipelineInputVarType.textInput,
required: true,
},
],
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
},
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
expect(result.current.isCreateSnippetDialogOpen).toBe(false)
expect(result.current.isCreatingSnippet).toBe(false)
})
it('should rely on API error handling when create fails', async () => {
mockMutateAsync.mockRejectedValue(new Error('create failed'))
const { result } = renderHook(() => useCreateSnippet())
await act(async () => {
await result.current.handleCreateSnippet({
name: 'My snippet',
description: '',
input_fields: [],
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
})
expect(mockToastError).not.toHaveBeenCalled()
expect(result.current.isCreatingSnippet).toBe(false)
})
})
})

View File

@ -0,0 +1,22 @@
import { renderHook } from '@testing-library/react'
import { useGetRunAndTraceUrl } from '../use-get-run-and-trace-url'
describe('useGetRunAndTraceUrl', () => {
it('should build snippet workflow run and trace urls from the snippet id', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl('snippet-1'))
expect(result.current.getWorkflowRunAndTraceUrl('run-1')).toEqual({
runUrl: '/snippets/snippet-1/workflow-runs/run-1',
traceUrl: '/snippets/snippet-1/workflow-runs/run-1/node-executions',
})
})
it('should return empty urls when no run id is provided', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl('snippet-1'))
expect(result.current.getWorkflowRunAndTraceUrl()).toEqual({
runUrl: '',
traceUrl: '',
})
})
})

View File

@ -0,0 +1,95 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useInspectVarsCrud } from '../use-inspect-vars-crud'
const mockApis = {
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
renameInspectVarName: vi.fn(),
appendNodeInspectVars: vi.fn(),
deleteInspectVar: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
deleteAllInspectorVars: vi.fn(),
isInspectVarEdited: vi.fn(),
resetToLastRunVar: vi.fn(),
invalidateSysVarValues: vi.fn(),
resetConversationVar: vi.fn(),
invalidateConversationVarValues: vi.fn(),
}
const mockUseInspectVarsCrudCommon = vi.fn(() => mockApis)
vi.mock('../../../workflow/hooks/use-inspect-vars-crud-common', () => ({
useInspectVarsCrudCommon: (...args: Parameters<typeof mockUseInspectVarsCrudCommon>) => mockUseInspectVarsCrudCommon(...args),
}))
const mockConfigsMap = {
flowId: 'snippet-123',
flowType: 'snippet',
fileSettings: {
image: { enabled: false },
fileUploadConfig: {},
},
}
vi.mock('../use-configs-map', () => ({
useConfigsMap: () => mockConfigsMap,
}))
describe('useInspectVarsCrud', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Composition', () => {
it('should pass configsMap to useInspectVarsCrudCommon', () => {
renderHook(() => useInspectVarsCrud('snippet-123'))
expect(mockUseInspectVarsCrudCommon).toHaveBeenCalledWith(
expect.objectContaining({
flowId: 'snippet-123',
flowType: 'snippet',
}),
)
})
it('should return all APIs from useInspectVarsCrudCommon', () => {
const { result } = renderHook(() => useInspectVarsCrud('snippet-123'))
expect(result.current.hasNodeInspectVars).toBe(mockApis.hasNodeInspectVars)
expect(result.current.fetchInspectVarValue).toBe(mockApis.fetchInspectVarValue)
expect(result.current.editInspectVarValue).toBe(mockApis.editInspectVarValue)
expect(result.current.deleteInspectVar).toBe(mockApis.deleteInspectVar)
expect(result.current.deleteAllInspectorVars).toBe(mockApis.deleteAllInspectorVars)
expect(result.current.resetToLastRunVar).toBe(mockApis.resetToLastRunVar)
expect(result.current.resetConversationVar).toBe(mockApis.resetConversationVar)
})
})
describe('API Surface', () => {
it('should expose all expected API methods', () => {
const { result } = renderHook(() => useInspectVarsCrud('snippet-123'))
const expectedKeys = [
'hasNodeInspectVars',
'hasSetInspectVar',
'fetchInspectVarValue',
'editInspectVarValue',
'renameInspectVarName',
'appendNodeInspectVars',
'deleteInspectVar',
'deleteNodeInspectorVars',
'deleteAllInspectorVars',
'isInspectVarEdited',
'resetToLastRunVar',
'invalidateSysVarValues',
'resetConversationVar',
'invalidateConversationVarValues',
]
for (const key of expectedKeys)
expect(result.current).toHaveProperty(key)
})
})
})

View File

@ -0,0 +1,243 @@
import type { SnippetInputField } from '@/models/snippet'
import { act, renderHook } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetDetailStore } from '../../store'
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
const mockGetNodes = vi.fn()
const mockGetNodesReadOnly = vi.fn()
const mockPostWithKeepalive = vi.fn()
const mockSyncDraftWorkflow = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
let deferSerialCallbacks = false
let queuedSerialCallbacks: Array<() => Promise<void> | void> = []
let reactFlowState: {
getNodes: typeof mockGetNodes
edges: Array<Record<string, unknown>>
transform: [number, number, number]
}
let workflowStoreState: {
syncWorkflowDraftHash: string | null
setDraftUpdatedAt: typeof mockSetDraftUpdatedAt
setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash
}
vi.mock('reactflow', () => ({
useStoreApi: () => ({ getState: () => reactFlowState }),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly }),
}))
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
useSerialAsyncCallback: (fn: (...args: unknown[]) => Promise<void>, checkFn?: () => boolean) =>
(...args: unknown[]) => {
if (checkFn?.())
return
if (deferSerialCallbacks) {
queuedSerialCallbacks.push(() => fn(...args))
return Promise.resolve()
}
return fn(...args)
},
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => workflowStoreState,
}),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
snippets: {
syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
},
},
}))
vi.mock('@/service/fetch', () => ({
postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args),
}))
vi.mock('@/config', () => ({ API_PREFIX: '/api' }))
vi.mock('../use-snippet-refresh-draft', () => ({
useSnippetRefreshDraft: () => ({
handleRefreshWorkflowDraft: vi.fn(),
}),
}))
const createInputField = (variable: string): SnippetInputField => ({
type: PipelineInputVarType.textInput,
label: variable,
variable,
required: false,
})
describe('snippet/use-nodes-sync-draft', () => {
beforeEach(() => {
vi.clearAllMocks()
deferSerialCallbacks = false
queuedSerialCallbacks = []
reactFlowState = {
getNodes: mockGetNodes,
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }],
transform: [12, 24, 1.5],
}
workflowStoreState = {
syncWorkflowDraftHash: 'draft-hash',
setDraftUpdatedAt: mockSetDraftUpdatedAt,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
}
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start', _temp: 'drop' } },
])
mockSyncDraftWorkflow.mockResolvedValue({
hash: 'next-hash',
updated_at: 123,
})
mockSetSyncWorkflowDraftHash.mockImplementation((hash: string) => {
workflowStoreState.syncWorkflowDraftHash = hash
})
useSnippetDetailStore.setState({
fields: [createInputField('topic')],
})
})
it('should include current input_fields when syncing the draft graph', async () => {
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
body: {
graph: {
nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start' } }],
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }],
viewport: { x: 12, y: 24, zoom: 1.5 },
},
input_fields: [createInputField('topic')],
hash: 'draft-hash',
},
})
})
it('should snapshot graph before queued draft sync executes', async () => {
deferSerialCallbacks = true
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
mockGetNodes.mockReturnValue([
{ id: 'late-node', position: { x: 9, y: 9 }, data: { title: 'Late' } },
])
reactFlowState.edges = [{ id: 'late-edge', source: 'late-node', target: 'late-target', data: { stable: false } }]
reactFlowState.transform = [99, 88, 0.5]
await act(async () => {
await Promise.all(queuedSerialCallbacks.map(run => run()))
})
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
body: {
graph: {
nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start' } }],
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }],
viewport: { x: 12, y: 24, zoom: 1.5 },
},
input_fields: [createInputField('topic')],
hash: 'draft-hash',
},
})
})
it('should include the latest graph when syncing input fields', async () => {
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
const nextFields = [createInputField('summary')]
await act(async () => {
await result.current.syncInputFieldsDraft(nextFields)
})
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
body: {
graph: {
nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start' } }],
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }],
viewport: { x: 12, y: 24, zoom: 1.5 },
},
input_fields: nextFields,
hash: 'draft-hash',
},
})
})
it('should serialize draft sync across hook instances and use the latest returned hash', async () => {
const { result: firstHook } = renderHook(() => useNodesSyncDraft('snippet-1'))
const { result: secondHook } = renderHook(() => useNodesSyncDraft('snippet-1'))
mockSyncDraftWorkflow
.mockResolvedValueOnce({
hash: 'hash-after-first-sync',
updated_at: 123,
})
.mockResolvedValueOnce({
hash: 'hash-after-second-sync',
updated_at: 124,
})
await act(async () => {
await Promise.all([
firstHook.current.doSyncWorkflowDraft(),
secondHook.current.doSyncWorkflowDraft(),
])
})
expect(mockSyncDraftWorkflow).toHaveBeenNthCalledWith(1, {
params: { snippetId: 'snippet-1' },
body: expect.objectContaining({
hash: 'draft-hash',
}),
})
expect(mockSyncDraftWorkflow).toHaveBeenNthCalledWith(2, {
params: { snippetId: 'snippet-1' },
body: expect.objectContaining({
hash: 'hash-after-first-sync',
}),
})
expect(workflowStoreState.syncWorkflowDraftHash).toBe('hash-after-second-sync')
})
it('should send input_fields together with graph on page close', () => {
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/snippets/snippet-1/workflows/draft', {
graph: {
nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start' } }],
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }],
viewport: { x: 12, y: 24, zoom: 1.5 },
},
input_fields: [createInputField('topic')],
hash: 'draft-hash',
})
})
})

View File

@ -0,0 +1,305 @@
import type { SnippetWorkflow } from '@/types/snippet'
import {
renderHook,
waitFor,
} from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSnippetInit } from '../use-snippet-init'
const mockWorkflowStoreSetState = vi.fn()
const mockSetPublishedAt = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockUseSnippetApiDetail = vi.fn()
const mockFetchSnippetDraftWorkflow = vi.fn()
const mockUseSnippetDefaultBlockConfigs = vi.fn()
const mockUseSnippetPublishedWorkflow = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
setState: mockWorkflowStoreSetState,
getState: () => ({
setDraftUpdatedAt: mockSetDraftUpdatedAt,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setPublishedAt: mockSetPublishedAt,
}),
}),
}))
vi.mock('@/service/use-snippets', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/use-snippets')>()
return {
...actual,
useSnippetApiDetail: (snippetId: string) => mockUseSnippetApiDetail(snippetId),
}
})
vi.mock('@/service/use-snippet-workflows', () => ({
fetchSnippetDraftWorkflow: (snippetId: string) => mockFetchSnippetDraftWorkflow(snippetId),
useSnippetDefaultBlockConfigs: (snippetId: string, onSuccess?: (data: unknown) => void) => mockUseSnippetDefaultBlockConfigs(snippetId, onSuccess),
useSnippetPublishedWorkflow: (snippetId: string, onSuccess?: (data: { created_at: number }) => void) => mockUseSnippetPublishedWorkflow(snippetId, onSuccess),
}))
const createDraftWorkflow = (overrides: Partial<SnippetWorkflow> = {}): SnippetWorkflow => ({
id: 'draft-1',
graph: {},
features: {},
input_fields: [],
hash: 'draft-hash',
created_at: 1_712_300_000,
updated_at: 1_712_345_678,
...overrides,
})
describe('useSnippetInit', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSnippetApiDetail.mockReturnValue({
data: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
type: 'node',
is_published: false,
version: '1',
use_count: 0,
tags: [],
input_fields: [],
created_at: 1_712_300_000,
created_by: 'user-1',
updated_at: 1_712_300_000,
updated_by: 'user-1',
},
error: null,
isLoading: false,
})
mockFetchSnippetDraftWorkflow.mockResolvedValue(undefined)
mockUseSnippetDefaultBlockConfigs.mockReturnValue({
data: undefined,
})
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: undefined,
})
})
it('should return snippet detail query result', async () => {
const { result } = renderHook(() => useSnippetInit('snippet-1'))
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(mockUseSnippetApiDetail).toHaveBeenCalledWith('snippet-1')
expect(mockFetchSnippetDraftWorkflow).toHaveBeenCalledWith('snippet-1')
expect(result.current.data?.snippet.id).toBe('snippet-1')
expect(result.current.data?.published.graph.viewport).toEqual({ x: 0, y: 0, zoom: 1 })
})
it('should use draft input_fields for snippet inputs', async () => {
mockUseSnippetApiDetail.mockReturnValue({
data: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
type: 'node',
is_published: false,
version: '1',
use_count: 0,
tags: [],
input_fields: [
{
label: 'Published field',
variable: 'published_field',
type: 'text-input',
required: true,
},
],
created_at: 1_712_300_000,
created_by: 'user-1',
updated_at: 1_712_300_000,
updated_by: 'user-1',
},
error: null,
isLoading: false,
})
mockFetchSnippetDraftWorkflow.mockResolvedValue(createDraftWorkflow({
input_fields: [
{
label: 'Draft field',
variable: 'draft_field',
type: 'text-input',
required: true,
},
],
}))
const { result } = renderHook(() => useSnippetInit('snippet-1'))
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data?.draft.inputFields).toEqual([
{
label: 'Draft field',
variable: 'draft_field',
type: 'text-input',
required: true,
},
])
})
it('should sync draft metadata before returning initialized data', async () => {
mockFetchSnippetDraftWorkflow.mockResolvedValue(createDraftWorkflow({
hash: 'fetched-draft-hash',
updated_at: 1_712_345_678,
graph: {
nodes: [{ id: 'node-1' }],
edges: [],
viewport: { x: 10, y: 20, zoom: 1.2 },
},
}))
const { result } = renderHook(() => useSnippetInit('snippet-1'))
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1_712_345_678)
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('fetched-draft-hash')
expect(result.current.data?.draft.graph.viewport).toEqual({ x: 10, y: 20, zoom: 1.2 })
})
it('should not return stale draft data while the draft workflow request is pending', () => {
mockFetchSnippetDraftWorkflow.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useSnippetInit('snippet-1'))
expect(result.current.data).toBeUndefined()
expect(result.current.isLoading).toBe(true)
})
it('should initialize with empty graph when the draft workflow does not exist', async () => {
mockFetchSnippetDraftWorkflow.mockResolvedValue(undefined)
const { result } = renderHook(() => useSnippetInit('snippet-1'))
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data?.published.graph).toEqual({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
})
})
it('should ignore outdated draft workflow response when snippet changes', async () => {
let resolveFirstDraft: (workflow: SnippetWorkflow) => void = () => {}
mockFetchSnippetDraftWorkflow.mockImplementation((snippetId: string) => {
if (snippetId === 'snippet-1') {
return new Promise<SnippetWorkflow>((resolve) => {
resolveFirstDraft = resolve
})
}
return Promise.resolve(createDraftWorkflow({
id: 'draft-2',
hash: 'snippet-2-hash',
graph: {
nodes: [{ id: 'snippet-2-node' }],
edges: [],
viewport: { x: 2, y: 2, zoom: 1 },
},
}))
})
const { result, rerender } = renderHook(({ snippetId }) => useSnippetInit(snippetId), {
initialProps: { snippetId: 'snippet-1' },
})
rerender({ snippetId: 'snippet-2' })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
resolveFirstDraft(createDraftWorkflow({
hash: 'stale-snippet-1-hash',
graph: {
nodes: [{ id: 'stale-node' }],
edges: [],
viewport: { x: 1, y: 1, zoom: 1 },
},
}))
await Promise.resolve()
expect(result.current.data?.draft.graph.nodes).toEqual([{ id: 'snippet-2-node' }])
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('snippet-2-hash')
expect(mockSetSyncWorkflowDraftHash).not.toHaveBeenCalledWith('stale-snippet-1-hash')
})
it('should normalize array default block configs into workflow store state', () => {
mockUseSnippetDefaultBlockConfigs.mockImplementation((_snippetId: string, onSuccess?: (data: unknown) => void) => {
onSuccess?.([
{ type: 'llm', config: { model: 'gpt-4.1' } },
{ type: 'code', config: { language: 'python3' } },
])
return { data: undefined, isLoading: false }
})
renderHook(() => useSnippetInit('snippet-1'))
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
nodesDefaultConfigs: {
llm: { model: 'gpt-4.1' },
code: { language: 'python3' },
},
})
})
it('should keep object default block configs as-is', () => {
mockUseSnippetDefaultBlockConfigs.mockImplementation((_snippetId: string, onSuccess?: (data: unknown) => void) => {
onSuccess?.({
llm: { model: 'gpt-4.1' },
})
return { data: undefined, isLoading: false }
})
renderHook(() => useSnippetInit('snippet-1'))
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
nodesDefaultConfigs: {
llm: { model: 'gpt-4.1' },
},
})
})
it('should sync published created_at into workflow store', () => {
mockUseSnippetPublishedWorkflow.mockImplementation((_snippetId: string, onSuccess?: (data: { created_at: number }) => void) => {
onSuccess?.({
created_at: 1_712_345_678,
})
return { data: { created_at: 1_712_345_678 }, isLoading: false }
})
renderHook(() => useSnippetInit('snippet-1'))
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
})
it('should reset published metadata when the published workflow is unavailable', () => {
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: undefined,
isLoading: false,
})
renderHook(() => useSnippetInit('snippet-1'))
expect(mockSetPublishedAt).toHaveBeenCalledWith(0)
})
})

View File

@ -0,0 +1,101 @@
import type { SnippetWorkflow } from '@/types/snippet'
import { renderHook, waitFor } from '@testing-library/react'
import { useSnippetRefreshDraft } from '../use-snippet-refresh-draft'
const mockFetchSnippetDraftWorkflow = vi.fn()
const mockHandleUpdateWorkflowCanvas = vi.fn()
const mockSnippetSetState = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const mockSetIsSyncingWorkflowDraft = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
const workflowStoreState = {
setDraftUpdatedAt: mockSetDraftUpdatedAt,
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
}
vi.mock('@/service/use-snippet-workflows', () => ({
fetchSnippetDraftWorkflow: (...args: unknown[]) => mockFetchSnippetDraftWorkflow(...args),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowUpdate: () => ({
handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => workflowStoreState,
}),
}))
vi.mock('../../store', () => ({
useSnippetDetailStore: {
setState: (...args: unknown[]) => mockSnippetSetState(...args),
},
}))
const createDraftWorkflow = (overrides: Partial<SnippetWorkflow> = {}): SnippetWorkflow => ({
id: 'draft-1',
graph: {
nodes: [{ id: 'node-1' }],
edges: [],
viewport: { x: 10, y: 20, zoom: 1.2 },
},
features: {},
input_fields: [],
hash: 'draft-hash',
created_at: 1_712_300_000,
updated_at: 1_712_345_678,
...overrides,
} as SnippetWorkflow)
describe('useSnippetRefreshDraft', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should refresh the draft workflow through the silent draft fetcher', async () => {
const draftWorkflow = createDraftWorkflow()
const onSuccess = vi.fn()
mockFetchSnippetDraftWorkflow.mockResolvedValueOnce(draftWorkflow)
const { result } = renderHook(() => useSnippetRefreshDraft('snippet-1'))
result.current.handleRefreshWorkflowDraft(onSuccess)
await waitFor(() => {
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'node-1' }],
edges: [],
viewport: { x: 10, y: 20, zoom: 1.2 },
})
})
expect(mockFetchSnippetDraftWorkflow).toHaveBeenCalledWith('snippet-1')
expect(mockSnippetSetState).toHaveBeenCalledWith({
fields: [],
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('draft-hash')
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1_712_345_678)
expect(onSuccess).toHaveBeenCalledWith(draftWorkflow)
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenNthCalledWith(1, true)
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenLastCalledWith(false)
})
it('should silently finish when the draft workflow does not exist yet', async () => {
mockFetchSnippetDraftWorkflow.mockResolvedValueOnce(undefined)
const { result } = renderHook(() => useSnippetRefreshDraft('snippet-1'))
result.current.handleRefreshWorkflowDraft()
await waitFor(() => {
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenLastCalledWith(false)
})
expect(mockFetchSnippetDraftWorkflow).toHaveBeenCalledWith('snippet-1')
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
expect(mockSnippetSetState).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,175 @@
import { act, renderHook } from '@testing-library/react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { useSnippetRun } from '../use-snippet-run'
type WorkflowStoreState = {
workflowRunningData?: {
task_id?: string
result: {
status: string
inputs_truncated: boolean
process_data_truncated: boolean
outputs_truncated: boolean
}
tracing: unknown[]
resultText: string
}
setWorkflowRunningData: (data: WorkflowStoreState['workflowRunningData']) => void
backupDraft?: unknown
setBackupDraft: (data: unknown) => void
setEnvironmentVariables: (data: unknown[]) => void
}
const mocks = vi.hoisted(() => {
const workflowStoreState: WorkflowStoreState = {
setWorkflowRunningData: vi.fn((data) => {
workflowStoreState.workflowRunningData = data
}),
setBackupDraft: vi.fn(),
setEnvironmentVariables: vi.fn(),
}
return {
workflowStoreState,
workflowStoreSetState: vi.fn((partial: Record<string, unknown>) => {
Object.assign(workflowStoreState, partial)
}),
reactFlowStoreState: {
getNodes: vi.fn(() => []),
setNodes: vi.fn(),
edges: [],
},
mockGetViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })),
mockDoSyncWorkflowDraft: vi.fn(),
mockHandleUpdateWorkflowCanvas: vi.fn(),
mockFetchInspectVars: vi.fn(),
mockInvalidateAllLastRun: vi.fn(),
mockInvalidateRunHistory: vi.fn(),
mockSsePost: vi.fn(),
mockStopWorkflowRun: vi.fn(),
runEventHandlers: {
handleWorkflowStarted: vi.fn(),
handleWorkflowFinished: vi.fn(),
handleWorkflowFailed: vi.fn(),
handleWorkflowNodeStarted: vi.fn(),
handleWorkflowNodeFinished: vi.fn(),
handleWorkflowNodeIterationStarted: vi.fn(),
handleWorkflowNodeIterationNext: vi.fn(),
handleWorkflowNodeIterationFinished: vi.fn(),
handleWorkflowNodeLoopStarted: vi.fn(),
handleWorkflowNodeLoopNext: vi.fn(),
handleWorkflowNodeLoopFinished: vi.fn(),
handleWorkflowNodeRetry: vi.fn(),
handleWorkflowAgentLog: vi.fn(),
handleWorkflowTextChunk: vi.fn(),
handleWorkflowTextReplace: vi.fn(),
},
}
})
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => mocks.reactFlowStoreState,
}),
useReactFlow: () => ({
getViewport: mocks.mockGetViewport,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: mocks.mockFetchInspectVars,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({
useWorkflowUpdate: () => ({
handleUpdateWorkflowCanvas: mocks.mockHandleUpdateWorkflowCanvas,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event', () => ({
useWorkflowRunEvent: () => mocks.runEventHandlers,
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => mocks.workflowStoreState,
setState: mocks.workflowStoreSetState,
}),
}))
vi.mock('@/service/base', () => ({
ssePost: mocks.mockSsePost,
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mocks.mockInvalidateAllLastRun,
useInvalidateWorkflowRunHistory: () => mocks.mockInvalidateRunHistory,
}))
vi.mock('@/service/workflow', () => ({
stopWorkflowRun: mocks.mockStopWorkflowRun,
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mocks.mockDoSyncWorkflowDraft,
}),
}))
const createRunningData = (taskId?: string): WorkflowStoreState['workflowRunningData'] => ({
task_id: taskId,
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
})
describe('useSnippetRun', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.workflowStoreState.workflowRunningData = undefined
mocks.workflowStoreState.backupDraft = undefined
})
it('stops a snippet workflow with the provided task id', () => {
mocks.workflowStoreState.workflowRunningData = createRunningData()
const { result } = renderHook(() => useSnippetRun('snippet-1'))
act(() => {
result.current.handleStopRun('task-1')
})
expect(mocks.mockStopWorkflowRun).toHaveBeenCalledWith('/snippets/snippet-1/workflow-runs/tasks/task-1/stop')
expect(mocks.workflowStoreState.workflowRunningData?.result.status).toBe(WorkflowRunningStatus.Stopped)
})
it('does not fall back to the workflow running task id when stop is called without one', () => {
mocks.workflowStoreState.workflowRunningData = createRunningData('task-from-store')
const { result } = renderHook(() => useSnippetRun('snippet-1'))
act(() => {
result.current.handleStopRun('')
})
expect(mocks.mockStopWorkflowRun).not.toHaveBeenCalled()
expect(mocks.workflowStoreState.workflowRunningData?.result.status).toBe(WorkflowRunningStatus.Stopped)
})
it('does not call the stop endpoint when task id is unavailable', () => {
mocks.workflowStoreState.workflowRunningData = createRunningData()
const { result } = renderHook(() => useSnippetRun('snippet-1'))
act(() => {
result.current.handleStopRun('')
})
expect(mocks.mockStopWorkflowRun).not.toHaveBeenCalled()
expect(mocks.workflowStoreState.workflowRunningData?.result.status).toBe(WorkflowRunningStatus.Stopped)
})
})

View File

@ -0,0 +1,131 @@
import type { SnippetInputField } from '@/models/snippet'
import { renderHook } from '@testing-library/react'
import { act } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetStartRun } from '../use-snippet-start-run'
const mockWorkflowStoreGetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: mockWorkflowStoreGetState,
}),
}))
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
}))
const mockSetShowDebugAndPreviewPanel = vi.fn()
const mockSetShowInputsPanel = vi.fn()
const mockSetShowEnvPanel = vi.fn()
const mockSetShowGlobalVariablePanel = vi.fn()
const mockHandleRun = vi.fn()
const inputFields: SnippetInputField[] = [
{
type: PipelineInputVarType.textInput,
label: 'Query',
variable: 'query',
required: true,
},
]
describe('useSnippetStartRun', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: undefined,
showDebugAndPreviewPanel: false,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
setShowInputsPanel: mockSetShowInputsPanel,
setShowEnvPanel: mockSetShowEnvPanel,
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
})
})
it('should open the debug panel and input form when snippet has input fields', () => {
const { result } = renderHook(() => useSnippetStartRun({
handleRun: mockHandleRun,
inputFields,
}))
act(() => {
result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
expect(mockHandleRun).not.toHaveBeenCalled()
})
it('should run immediately when snippet has no input fields', () => {
const { result } = renderHook(() => useSnippetStartRun({
handleRun: mockHandleRun,
inputFields: [],
}))
act(() => {
result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
expect(mockHandleRun).toHaveBeenCalledWith({ inputs: {} })
})
it('should close the panel when debug panel is already open', () => {
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: undefined,
showDebugAndPreviewPanel: true,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
setShowInputsPanel: mockSetShowInputsPanel,
setShowEnvPanel: mockSetShowEnvPanel,
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
})
const { result } = renderHook(() => useSnippetStartRun({
handleRun: mockHandleRun,
inputFields,
}))
act(() => {
result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
})
it('should do nothing when workflow is already running', () => {
mockWorkflowStoreGetState.mockReturnValue({
workflowRunningData: {
result: {
status: WorkflowRunningStatus.Running,
},
},
showDebugAndPreviewPanel: false,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
setShowInputsPanel: mockSetShowInputsPanel,
setShowEnvPanel: mockSetShowEnvPanel,
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
})
const { result } = renderHook(() => useSnippetStartRun({
handleRun: mockHandleRun,
inputFields,
}))
act(() => {
result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
})

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