Compare commits

...
Author SHA1 Message Date
Charles YaoandGitHub 06456afd49 feat: add end user oauth apis (#29683) 2025-12-19 01:30:16 -06:00
zhsama d98e70922d Merge branch 'main' into feat/end-user-oauth 2025-12-17 13:44:46 +08:00
JoelandGitHub 94a5fd3617 chore: tests for webapp run batch (#29767) 2025-12-17 13:36:50 +08:00
JoelGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
5bb1346da8 chore: tests form add annotation (#29770)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-17 13:36:40 +08:00
FFXNGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Jyong
a93eecaeee feat: Add "type" field to PipelineRecommendedPlugin model; Add query param "type" to recommended-plugins api. (#29736)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jyong <76649700+JohnJyong@users.noreply.github.com>
2025-12-17 11:26:08 +08:00
Ryusei HashimotoGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
86131d4bd8 feat: add datasource_parameters handling for API requests (#29757)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-17 10:37:55 +08:00
581b62cf01 feat: add automated tests for pipeline setting (#29478)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
2025-12-17 10:26:58 +08:00
yyhandGitHub 91714ee413 chore(web): add some jest tests (#29754) 2025-12-17 10:21:32 +08:00
yyhandGitHub 232149e63f chore: add tests for config string and dataset card item (#29743) 2025-12-17 10:19:10 +08:00
-LAN-andGitHub 4a1ddea431 ci: show missing lines in coverage report summary (#29717) 2025-12-17 10:18:41 +08:00
GuanMuandGitHub 5539bf8788 fix: add Slovenian and Tunisian Arabic translations across multiple language files (#29759) 2025-12-17 10:18:10 +08:00
wangxiaoleiandGitHub dda7eb03c9 feat: _truncate_json_primitives support file (#29760) 2025-12-17 08:10:43 +09:00
c2f2be6b08 fix: oxlint no unused expressions (#29675)
Co-authored-by: daniel <daniel@example.com>
2025-12-16 18:00:04 +08:00
zyssyz123GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>Joel-LAN-
b7649f61f8 fix: Login secret text transmission (#29659)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2025-12-16 16:55:51 +08:00
ae4a9040df Feat/update notion preview (#29345)
Co-authored-by: twwu <twwu@dify.ai>
2025-12-16 16:43:45 +08:00
JoelandGitHub d2b63df7a1 chore: tests for components in config (#29739) 2025-12-16 16:39:04 +08:00
-LAN-GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
0749e6e090 test: Stabilize sharded Redis broadcast multi-subscriber test (#29733)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-12-16 16:35:55 +08:00
yyhandGitHub 4589157963 test: Add comprehensive Jest test for AppCard component (#29667) 2025-12-16 15:44:51 +08:00
-LAN-andGitHub 37d4dbeb96 feat: Remove TLS 1.1 from default NGINX protocols (#29728) 2025-12-16 15:39:42 +08:00
yyhandGitHub c036a12999 test: add comprehensive unit tests for APIKeyInfoPanel component (#29719) 2025-12-16 15:07:30 +08:00
JoelandGitHub 47cd94ec3e chore: tests for billings (#29720) 2025-12-16 15:06:53 +08:00
-LAN-andGitHub e5cf0d0bf6 chore: Disable Swagger UI by default in docker samples (#29723) 2025-12-16 15:01:51 +08:00
Charles Yao 60c2e209b6 Update with main branch 2025-12-14 13:04:08 -08:00
zhsama 6e743a495f Merge branch 'main' into feat/end-user-oauth 2025-12-10 15:39:36 +08:00
zhsama 2ea07cd8f8 Merge branch 'main' into feat/end-user-oauth
# Conflicts:
#	web/app/components/app/configuration/config/agent/agent-tools/index.tsx
2025-12-09 17:41:01 +08:00
zhsama 2ea8e11882 Merge branch 'main' into feat/end-user-oauth 2025-12-08 16:49:57 +08:00
zhsama ff3e3338cb Merge branch 'main' into feat/end-user-oauth 2025-12-05 02:58:50 +08:00
zhsama eb0e63c336 Merge remote-tracking branch 'origin/main' into feat/end-user-oauth
# Conflicts:
#	web/app/components/app/configuration/config/agent/agent-tools/index.tsx
2025-12-04 16:16:04 +08:00
zhsama 16dc744908 Merge remote-tracking branch 'origin/main' into feat/end-user-oauth 2025-12-03 18:44:20 +08:00
zhsama f3258bab9e Merge remote-tracking branch 'origin/main' into feat/end-user-oauth 2025-12-03 16:36:24 +08:00
zhsama 472f8a8a2b Merge branch 'origin-main' into feat/end-user-oauth 2025-12-02 13:30:53 +08:00
zhsama c8807d3f89 feat: add service connection panel and translations for service connection messages 2025-12-01 15:23:46 +08:00
zhsama 5e6053b367 Merge branch 'origin-main' into feat/end-user-oauth 2025-12-01 12:26:01 +08:00
zhsama f5e36a8a2b Merge branch 'origin-main' into feat/end-user-oauth 2025-12-01 02:25:44 +08:00
zhsama 660efda7f5 feat: enhance AgentTools component with expandable provider sections and improved UI interactions 2025-11-28 18:30:55 +08:00
zhsama 5e96e4dda6 feat: introduce CredentialConfigHeader and EndUserCredentialSection components for enhanced plugin authentication UI 2025-11-28 18:12:11 +08:00
zhsama a31eea8389 feat: refactor AgentTools component to group tools by provider and enhance UI interactions 2025-11-28 16:42:52 +08:00
zhsama 6aa0c9e5cc Merge branch 'refs/heads/origin-main' into feat/end-user-oauth 2025-11-28 14:19:42 +08:00
zhsama 8fe47a3c04 Merge remote-tracking branch 'origin/main' into feat/end-user-oauth 2025-11-27 17:45:28 +08:00
zhsama cb4670cd68 feat: enhance agent tools with end user credential support 2025-11-27 17:45:05 +08:00
zhsama 1400b9c6e2 refactor(plugin-auth): enhance end-user type selection UI and remove unused state 2025-11-27 17:45:05 +08:00
zhsama b7d9483bc2 feat(end-user): implement end-user credential management in plugin-auth component
- Added support for end-user credentials with options for OAuth and API Key.
- Introduced new props in PluginAuth for managing end-user credential types and their states.
- Updated workflow types to include end-user credential fields.
- Enhanced UI to allow users to select and manage end-user credentials.
- Added translations for new UI elements related to end-user credentials.
2025-11-27 17:45:05 +08:00
Charles YaoandGitHub 9df9db3a8f feat: use enums instead of literals (#28733) 2025-11-26 13:31:12 -06:00
Charles Yao bf4f9b04bf Merge branch 'feat/end-user-oauth' into feat/end-user-add-dsl-field 2025-11-26 13:29:54 -06:00
Charles Yao a659cbf71d ECO-184: use enums instead of literals 2025-11-26 13:28:23 -06:00
Charles YaoandGitHub 72514904ea Revert "feat: add dsl field for end user oauth" (#28732) 2025-11-26 13:24:07 -06:00
Charles YaoandGitHub f3fbd4f90e Revert "feat: add dsl field for end user oauth" 2025-11-26 13:23:58 -06:00
Charles YaoandGitHub 5947cc2bab feat: add dsl field for end user oauth (#28687) 2025-11-26 13:23:27 -06:00
Charles Yao 31109735a3 fix: add dependency 2025-11-26 01:31:10 -06:00
Charles Yao df025ac400 Merge branch 'feat/end-user-oauth' into feat/end-user-add-dsl-field 2025-11-26 01:30:18 -06:00
Charles YaoandGitHub f4a3e290cb fix: change end user table to uuidv7 (#28689) 2025-11-26 01:29:15 -06:00
Charles Yao 66e85ca16c fix: change end user table to user uuid v7 2025-11-26 01:27:07 -06:00
Charles Yao ac8136cca4 ECO-184: add new dsl field to tool node, ready for enduser auth 2025-11-26 01:13:57 -06:00
Charles Yao 8c111de6a9 ECO-184: add new dsl field to tool node, ready for enduser auth 2025-11-26 01:13:35 -06:00
Charles YaoandGitHub d784a0432c fix: migration file conflits (#28665) 2025-11-25 21:27:26 -06:00
Charles Yao 1636b228db ECO-183: fix migration file conflits 2025-11-25 17:24:49 -06:00
zhsama e36d460d67 Merge branch 'refs/heads/origin-main' into feat/end-user-oauth 2025-11-25 14:38:40 +08:00
Charles Yao 68f195c5e9 Merge branch 'main' into feat/end-user-oauth 2025-11-24 21:29:42 -06:00
Charles Yao 200e801182 ECO-171: remove unused index statement from migration script 2025-11-24 11:12:46 -06:00
Charles Yao 5bcf5be874 ECO-171: remove unused index statement from migration script 2025-11-24 11:11:15 -06:00
Charles Yao b60ba0b192 Merge branch 'main' into feat/end-user-oauth 2025-11-24 11:04:39 -06:00
Charles YaoandGitHub 26dc4d43bf feat: add new table of end user oauth (#28351) 2025-11-24 01:28:40 -06:00
Charles Yao 0e355079fa ECO-171: removing duplicated index 2025-11-24 01:26:54 -06:00
Charles Yao b51bf33b1e ECO-171: adding comments for expire_at as clarification 2025-11-24 00:02:37 -06:00
Charles Yao 30594978f9 ECO-171: update to competible with mysql 2025-11-23 23:43:59 -06:00
-LAN-GitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2e2d7a5345 Apply suggestion from @gemini-code-assist[bot]
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-24 13:23:31 +08:00
Charles Yao cf2457a03c ECO-171: update credential cast 2025-11-23 23:17:24 -06:00
Charles Yao 4590dab046 ECO-171: update migration script with sql alchemy auto naming 2025-11-23 23:10:24 -06:00
Charles Yao f2df8af4c8 ECO-171: update to use server default time stamp 2025-11-23 23:00:10 -06:00
Charles Yao 5b93ed3fcd ECO-171: update unique constraints 2025-11-23 21:29:23 -06:00
Charles Yao 5fd83e9425 ECO-171: remove postgresql specific features 2025-11-23 20:41:13 -06:00
Charles Yao 8f6937eea6 ECO-171: resolving comments 2025-11-21 17:05:16 -06:00
Charles Yao 74fc026fe0 merge main 2025-11-21 09:12:46 -06:00
Charles Yao bbd466eaba ECO-171: fix comments 2025-11-20 23:08:00 -06:00
Charles Yao 153609b968 ECO-171: remove server defaults 2025-11-20 22:57:37 -06:00
Charles YaoGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
6cd7ab4719 Update api/models/tools.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-20 21:18:25 -06:00
Charles YaoGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
39de9e7248 Update api/models/tools.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-20 21:18:14 -06:00
Charles Yao 5e93a61865 ECO-171: edited migration script 2025-11-20 21:11:12 -06:00
Charles Yao 76069b5d6d ECO-171: add migration script 2025-11-20 21:11:12 -06:00
Asuka MinatoGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
adf673d031 Apply suggestion from @gemini-code-assist[bot]
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-19 17:18:50 +09:00
Harry 2fa6684c4d Merge remote-tracking branch 'origin/main' into feat/trigger 2025-11-13 10:59:57 +08:00
Yeuoly 87439b8fec Merge main 2025-11-12 17:49:17 +08:00
YeuolyandGitHub 08034532f6 Update trigger.py 2025-11-12 17:42:55 +08:00
MariesandGitHub c5f47ebccd Merge branch 'main' into feat/trigger 2025-11-12 17:14:35 +08:00
Yeuoly 6744306818 Merge branch 'main' into feat/trigger 2025-11-12 17:04:31 +08:00
lyzno1andGitHub 3ff14ccc89 Merge branch 'main' into feat/trigger 2025-11-12 16:49:08 +08:00
9c30f16e4b Update api/tasks/trigger_processing_tasks.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 16:42:12 +08:00
Harry c31933c163 chore: downgrade dify-api version to 1.9.2 in uv.lock 2025-11-12 16:20:22 +08:00
Harry 574eb1a10a chore: downgrade version to 1.9.2 in pyproject.toml(ready for merge) 2025-11-12 16:18:19 +08:00
Harry e6ac783fc3 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-11-12 16:01:19 +08:00
Harry 689a75f44a Merge remote-tracking branch 'origin/main' into feat/trigger 2025-11-12 15:07:05 +08:00
hjlarry a25f469bde fix(trigger): Prevent marketplace tool downloads from retriggering on tab change 2025-11-12 13:40:56 +08:00
lyzno1 044ee7ef54 feat(workflow): always render featured tools section 2025-11-12 10:37:39 +08:00
hjlarry 2725f28fa8 fix(trigger): incorrect behavior when node uninstalled on the canvas 2025-11-12 10:12:49 +08:00
Charles Yao c493e08df1 add new table of end user oauth 2025-11-11 20:05:11 -06:00
zhsama 36ad784251 feat(workflow-header): add conditional logic to disable publish and refresh actions based on workflow node presence 2025-11-11 20:08:09 +08:00
lyzno1 0a39e5c092 Fix modal query sync for settings & pricing 2025-11-11 19:32:48 +08:00
lyzno1andGitHub 9169a5e35b Merge branch 'main' into feat/trigger 2025-11-11 18:05:23 +08:00
zhsama bfdcb79e19 feat(card-view): enhance CardView to conditionally render AppCards based on trigger node presence in workflow 2025-11-11 16:54:06 +08:00
zhsama c37cce000f refactor: replace TRIGGER_NODE_TYPES with isTriggerNode utility for improved node type checks across workflow components 2025-11-11 16:54:06 +08:00
autofix-ci[bot]andGitHub 6d3fb9b769 [autofix.ci] apply automated fixes 2025-11-11 08:37:13 +00:00
Harry c04913ecf8 refactor(tests): remove redundant graph validation tests from WorkflowService unit tests
- Deleted tests for graph initialization and error propagation that were deemed unnecessary.
- Cleaned up the test suite to improve maintainability and focus on essential validation scenarios.
2025-11-11 16:35:19 +08:00
lyzno1 3a84a64c32 refactor: unify account setting tab constants and tighten modal types 2025-11-11 16:16:41 +08:00
lyzno1 b344d4add1 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-11-11 16:16:22 +08:00
lyzno1 405a4ec9f8 feat: add URL parameter support for settings modal using action=showSettings 2025-11-11 16:16:06 +08:00
Harry 8bb11a588c fix(tests): fix end node missing ouputs 2025-11-11 16:14:00 +08:00
Harry 44f451bd7d refactor(api): improve graph validation logic in WorkflowService
- Updated the validate_graph_structure method to handle empty graph cases gracefully.
- Introduced a variable for workflow_id to ensure consistent handling of unknown workflow IDs.
- Enhanced code readability and maintainability by refining the method's structure.
2025-11-11 16:14:00 +08:00
zhsama 35d914e755 Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-11-11 15:22:39 +08:00
zhsama 9c37f8c1cb feat(trigger): add support for trigger nodes and user input node management in workflow components 2025-11-11 15:21:04 +08:00
lyzno1 9de0e3c3a7 fix: add missing TimePicker type definitions for notClearable, triggerFullWidth, showTimezone and placement 2025-11-11 15:18:14 +08:00
lyzno1 707c94f86e feat: add URL parameter support for pricing modal using action=showPricing 2025-11-11 15:15:40 +08:00
lyzno1 81afd087f6 feat: add trigger events, workflow execution, and start nodes to billing plan features
- Add three new feature items to cloud plan list:
  - Trigger Events (varies by plan: 3K for sandbox, 20K/month for pro, unlimited for team)
  - Workflow Execution (standard/faster/priority based on plan)
  - Start Nodes (limited to 2 for sandbox, unlimited for pro/team)
- Add i18n translations for en-US and zh-Hans
- Position new items below document processing priority and above divider
2025-11-11 15:00:25 +08:00
lyzno1 0f952f328f feat: add api rate limit and trigger events billing card 2025-11-11 15:00:25 +08:00
autofix-ci[bot]andGitHub 50619fba0a [autofix.ci] apply automated fixes 2025-11-11 06:54:03 +00:00
Harry aad31bb703 feat(api): enhance workflow validation and structure checks
- Added a new validation class to ensure that trigger nodes do not coexist with UserInput (start) nodes in the workflow graph.
- Implemented a method in WorkflowService to validate the graph structure before persisting workflows, leveraging the new validation logic.
- Updated unit tests to cover the new validation scenarios and ensure proper error propagation.
2025-11-11 14:52:13 +08:00
hjlarry 7484a020e1 fix(trigger): subscription schema use bool field cause pydantic error 2025-11-11 14:05:11 +08:00
autofix-ci[bot]andGitHub 186828c13a [autofix.ci] apply automated fixes 2025-11-11 04:47:22 +00:00
Harry 203fb95391 chore(api): update dependencies and default queue configurations
- Updated `revision` in `uv.lock` from 3 to 2.
- Added `croniter` package version 6.0.0 with dependencies in `uv.lock`.
- Updated `dify-api` version to 1.10.0rc1 and added `croniter` as a dependency.
- Modified default queue names in `entrypoint.sh` for both CLOUD and SELF_HOSTED editions to include `priority_dataset`.
2025-11-11 12:45:02 +08:00
Harry a94e650ffd Merge remote-tracking branch 'origin/main' into feat/trigger
# Conflicts:
#	api/docker/entrypoint.sh
#	api/uv.lock
#	dev/start-worker
#	docker/.env.example
#	docker/docker-compose.yaml
#	web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx
#	web/app/components/base/date-and-time-picker/date-picker/index.tsx
#	web/app/components/base/date-and-time-picker/types.ts
2025-11-11 12:42:01 +08:00
hjlarry 00fdd06179 fix(trigger): subscription schema config not display field description 2025-11-10 13:43:56 +08:00
lyzno1 62fbc90389 refactor: update free plan rate limit description in pricing modal 2025-11-10 10:48:32 +08:00
hjlarry f19a21da11 fix prepare userinput logic 2025-11-10 09:59:20 +08:00
zhsama 7401792063 feat(last-run): add handling for Listening status in run result calculation 2025-11-07 16:39:30 +08:00
zhsama 79e46c8a81 feat(step-run): add resolvedStatus calculation for improved run result handling 2025-11-07 15:19:40 +08:00
zhsama 7658c92cf9 feat(trigger): improve trigger node in useOneStepRun for getting system variables 2025-11-07 13:17:57 +08:00
zhsama 85a5c78b80 feat: enhance workflow log components with detailed trigger metadata and type safety 2025-11-06 16:16:34 +08:00
lyzno1 9d7b47c784 trigger CI
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
2025-11-06 15:41:54 +08:00
lyzno1andGitHub e4c6ed9c60 Merge branch 'main' into feat/trigger 2025-11-06 15:25:19 +08:00
zhsama fcfade4778 fix: update checkValidFns type to Partial and ensure error message fallback in useOneStepRun 2025-11-06 14:51:32 +08:00
zhsama 000e8bd12b fix: improve error handling in useOneStepRun and useWorkflowRun to provide structured error messages 2025-11-06 14:05:45 +08:00
Harry ed8da2c760 fix: return structured error response for PluginInvokeError in workflow trigger APIs 2025-11-06 12:41:17 +08:00
zhsama fb6dc14e9b refactor:simplify syncWorkflowDraft parameters 2025-11-06 12:19:09 +08:00
hjlarry 77e6e98234 fix CI 2025-11-06 09:46:43 +08:00
lyzno1 fb3699ec5e fix(web): resolve type checks in app operations 2025-11-05 20:08:59 +08:00
zhsama 4601be8b67 fix: update hasConnectedUserInput function to use specific types for nodes and edges 2025-11-05 18:12:57 +08:00
zhsama cc4d4adfb9 feat: enhance workflow draft processing by adding hydration and sanitization functions 2025-11-05 16:54:49 +08:00
lyzno1 6a08623949 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-11-05 16:50:53 +08:00
Harry f0127ffc9a feat: enhance trigger metadata structure by adding type field and updating trigger_metadata handling 2025-11-05 16:49:29 +08:00
Harry f1e513830c feat: implement logging for failed trigger invocations in workflow processing 2025-11-05 16:49:29 +08:00
Harry 7de533a643 feat: add start-web script to automate web project setup and execution 2025-11-05 16:49:29 +08:00
autofix-ci[bot]andGitHub 052127c473 [autofix.ci] apply automated fixes 2025-11-05 05:07:06 +00:00
Harry 7a4be5c0d2 Update package metadata in uv.lock: increment revision to 2, add upload times for sdist and wheels, and update aiohttp sdist version to 3.13.2. 2025-11-05 13:00:28 +08:00
Harry a6208feed8 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-11-05 12:59:25 +08:00
lyzno1andGitHub c8f55549d7 Remove global pnpm installation from script 2025-11-05 10:27:45 +08:00
lyzno1 132a86dcb3 fix: output node vars check 2025-11-05 10:15:57 +08:00
zhsama 9f59baed10 fix(urlValidation): remove specific check for Dify cloud trigger debug URLs 2025-11-04 18:34:41 +08:00
zhsama ce56286329 feat(validation): implement isPrivateOrLocalAddress utility and integrate into webhook components for improved URL validation 2025-11-04 18:32:19 +08:00
Harry 6e76f2aff2 fix(api): update TriggerInvokeEventResponse to use Field for default value of cancelled 2025-11-04 18:15:13 +08:00
Harry 49edd58722 fix(trigger): enhance credential handling by decrypting and masking subscription properties and parameters 2025-11-04 18:15:13 +08:00
zhsama 6a28aee13e Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-11-04 16:51:37 +08:00
WTW0313 79c70d09c9 feat(marketplace): introduce IS_MARKETPLACE flag and update X-Dify-Version header logic 2025-11-04 16:09:50 +08:00
zhsama b9bb97887b fix(workflow): handle node inspection variable deletion when not fetched 2025-11-04 15:25:15 +08:00
lyzno1 7df6d9f1aa Merge remote-tracking branch 'origin/main' into feat/trigger 2025-11-04 09:56:02 +08:00
lyzno1 587f83bc34 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-11-04 09:55:43 +08:00
lyzno1 d81b2e6820 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-11-04 09:52:08 +08:00
lyzno1 8315e0c74b Merge remote-tracking branch 'origin/main' into feat/trigger 2025-11-04 09:44:13 +08:00
hjlarry 3cc6690356 fix(trigger): global var dialogue_count and timestamp auto converted to string 2025-11-03 17:12:58 +08:00
hjlarry 6507263b28 fix(trigger): timestamp should be number type 2025-11-03 15:40:33 +08:00
hjlarry 8fa0bb48df fix CI 2025-11-03 14:57:01 +08:00
hjlarry 637a675681 fix(trigger): workflow checklist not work for knowledge pipeline 2025-11-03 09:42:33 +08:00
lyzno1 085ada86e6 chore: trigger ci
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
2025-10-31 20:15:14 +08:00
lyzno1 f59d430219 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-31 20:01:16 +08:00
lyzno1 c415e5b893 Merge branch 'feat/trigger' of https://github.com/langgenius/dify into feat/trigger 2025-10-31 20:00:51 +08:00
lyzno1 67b6b3612c fix: trigger docs link 2025-10-31 20:00:39 +08:00
Yeuoly 229b0e190f bump version 2025-10-30 23:21:56 +08:00
autofix-ci[bot]andGitHub 09d412cf2a [autofix.ci] apply automated fixes 2025-10-30 15:18:20 +00:00
Harry 2842cbf1e1 refactor: update error handling to use BadRequest for plugin invocation errors 2025-10-30 23:16:14 +08:00
Harry e2543bcf30 refactor: remove unused error imports in TriggerManager 2025-10-30 23:05:08 +08:00
Harry 3f75aa6848 refactor: simplify error handling in TriggerManager 2025-10-30 23:04:54 +08:00
Yeuoly 57719f3ce9 fix: docker env 2025-10-30 22:21:14 +08:00
Harry 45677ac57c bump plugin daemon image version to 0.4.0-local 2025-10-30 22:19:24 +08:00
Yeuoly 4eacbf37ff bump docker compose daemon version 2025-10-30 21:22:09 +08:00
Yeuoly ef256ac276 bump version 2025-10-30 21:20:58 +08:00
hjlarry 2733e04039 fix CI 2025-10-30 20:28:47 +08:00
hjlarry e49ec82258 fix CI 2025-10-30 20:25:35 +08:00
hjlarry cf301eb1d9 fix CI 2025-10-30 20:23:22 +08:00
hjlarry 98b9ba2b2e fix CI 2025-10-30 20:22:01 +08:00
hjlarry 2126c64468 fix CI 2025-10-30 20:17:11 +08:00
hjlarry 271a1b4f98 fix CI 2025-10-30 20:10:49 +08:00
hjlarry 9be3c62c04 fix CI 2025-10-30 20:08:16 +08:00
lyzno1 04bfa235a9 fix: test 2025-10-30 19:49:08 +08:00
lyzno1 3b37ae1b4e fix: dotenv lint 2025-10-30 19:37:45 +08:00
Yeuoly c1cb93cd26 fix 2025-10-30 18:55:08 +08:00
Yeuoly 75fa161c46 apply fix 2025-10-30 18:49:06 +08:00
Yeuoly d6d82cff33 apply linter 2025-10-30 18:48:16 +08:00
lyzno1 5c266fecf9 fix: types 2025-10-30 18:36:08 +08:00
Yeuoly 7244978b24 fix 2025-10-30 18:33:46 +08:00
Yeuoly 623021dcff cleanup 2025-10-30 18:32:05 +08:00
zhsama 5af165fce9 fix: change timestamp type to integer 2025-10-30 18:24:30 +08:00
lyzno1 9503fafc53 fix 2025-10-30 18:15:03 +08:00
lyzno1 99fac21bdb fix: type 2025-10-30 18:11:52 +08:00
yessenia bc95678c5e fix(trigger): appmode type 2025-10-30 18:03:12 +08:00
lyzno1 3f34f38635 fix: types 2025-10-30 18:02:58 +08:00
lyzno1 30f771369b fix: types 2025-10-30 18:01:12 +08:00
hjlarry 20bd059a6c fix CI 2025-10-30 17:58:59 +08:00
lyzno1 f5eb406394 fix: types 2025-10-30 17:58:31 +08:00
hjlarry cbebac1d45 fix: webhook container tests 2025-10-30 17:46:59 +08:00
lyzno1 030da43ae3 fix: type 2025-10-30 17:44:43 +08:00
yessenia b7f1394403 fix(trigger): add default icon 2025-10-30 17:42:10 +08:00
lyzno1 ceb6a09387 exclude test file in tsc 2025-10-30 17:39:02 +08:00
Yeuoly 14ad800967 Revert "rm type check"
This reverts commit 34d1f86f76.
2025-10-30 17:34:45 +08:00
lyzno1 34d1f86f76 rm type check 2025-10-30 17:28:38 +08:00
lyzno1 b9b9f8eae3 fix: type 2025-10-30 17:24:36 +08:00
lyzno1 0de8596afe Merge branch 'feat/trigger' of https://github.com/langgenius/dify into feat/trigger 2025-10-30 17:14:11 +08:00
autofix-ci[bot]andGitHub 4dbd26ff66 [autofix.ci] apply automated fixes 2025-10-30 09:14:08 +00:00
Yeuoly d018ef9033 apply autofix to autofix CI 2025-10-30 17:11:49 +08:00
lyzno1 979c985804 Merge branch 'feat/trigger' of https://github.com/langgenius/dify into feat/trigger 2025-10-30 17:11:08 +08:00
Yeuoly 291e9a3aee fix: ruff 2025-10-30 17:10:21 +08:00
Yeuoly 5861ca773e fix: mapping to dict 2025-10-30 17:09:40 +08:00
autofix-ci[bot]andGitHub eb3b5f751a [autofix.ci] apply automated fixes 2025-10-30 09:08:11 +00:00
lyzno1 9bbfbf1c5f Merge branch 'feat/trigger' of https://github.com/langgenius/dify into feat/trigger 2025-10-30 17:06:27 +08:00
zhsama 8cbd124b80 delete: remove cron-parser unit tests 2025-10-30 17:05:41 +08:00
lyzno1 d137d0eed0 rm test 2025-10-30 17:05:27 +08:00
zhsama 58c5db3b00 Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-10-30 17:05:14 +08:00
lyzno1 8750796f9f Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-30 16:59:56 +08:00
lyzno1 7d4bb45f94 fix(workflow): align plugin lock overlay with install availability 2025-10-30 16:55:07 +08:00
zhsama db744444f2 fix(CI): fix CI errors 2025-10-30 16:54:17 +08:00
Yeuoly b25d379ef4 cleanup 2025-10-30 16:33:39 +08:00
zhsama e1e95f7ccd fix(CI): fix CI errors 2025-10-30 16:33:04 +08:00
Yeuoly edd50420ec apply test fix 2025-10-30 16:24:43 +08:00
Yeuoly 9af8fe085b fix: apply docker template 2025-10-30 16:20:26 +08:00
zhsama ed6bb121bb fix: update types 2025-10-30 16:16:55 +08:00
zhsama 4635b99153 Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-10-30 16:16:21 +08:00
zhsama 282fde9a04 feat(next.config): add console log removal configuration for production 2025-10-30 16:16:11 +08:00
Yeuoly e9078eedbd fix: Variable e is not accessed (reportUnusedVariable) 2025-10-30 16:14:13 +08:00
YeuolyGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
501698d844 Potential fix for code scanning alert no. 243: Information exposure through an exception
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-30 16:11:32 +08:00
Yeuoly dd089b1b21 fix: coding style 2025-10-30 16:10:54 +08:00
Yeuoly 6260a1a28c fix: cycle imports 2025-10-30 16:09:39 +08:00
YeuolyGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
8bc5035624 Potential fix for code scanning alert no. 211: Information exposure through an exception
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-30 16:00:57 +08:00
YeuolyGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2dbfd9ea5a Potential fix for code scanning alert no. 241: Use of a broken or weak cryptographic hashing algorithm on sensitive data
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-30 16:00:44 +08:00
YeuolyGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
08e61d76d6 Potential fix for code scanning alert no. 244: Information exposure through an exception
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-30 16:00:24 +08:00
YeuolyGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
447127cee4 Update api/controllers/console/app/workflow.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-10-30 15:59:37 +08:00
YeuolyGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
49ebbd05b5 Update api/controllers/console/app/workflow.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-10-30 15:58:58 +08:00
YeuolyGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
defea962f6 Update api/controllers/console/workspace/trigger_providers.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-10-30 15:58:41 +08:00
Yeuoly b3866288e0 fix: docker compose 2025-10-30 15:54:25 +08:00
lyzno1 bed2ce69bb Merge branch 'feat/trigger' of https://github.com/langgenius/dify into feat/trigger 2025-10-30 15:35:39 +08:00
lyzno1 4d37d61851 feat: dim node 2025-10-30 15:34:50 +08:00
lyzno1 8a48db6d0d Improve workflow tool install flow 2025-10-30 15:34:49 +08:00
lyzno1 ff0f645e54 Fix plugin install detection for tool nodes 2025-10-30 15:34:49 +08:00
lyzno1 6e0765fbaf feat: add install check for tools, triggers and datasources 2025-10-30 15:34:49 +08:00
yessenia 1d03e0e9fc fix(trigger): hide input params when no subscription 2025-10-30 15:28:34 +08:00
Yeuoly cac60a25bb cleanup: migrations 2025-10-30 15:27:02 +08:00
Yeuoly 57c65ec625 fix: typing 2025-10-30 14:58:30 +08:00
Yeuoly ffc3c61d00 merge workflow pasuing 2025-10-30 14:54:14 +08:00
Yeuoly aa3b16a136 fix: migrations 2025-10-30 14:45:26 +08:00
Yeuoly 6e0b408dd5 Merge branch 'main' into feat/trigger 2025-10-30 14:43:27 +08:00
lyzno1 be9eeff6c2 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-30 12:14:47 +08:00
hjlarry ca9d92b1e5 fix(variable): when open history mode not close global var panel 2025-10-30 09:53:00 +08:00
hjlarry 0607db41e5 fix(variable): draft run workflow cause global var panel misalign 2025-10-30 09:02:38 +08:00
hjlarry 48b1829b14 chore: improve toggle env/conversation/global var panel 2025-10-29 22:08:04 +08:00
hjlarry 6767a8f72c chore: i18n for system var 2025-10-29 21:10:26 +08:00
Harry 1e477af05f feat(trigger): add system variables to webhook node outputs
Enhanced the TriggerWebhookNode to include system variables as outputs. This change allows for better accessibility of system variables during node execution, improving the overall functionality of the webhook trigger process. A TODO comment has been added to address future improvements for direct access to system variables.
2025-10-29 18:15:36 +08:00
Harry 9b5e5f0f50 refactor(api): replace dict type hints with Mapping for improved type safety
Updated type hints in several services to use Mapping instead of dict for better compatibility with various dictionary-like objects. Adjusted credential handling to ensure consistent encryption and decryption processes across ToolManager, DatasourceProviderService, ApiToolManageService, BuiltinToolManageService, and MCPToolManageService. This change enhances code clarity and adheres to strong typing practices.
2025-10-29 18:10:38 +08:00
Harry fb12f31df2 feat(trigger): system variables for trigger nodes
Added a timestamp field to the SystemVariable model and updated the WorkflowAppRunner to include the current timestamp during execution. Enhanced node type checks to recognize trigger nodes in various services, ensuring proper handling of system variables and node outputs in TriggerEventNode and TriggerScheduleNode. This improves the overall workflow execution context and maintains consistency across node types.
2025-10-29 18:10:38 +08:00
yessenia db2c6678e4 fix(trigger): show subscription url & add readme in trigger plugin node 2025-10-29 16:16:29 +08:00
zhsama bc3421add8 refactor(variable): update global variable names and types for consistency 2025-10-29 15:53:37 +08:00
zhsama 61d8809a0f fix(workflow): enhance validation before running workflows by integrating warning notifications 2025-10-29 15:53:13 +08:00
lyzno1 d37cc9f9c8 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-29 15:16:28 +08:00
lyzno1 0db082f6d0 feat(workflow): persist RAG recommendation panel collapse state 2025-10-29 15:10:45 +08:00
lyzno1 c94dc52310 fix: remove duplicate RAG tool heading and fix select callback type 2025-10-29 14:57:38 +08:00
hjlarry bebcbfd80e chore: improve delete app related tables 2025-10-29 14:29:59 +08:00
Harry dfc5e3609d refactor(trigger): streamline OAuth client existence check
Replaced the method for checking the existence of a system OAuth client with a new dedicated method `is_oauth_system_client_exists` in the TriggerProviderService. This improves code clarity and encapsulates the logic for verifying the presence of a system-level OAuth client. Updated the TriggerOAuthClientManageApi to utilize the new method for better readability.
2025-10-29 14:22:56 +08:00
lyzno1 fa5765ae82 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-29 12:56:31 +08:00
lyzno1 852d851996 fix(workflow): add empty array validation for required checklist fields in trigger plugin
The checkValid function was not properly validating required checklist fields when they had empty array values. This caused required fields to pass validation even when no options were selected.

Added array length check to the constant type validation to ensure required checklist fields must have at least one selected option.
2025-10-29 12:36:43 +08:00
hjlarry 0b599b44b0 chore: when delete app also delete related trigger tables 2025-10-29 12:15:34 +08:00
lyzno1 f06dc3ef90 fix: localize workflow block search filters 2025-10-29 11:55:30 +08:00
lyzno1 f9df61e648 feat: add inline code copy styling for variable inspect webhook url 2025-10-29 10:14:50 +08:00
lyzno1 6e76e02dba fix: trigger plugin help link 2025-10-29 09:35:45 +08:00
zhsama dc24450e29 feat(workflow): add webhook debug URL display in variable inspection 2025-10-29 04:38:27 +08:00
zhsama 8bcecce627 feat(workflow): add toast notifications for warning nodes during execution 2025-10-29 01:40:27 +08:00
zhsama 66cb963df3 feat(workflow): enhance validation by integrating warning nodes into last run checks. 2025-10-29 01:28:31 +08:00
Harry 5c95c77604 refactor(trigger): streamline workflow argument handling in DraftWorkflowTriggerNodeApi
- Simplified retrieval of workflow arguments by directly accessing event.workflow_args.
- Removed unnecessary conditional checks for user inputs, ensuring cleaner code.
- Enhanced TriggerEventNode to use deepcopy for user inputs to prevent unintended mutations.
2025-10-29 01:04:37 +08:00
zhsama a264a609db feat(workflow): integrate workflow run validation before execution 2025-10-29 00:36:48 +08:00
zhsama 3a876fd437 Merge branch 'main' into feat/trigger 2025-10-29 00:08:50 +08:00
zhsama 13bc68a646 feat(trigger): enhance runScheduleSingleRun to handle API response 2025-10-29 00:07:08 +08:00
Harry b41538d8c7 feat(trigger): reinforcement schedule trigger debugging with cron calculation
- Implemented a caching mechanism for schedule trigger debug events using Redis to optimize performance.
- Added methods to create and manage schedule debug runtime configurations, including cron expression handling.
- Updated the ScheduleTriggerDebugEventPoller to utilize the new caching and event creation logic.
- Removed the deprecated build_schedule_pool_key function from event handling.
2025-10-28 23:34:08 +08:00
zhsama 720480d05e chore(tests): remove deprecated test files for schedule and webhook triggers 2025-10-28 22:42:29 +08:00
yessenia 71b1af69c5 feat(trigger): request condition param 2025-10-28 22:35:03 +08:00
Harry 18fd79fbe6 feat(trigger): add event_name to PluginTriggerMetadata for enhanced trigger handling
- Introduced event_name attribute in PluginTriggerMetadata to improve metadata clarity.
- Updated dispatch_triggered_workflow function to include event_name when dispatching triggered workflows.
2025-10-28 18:32:06 +08:00
Harry c16421df27 refactor: improve trigger metadata handling and streamline workflow service
- Updated ScheduleTriggerDebugEventPoller to include an empty files list in workflow_args.
- Enhanced WorkflowAppService to handle trigger metadata more effectively, including a new method for processing metadata and removing the deprecated _safe_json_loads function.
- Adjusted PluginTriggerMetadata to use icon_filename and icon_dark_filename for better clarity.
- Simplified async workflow task parameters by changing triggered_from to trigger_from for consistency.
2025-10-28 17:50:06 +08:00
Harry 0d686fc6ae refactor: streamline trigger event node metadata handling and update async workflow service for JSON serialization
- Removed unnecessary input data from the TriggerEventNode's metadata.
- Updated AsyncWorkflowService to use model_dump_json() for trigger metadata serialization.
- Added a comment in WorkflowAppService to address the large size of the workflow_app_log table and the use of an additional details field.
2025-10-28 17:50:06 +08:00
yessenia db352c0a18 Merge branch 'main' into feat/trigger 2025-10-28 17:11:15 +08:00
yessenia bf7b18d442 feat(trigger): dynamic options opt 2025-10-28 16:20:01 +08:00
lyzno1 7d56ca5294 fix: add time-picker placement prop 2025-10-28 15:49:10 +08:00
zhsama 0b1015e221 feat(workflow): enhance variable inspector to support schedule trigger events with next execution time display 2025-10-28 14:12:20 +08:00
lyzno1 96f0d648fa feat: invalidate trigger plugin queries after marketplace installs 2025-10-28 11:31:02 +08:00
lyzno1 c4996f9563 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-28 11:28:06 +08:00
yessenia 850c5fec32 fix(trigger): invalid subscription 2025-10-27 21:27:08 +08:00
zhsama b1f79c34d1 fix(workflow): add support for schedule triggers in workflow run hook 2025-10-27 20:52:44 +08:00
yessenia 96a461646e fix(trigger): readme portal zindex 2025-10-27 20:05:02 +08:00
zhsama 5df94fd866 fix(workflow): enhance node-run to include schedule triggers 2025-10-27 19:44:26 +08:00
lyzno1 e074ba84d1 fix(workflow): avoid nested buttons in subscription selector to stop hydration warning 2025-10-27 17:23:58 +08:00
lyzno1 1335be8d60 Revert "feat: propagate trigger metadata for plugin icons across UI"
This reverts commit 3bd62f3fdf.
2025-10-27 17:06:40 +08:00
lyzno1 c79d75b32d Revert "fix: display plugin trigger labels in logs using i18n metadata"
This reverts commit 651cc81cfe.
2025-10-27 17:06:35 +08:00
lyzno1 f18054847e Revert "fix: workflow_trigger"
This reverts commit cc219cc81c.
2025-10-27 17:06:30 +08:00
lyzno1 b2b81f3822 Revert "fix: trigger by display translations"
This reverts commit 33daedd7aa.
2025-10-27 17:06:25 +08:00
yessenia 90753b2782 fix(trigger): readme style 2025-10-27 16:33:40 +08:00
hjlarry c05fa9963a fix plugin name incorrect encoded 2025-10-27 16:08:47 +08:00
Harry 9de7a7d48f fix(trigger): update outputs in TriggerEventNode to use inputs directly 2025-10-27 15:35:09 +08:00
yessenia 29cddc449f fix(trigger): add clickOutsideNotClose prop 2025-10-27 14:30:08 +08:00
zhsama dfed14ba67 refactor: simplify syncWorkflowDraft parameters by removing payload sanitization 2025-10-27 13:46:27 +08:00
lyzno1andGitHub 440262a51b fix: serialize workflow draft sync operations (#27487) 2025-10-27 13:29:40 +08:00
Harry d705fece9d fix(plugin): update trigger field type to allow None and add field validator for parameters in EventEntity 2025-10-27 12:02:22 +08:00
lyzno1 d08cc48368 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-27 11:37:07 +08:00
lyzno1andGitHub b94ad084c3 feat: surface featured trigger recommendations in start tab (#27319) 2025-10-27 11:33:02 +08:00
Harry 9453148233 chore(migrations): remove obsolete migration files for workflow webhook and schedule plan tables 2025-10-27 00:36:27 +08:00
Harry 1857d0e53f chore(migrations): remove obsolete migration files for workflow trigger logs, app triggers, and plugin triggers 2025-10-27 00:28:07 +08:00
Harry ae422c2628 fix(trigger): simplify return logic in TriggerProviderService by removing unnecessary None return 2025-10-26 23:48:50 +08:00
Harry d933116e46 fix(workflow): improve error handling in DraftWorkflowTriggerNodeApi by returning JSON response 2025-10-26 23:48:50 +08:00
lyzno1 5cf4afd7b2 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-26 11:54:17 +08:00
lyzno1 f7853f3b27 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-25 14:58:32 +08:00
lyzno1 913d85302c fix: hide replay button for non app-run workflow logs 2025-10-25 14:42:26 +08:00
lyzno1 33daedd7aa fix: trigger by display translations 2025-10-25 14:36:29 +08:00
lyzno1 cc219cc81c fix: workflow_trigger 2025-10-25 14:21:52 +08:00
lyzno1 945295adc3 chore: add some ja-JP translations 2025-10-25 14:00:21 +08:00
lyzno1 651cc81cfe fix: display plugin trigger labels in logs using i18n metadata 2025-10-25 12:41:37 +08:00
lyzno1 3bd62f3fdf feat: propagate trigger metadata for plugin icons across UI 2025-10-25 12:15:21 +08:00
lyzno1 e3484c8dc3 fix: ruff format 2025-10-25 12:08:22 +08:00
lyzno1 eecbe533a1 fix: ruff check 2025-10-25 12:07:46 +08:00
Harry 4221e99362 update(docker): add triggered workflow dispatcher and refresh executor to default Celery queues 2025-10-24 21:31:38 +08:00
Stream 5c69521973 feat: align with params 2025-10-24 21:20:12 +08:00
Stream ffcaa67a56 feat: align with path 2025-10-24 20:47:54 +08:00
Stream 64a070f6b0 feat: align with path 2025-10-24 19:56:06 +08:00
Stream c61656c759 fix: request param 2025-10-24 19:40:50 +08:00
Stream 6d34e4e99b fix: request path 2025-10-24 19:33:19 +08:00
Stream d3a767364b fix: request path 2025-10-24 19:15:18 +08:00
Stream f3c6d1ca1d fix: param passing 2025-10-24 18:27:23 +08:00
zhsama 6098dc0242 feat(workflow): enhance listening descriptions for plugin and webhook triggers 2025-10-24 16:18:07 +08:00
zhsama 29ec3c7d5c feat(workflow): update listening descriptions when trigger nodes start test-run 2025-10-24 15:32:23 +08:00
zhsama 4597ab4efb Merge branch 'refs/heads/main' into feat/trigger 2025-10-24 14:44:15 +08:00
lyzno1andGitHub 1dddcf1194 fix(workflow): resolve occasional issues with syncing historical states or clearing DSL in draft mode (#27391) 2025-10-24 12:33:14 +08:00
Stream c4c38a51d9 fix: merge 2025-10-24 11:58:13 +08:00
yessenia 8a8c0703b1 feat: add datasource node readme 2025-10-24 11:46:58 +08:00
yessenia 1b74869b04 fix: plugin readme params 2025-10-24 10:48:59 +08:00
lyzno1 f065504ed6 fix(app-overview): soften tooltip styling 2025-10-24 10:34:16 +08:00
lyzno1 3f5485605f chore: update docs link 2025-10-24 10:31:09 +08:00
lyzno1 399bb522e0 chore: add ts-node for test 2025-10-24 10:20:58 +08:00
lyzno1 9fffa9a996 refactor(workflow): clean up entry node status and colocate store types 2025-10-24 10:20:38 +08:00
lyzno1 aee9a8366f refactor: move marketplace footer outside scroll containers 2025-10-24 10:07:02 +08:00
lyzno1 c3eec7ea8a Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-24 09:47:28 +08:00
yessenia 4b4ec3438f feat: add plugin readme 2025-10-24 01:18:58 +08:00
zhsama 9aa43c9165 feat(workflow): enhance trigger node handling with event listening and state management 2025-10-23 18:21:22 +08:00
zhsama 4ae23ed0f9 feat(workflow): remove unused trigger status logic and simplify entry node status handling 2025-10-23 18:19:06 +08:00
Yeuoly efe68d5aa6 Merge branch 'main' into feat/trigger 2025-10-23 18:05:59 +08:00
zhsama 1604db02b5 fix(workflow): change description field to be required in TriggerPluginNodePayload 2025-10-23 16:56:00 +08:00
zhsama e7192de9c0 Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-10-23 16:46:35 +08:00
zhsama f822b38a00 feat(workflow): integrate payload sanitization for workflow draft synchronization 2025-10-23 16:45:28 +08:00
yessenia 5a5c7f38d1 fix(plugin): stop loading when uninstall fails 2025-10-23 16:18:52 +08:00
zhsama 42a9a88ae2 refactor(trigger-plugin): enhance variable type resolution and encapsulate output variable logic in a dedicated function 2025-10-23 16:01:18 +08:00
zhsama aea3fc6281 Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-10-23 15:33:31 +08:00
zhsama bcdc11396a refactor(trigger-plugin): streamline variable type resolution and output variable construction 2025-10-23 15:33:19 +08:00
hjlarry ecd1d44d23 chore: update trigger dsl version to 0.5.0 2025-10-23 15:05:39 +08:00
hjlarry 6df786248c fix: draft run webhook node the _raw var not display on the panel 2025-10-23 13:35:45 +08:00
lyzno1 37e75f7791 Ensure workflow tools tab always shows marketplace footer 2025-10-23 13:08:59 +08:00
lyzno1 7ada2385b3 feat: add toggle behavior for featured tools 2025-10-23 12:27:42 +08:00
lyzno1 a77aab96f5 fix: align all workflow trigger docs link 2025-10-23 12:17:27 +08:00
Stream 13af48800b fix: merge 2025-10-23 12:13:33 +08:00
Stream 6e7fb59638 Merge branch 'feat/plugin-readme' into feat/trigger
# Conflicts:
#	api/controllers/console/workspace/plugin.py
#	api/core/plugin/entities/plugin_daemon.py
2025-10-23 12:10:44 +08:00
lyzno1 863b4f8fe9 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-23 11:54:35 +08:00
yessenia 949ac9d930 feat(trigger): add formitem desc 2025-10-23 10:57:42 +08:00
yessenia 06d1a2e2fd feat(trigger): remove Redundant comp & triggers api no cache 2025-10-23 10:57:41 +08:00
lyzno1andGitHub d478f62b49 Optimize workflow tool sync after plugin install (#27280) 2025-10-23 09:58:54 +08:00
zhsama 128bc2241d feat(checkbox): adjust styles for checkbox component layout 2025-10-22 17:35:04 +08:00
Harry b2730d680c fix(trigger): add missing fields in TriggerEventNode configuration 2025-10-22 16:55:55 +08:00
lyzno1 52b180104a Limit workflow tool/trigger search to provider and item names 2025-10-22 16:53:23 +08:00
Harry 9cdb62da93 fix(trigger): reset inputs in TriggerEventNode to an empty dictionary 2025-10-22 15:58:43 +08:00
Joel 5af08edfda chore: add missing icon 2025-10-22 15:38:54 +08:00
Harry 24fa5f33d7 fix(trigger): update input handling in TriggerEventNode to correctly retrieve and set outputs 2025-10-22 15:37:00 +08:00
yessenia caf0bf34dd fix(trigger): add event node status & min-height in modal 2025-10-22 15:31:03 +08:00
lyzno1 181a1ae7f3 fix: hover tooltip 2025-10-22 15:09:01 +08:00
lyzno1 e18ecead2c fix: hide All tools when searching 2025-10-22 15:07:46 +08:00
lyzno1 36a26adab2 fix: install from marketplace 2025-10-22 14:59:49 +08:00
zhsama bc2edf5107 refactor(workflow): replace fetch function with base/post in use-one-step-run and use-workflow-run hooks in polling 2025-10-22 14:55:57 +08:00
lyzno1 50bbac5973 Add double-arrow icon swap on featured tools “Show more” hover 2025-10-22 14:47:19 +08:00
lyzno1 45b221659b Tighten featured tools header arrow and add “All tools” section divider 2025-10-22 14:41:18 +08:00
lyzno1 16957f14f1 fix: featured icons 2025-10-22 14:33:00 +08:00
lyzno1 0d7dde0639 Align featured tool hover layout and widen action dropdown 2025-10-22 14:19:22 +08:00
zhsama 37805184d9 Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-10-22 12:58:15 +08:00
Yeuoly df9932088f avoid time slice strategy in community edition 2025-10-22 12:50:11 +08:00
zhsama d101a83be8 Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-10-22 12:49:41 +08:00
lyzno1 94ea289c75 fix: suggestions tools list 2025-10-22 12:46:22 +08:00
lyzno1 e2539e91eb fix: view docs 2025-10-22 12:46:22 +08:00
lyzno1 77e9bae3ff feat(workflow): polish featured tools recommendations 2025-10-22 12:46:21 +08:00
lyzno1 d99644237b chore: align help link translations 2025-10-22 12:46:21 +08:00
lyzno1 5cb268e99b feat: suggestions ui 2025-10-22 12:46:21 +08:00
lyzno1 f179b03d6e fix: constrain rag pipeline datasource selector width 2025-10-22 12:46:21 +08:00
lyzno1 28fe58f3dd feat: try to add tools suggestions 2025-10-22 12:46:21 +08:00
Yeuoly 14acd05846 fix 2025-10-22 12:41:19 +08:00
zhsama ccce135bf5 fix(workflow): add setShowVariableInspectPanel for specific block types in useLastRun hook 2025-10-22 12:38:03 +08:00
Yeuoly cb5607fc8c refactor: TimeSliceLayer 2025-10-22 12:13:12 +08:00
Yeuoly 7f70d1de1c ASYNC_WORKFLOW_SCHEDULER_GRANULARITY 2025-10-22 12:10:12 +08:00
Yeuoly c36173f5a9 fix: typing 2025-10-22 11:55:26 +08:00
Yeuoly 7acbe981e2 fix: discorrect elapsed_time 2025-10-22 11:49:02 +08:00
Yeuoly dd6ab7c68c feat: support pausing workflow trigger log 2025-10-22 11:45:16 +08:00
Joel a1ea256e79 fix: global icon in inspect 2025-10-22 11:36:01 +08:00
Joel 14942c9ee9 fix: page crash 2025-10-22 11:28:28 +08:00
Joel b0b316ed48 fix: rag pipline not show sys vars 2025-10-22 11:07:08 +08:00
hjlarry 871cfbd40c fix: CredentialsSchema missing help field display 2025-10-22 09:23:59 +08:00
yessenia 9a3ca0ce3b fix(trigger): check subscription removed 2025-10-21 20:01:16 +08:00
zhsama c90df5c12c refactor(entry-node): remove showIndicator prop and related logic for cleaner component structure 2025-10-21 19:53:29 +08:00
yessenia f4acc78f66 fix(trigger): deal with empty manualPropertiesSchema 2025-10-21 19:49:42 +08:00
Yeuoly 3d5e2c5ca1 feat(trigger): add suspend/timeslice layers and workflow CFS scheduler
- add suspend, timeslice, and trigger post engine layers
- introduce CFS workflow scheduler tasks and supporting entities
- update async workflow, trigger, and webhook services to wire in the new scheduling flow
2025-10-21 19:20:54 +08:00
zhsama 55bf9196dc feat(trigger): add TriggerSchedule to node type checks for workflow execution 2025-10-21 18:57:57 +08:00
yessenia 18a52b4937 fix(trigger): subscription removed in workflow 2025-10-21 18:43:15 +08:00
Joel 439727746c fix: trigger timestamp show place 2025-10-21 18:21:37 +08:00
Joel 04b55177b5 feat: support show global vars 2025-10-21 17:59:37 +08:00
zhsama 2793ede875 feat: update checkbox component in the panel and refactor form types for checkbox and boolean 2025-10-21 17:28:25 +08:00
yessenia dc4801c014 refactor(trigger): refactor app mode type to enum 2025-10-21 16:50:18 +08:00
yessenia d5e2649608 fix(trigger): disable some options when no start node 2025-10-21 15:25:36 +08:00
Joel 4102f0bc9d feat: vars to new place 2025-10-21 15:03:16 +08:00
Joel 25e4203cb1 main 2025-10-21 14:44:24 +08:00
Joel e1a3ead941 main 2025-10-21 14:42:27 +08:00
zhsama 6251090893 Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-10-21 14:39:10 +08:00
zhsama aa5e04b70e Merge branch 'main' into feat/trigger
# Conflicts:
#	web/app/components/workflow/hooks-store/store.ts
#	web/package.json
#	web/pnpm-lock.yaml
2025-10-21 14:36:07 +08:00
Harry 8ac25c29ee feat(trigger): implement subscription refresh logic with enhanced error handling and logging 2025-10-21 14:11:27 +08:00
Harry f4517d667b feat(trigger): enhance trigger provider refresh task with locking mechanism and due filter logic 2025-10-21 14:11:27 +08:00
Yeuoly dc2481c805 feat: docs 2025-10-21 13:56:31 +08:00
Yeuoly 8d7435a51b docs: introduce agent skill for trigger 2025-10-21 12:23:19 +08:00
lyzno1 bb28c718df fix: correct webhook trigger node id parsing 2025-10-21 11:48:50 +08:00
lyzno1 1b7a5b6209 fix: immer breaking change 2025-10-21 11:42:31 +08:00
lyzno1 448622b4fd fix: pnpm lock file 2025-10-21 11:39:39 +08:00
crazywoolaandlyzno1 e9dda03e8d fix: immer version and ref in code base (#27130) 2025-10-21 11:38:44 +08:00
lyzno1 8d3d177932 fix: pnpm lock file 2025-10-21 11:35:41 +08:00
lyzno1 f0af4d692a fix: breaking change 2025-10-21 11:32:20 +08:00
lyzno1 075173e67d fix(workflow): reset onboarding auto-open flag across flows 2025-10-21 11:19:36 +08:00
Yeuoly f02d575379 Merge branch 'main' into feat/trigger 2025-10-21 11:09:26 +08:00
yessenia 735ebf6c59 fix(trigger): oauth client params 2025-10-21 09:27:10 +08:00
Harry 96f0b7abe3 fix(trigger): handle missing 'inputs' key in trigger data retrieval 2025-10-20 21:47:49 +08:00
zhsama eb1686f04b Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-10-20 20:27:02 +08:00
zhsama d4b5d9a02a feat(trigger): add trigger validation logic and utility functions for improved checklist integration 2025-10-20 20:26:40 +08:00
Harry f87f77ce7b feat(trigger): add configuration for trigger provider refresh task 2025-10-20 20:02:12 +08:00
Harry 24619e74f6 fix(trigger): update error handling and credential expiration field 2025-10-20 20:02:12 +08:00
zhsama f5c1646f79 fix(dynamic-options): fix the dynamic options in plugin trigger 2025-10-20 19:34:41 +08:00
zhsama e26d77e78c fix(checklist): enhance type safety by refining BlockEnum usage in checklist components 2025-10-20 19:34:41 +08:00
yessenia 8e1e81732a fix(trigger): formitem boolean layout 2025-10-20 19:27:21 +08:00
yessenia 801f8c1592 fix(trigger): oauth client default values 2025-10-20 18:21:38 +08:00
Harry fd4b234171 feat: improve oauth client info api 2025-10-20 16:50:03 +08:00
Harry dff536ab6d feat(trigger): trigger plugin protocol improvements 2025-10-20 16:50:03 +08:00
hjlarry a152ce45d3 fix: start/stop button on the node control not work 2025-10-20 16:43:16 +08:00
Yeuoly 6a164f8811 refactor: use EnumText 2025-10-20 15:48:11 +08:00
Yeuoly a03ff39f3e chore: add to .env.example 2025-10-20 15:42:29 +08:00
Yeuoly a6373e357a fix: typing 2025-10-20 15:38:54 +08:00
Yeuoly 538b639bef fix: unify trigger url generation 2025-10-20 15:34:51 +08:00
yessenia fe0457b257 fix(trigger): show text 2025-10-20 14:17:52 +08:00
zhsama d5b228f234 fix(end-node): adjust required status and update end node terminology to output in i18n 2025-10-20 14:00:14 +08:00
lyzno1 1c2f95eeb6 fix(migrations): chain messages.app_mode upgrade after plugin trigger 2025-10-20 13:40:37 +08:00
lyzno1 81b3436ec4 fix(trigger): resolve circular import in models 2025-10-20 09:23:11 +08:00
Yeuoly 3e4f2bcf14 optimize: TriggerDispatchResponse 2025-10-18 20:40:59 +08:00
Yeuoly c7696964b9 fix: refine 2025-10-18 20:27:22 +08:00
Yeuoly fb8ecf7b5a refactor: move out enums to specific file 2025-10-18 20:22:21 +08:00
Yeuoly e3c2345b21 fix: typing 2025-10-18 20:17:23 +08:00
Yeuoly bfe0d14409 fix: typing 2025-10-18 20:16:10 +08:00
Yeuoly c7498c3a11 fix: typing 2025-10-18 20:14:00 +08:00
Yeuoly 5fba41688a refactor: cleaning up terrible data 2025-10-18 20:12:20 +08:00
Yeuoly b63b9c32f7 refactor: models 2025-10-18 20:06:46 +08:00
Yeuoly 65c6203ad7 fix: correct building reference 2025-10-18 19:54:06 +08:00
Yeuoly 3a18337129 refactor: confused abstract class 2025-10-18 19:47:23 +08:00
Yeuoly b6b433626e fix: typing 2025-10-18 19:43:00 +08:00
Yeuoly 5d6b9b0cb1 refactor 2025-10-18 19:41:53 +08:00
Yeuoly 6d09330f98 chore: rename PluginTriggerManager to PluginTriggerClient 2025-10-18 19:33:08 +08:00
Yeuoly 5df9afa91a fix: typing 2025-10-18 19:32:08 +08:00
Yeuoly 30a341331f chore: unify request handling 2025-10-18 19:29:00 +08:00
Yeuoly 31cf4b6619 fix: query parameter dose not exist in workflow 2025-10-18 19:19:36 +08:00
Yeuoly dd0da3218c feat: introduce payload field to plugin trigger processing 2025-10-18 19:15:46 +08:00
Yeuoly 11c9219848 chore: better exception handling 2025-10-18 19:15:09 +08:00
Yeuoly b1ffd2ef2b refine: use enum reference to avoid plain text declarations 2025-10-18 19:14:24 +08:00
Yeuoly 86cf7952fb refactor: add typing annotation 2025-10-18 19:13:07 +08:00
Yeuoly d790d2b6bc feat: introduce payload field to TriggerDispatchResponse and a better typing 2025-10-18 19:12:43 +08:00
Yeuoly a711a8e759 refactor: better typing 2025-10-18 19:11:50 +08:00
Yeuoly 8a18b6e13b refactor webhook service enduser operations 2025-10-18 19:11:15 +08:00
Yeuoly 95aeb61d7c fix: missing backwards invocation 2025-10-18 19:10:22 +08:00
Yeuoly e8b0144cf7 refactor: remove common end user operations out of wraps.py and move it into EndUserService 2025-10-18 19:09:55 +08:00
yessenia 2c8c1860ca fix(trigger): show event output 2025-10-18 16:28:26 +08:00
Yeuoly 5edfbd5305 fix: meaningless error messages 2025-10-18 16:27:12 +08:00
lyzno1 4ceae655bd fix: prevent selecting time text in picker 2025-10-18 15:50:15 +08:00
lyzno1 6ae76d108b feat: add cursor pointer to macketplace actions 2025-10-17 21:31:40 +08:00
lyzno1 9cc3cfb63e fix: hide footer from all start block when search not found 2025-10-17 21:28:57 +08:00
lyzno1 58e4c0793a feat: align tool selector empty state with start blocks 2025-10-17 21:25:28 +08:00
Harry 80f2c1be67 fix(trigger): enhance error handling and refactor end user creation in trigger workflows
- Improved error handling in `TriggerSubscriptionListApi` to return a 404 response for ValueErrors.
- Refactored end user creation logic in `service_api/wraps.py` to use `get_or_create_end_user` for better clarity and consistency.
- Introduced a new method `create_end_user_batch` for batch creation of end users, optimizing database interactions.
- Updated various trigger-related services to utilize the new end user handling, ensuring proper user context during trigger dispatching.
2025-10-17 21:00:57 +08:00
lyzno1 8a5174d078 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-17 19:21:15 +08:00
zhsama d0f357a690 feat(workflow): enhance listening functionality with multiple trigger node support 2025-10-17 19:09:55 +08:00
zhsama fbe3df5658 fix(plugin-detail-panel): update provider reference to use trigger identity name 2025-10-17 18:23:35 +08:00
yessenia 21e3ef91eb fix(trigger): show event detail 2025-10-17 18:23:04 +08:00
zhsama 3f116dc74b feat(variable-inspect): improve listening description resolution in Listening component 2025-10-17 18:11:26 +08:00
hjlarry 32731c4622 render autoCommonParametersSchema other input type 2025-10-17 18:09:14 +08:00
zhsama 3c1f0e1aec fix(trigger): fix authentication status check 2025-10-17 17:13:07 +08:00
Joel 685e48636d fix: if tag show global vars problem 2025-10-17 16:57:42 +08:00
Joel 7c4edaa636 fix: variableValid in prompt editor 2025-10-17 16:48:27 +08:00
Joel 35867707d0 fix: global var type render in node 2025-10-17 15:24:49 +08:00
zhsama 5b884d750f feat(trigger): add run all triggers test-run and implement TriggerType enum 2025-10-17 14:56:05 +08:00
Harry bc0d5f4e41 fix(trigger): enhance subscription retrieval error handling in TriggerService
- Added exception handling for `get_subscription_by_endpoint` to return a 404 response when the plugin is not found and a 500 response for other errors.
- Improved overall robustness of the subscription retrieval process.
2025-10-17 14:43:43 +08:00
Harry f20452622a fix(trigger): improve event retrieval handling in PluginTriggerProviderController
- Updated the `get_event` method to return `None` instead of raising a ValueError when an event is not found, enhancing error handling.
- Adjusted the `get_event_parameters` method to handle cases where the event may be `None`, returning an empty dictionary instead of causing an error.
- Improved type hinting for better clarity and type safety.
2025-10-17 14:43:43 +08:00
Joel 6ba26cf7b5 fix: global var show in node 2025-10-17 14:39:30 +08:00
Joel 7510e0654b fix: show global vars in picker 2025-10-17 14:24:20 +08:00
Joel 564bb22d8b feat: system var icon 2025-10-17 13:57:26 +08:00
lyzno1 5e2d5f0d83 feat: allow trigger schedule TimePicker to stretch with panel 2025-10-17 13:52:26 +08:00
hjlarry d90ffbcf14 rm unused ensureWebhookRawVariable 2025-10-17 13:49:33 +08:00
hjlarry 771cc72dcf fix auto generate webhook url 2025-10-17 13:41:03 +08:00
-LAN- 04c91111e9 fix(trigger): trigger node is marked as 'branch' type 2025-10-17 13:37:46 +08:00
yessenia 5a13daefdb fix(trigger): close portal after select a subscription 2025-10-17 13:31:00 +08:00
lyzno1 c033c05ec1 fix: resolve trigger plugin icons in workflow checklist 2025-10-17 12:55:41 +08:00
hjlarry 5b2f323a87 improve webhook request headers 2025-10-17 11:27:48 +08:00
Joel b855d95430 feat: can choose global vars 2025-10-17 11:02:27 +08:00
yessenia fe4b63210e fix(trigger): oauth client config 2025-10-17 10:52:42 +08:00
Joel 84c09ec59d chore: user input output vars show 2025-10-17 10:21:11 +08:00
hjlarry 40e17ef801 fix merge main cause current_user not defined 2025-10-17 09:49:09 +08:00
yessenia f1fcb92691 feat(trigger): add category trigger 2025-10-16 18:30:54 +08:00
lyzno1 3865555113 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-16 18:30:33 +08:00
lyzno1 95e46806a4 fix: marketplace item install hover 2025-10-16 18:01:00 +08:00
lyzno1 c9c3d03878 fix: keep start tab search results restorable 2025-10-16 17:56:32 +08:00
lyzno1 b28ec4be6e fix: start block ui 2025-10-16 17:48:24 +08:00
lyzno1 29d7023fae - Update all-tools.tsx so provider search results keep only relevant items: full list retained when the provider matches; otherwise the provider is cloned with just matching tools.
- Mirror the same filtering strategy for Start-tab trigger plugins in trigger-plugin/list.tsx, ensuring only matching events render when searching.
2025-10-16 17:46:44 +08:00
lyzno1 22f6c23780 refactor: remove empty search placeholder from tool selector 2025-10-16 17:39:35 +08:00
hjlarry 548db29a47 add var name check for webhook node 2025-10-16 16:59:46 +08:00
hjlarry 1089c5bf04 add _webhook_raw to downstreamed node 2025-10-16 16:35:05 +08:00
hjlarry 559cf6583f fix add candidate webhook node raise error 2025-10-16 15:33:18 +08:00
yessenia b04f92715c feat(trigger): plugin category type 2025-10-16 15:30:04 +08:00
Harry 671aba6ab7 fix(trigger): handle missing subscription constructor gracefully in PluginTriggerProviderController
- Updated the logic in `PluginTriggerProviderController` to return an empty list instead of raising a ValueError when the subscription constructor is not found, improving error handling and flow.
2025-10-16 15:09:13 +08:00
Harry beaeb30dcc fix(trigger): enhance credential encryption handling in TriggerProviderService
- Introduced conditional initialization of credential_encrypter based on credential_type to prevent errors when unauthorized.
- Updated the encryption logic to handle cases where credential_encrypter may be None, ensuring robustness in credential processing.
2025-10-16 15:07:05 +08:00
hjlarry 56abca1f41 webhook i18n 2025-10-16 14:52:15 +08:00
zhsama 52d5f219e1 fix(workflow): include trigger node type in available blocks check 2025-10-16 14:24:44 +08:00
Harry d4516e942c fix(trigger): improve error handling in DraftWorkflowTriggerNodeApi and update input class naming
- Removed specific exception handling for ValueError and PluginInvokeError in `DraftWorkflowTriggerNodeApi`, allowing a more general exception to be raised.
- Renamed `PluginTriggerInput` to `TriggerEventInput` in `TriggerEventNodeData` for better clarity and consistency.
- Updated validation logic in `TriggerEventInput` to ensure correct type checks for input values.
2025-10-16 14:04:44 +08:00
zhsama 1c17a16830 feat(trigger): format event_parameters and improve 2025-10-16 14:00:21 +08:00
lyzno1 1f6ab13fc5 fix(workflow): auto run single start node without dropdown 2025-10-16 09:37:18 +08:00
lyzno1 7344df87e5 Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-10-15 20:47:20 +08:00
lyzno1 29353bd7c2 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-15 20:47:02 +08:00
yessenia 7b6f5d6860 fix(trigger): show tool credentials in workflow 2025-10-15 20:42:14 +08:00
lyzno1 2ccb20bf3a fix(workflow): gate “publish as tool” on published user input node validity 2025-10-15 20:26:12 +08:00
lyzno1 34b7e5cbca fix: enable scrolling in start selector tab 2025-10-15 19:09:23 +08:00
yessenia a595e2df06 fix(trigger): skip validation when updating properties 2025-10-15 18:44:05 +08:00
zhsama 729e0e9b1e feat(workflow): add disableVariableInsertion prop to form input and trigger components 2025-10-15 18:20:13 +08:00
zhsama c03b790888 feat(trigger): add event_parameters to PluginTriggerNode configuration 2025-10-15 18:14:43 +08:00
zhsama 112b5f63dd feat(workflow): enhance single run handling 2025-10-15 18:14:33 +08:00
Harry 334e5f19bf fix(trigger): handle missing subscription constructor in trigger subscription builder
- Updated the `TriggerSubscriptionBuilderService` to return an empty dictionary when the subscription constructor is not available, improving robustness in subscription handling.
2025-10-15 17:44:51 +08:00
Harry 35bbf67175 refactor(trigger): Rename and replace PluginTriggerNode with TriggerEventNode
- Updated references from `PluginTriggerNode` to `TriggerEventNode` across multiple files to reflect the new naming convention.
- Modified `PluginTriggerNodeData` to `TriggerEventNodeData`, including changes to event parameters for better clarity and consistency in data handling.
- Removed the deprecated `trigger_plugin_node.py` file as part of the refactor.
2025-10-15 17:30:42 +08:00
yessenia 9aec255ee9 feat(trigger): update subscription list after saving draft 2025-10-15 17:22:14 +08:00
Harry b07e80e6ae fix(trigger): update error type for event handling in trigger manager
- Changed the error type check from "TriggerIgnoreEventError" to "EventIgnoreError" in the `TriggerManager` class to improve clarity in error handling during trigger invocations.
2025-10-15 17:14:44 +08:00
Harry ad2b910d73 refactor(trigger): Enhance error handling and parameter resolution in trigger workflows
- Improved error handling in `DraftWorkflowTriggerRunApi`, `DraftWorkflowTriggerNodeApi`, and `DraftWorkflowTriggerRunAllApi` to raise exceptions directly, providing clearer error messages.
- Introduced `get_event_parameters` method in `PluginTriggerProviderController` to retrieve event parameters for triggers.
- Updated `PluginTriggerNodeData` to include a new method for resolving parameters based on event schemas, ensuring better validation and handling of trigger inputs.
- Refactored `TriggerService` to utilize the new parameter resolution method, enhancing the clarity and reliability of trigger invocations.
2025-10-15 17:05:51 +08:00
yessenia f28a7218cd fix(trigger): optimize subscription entry in workflow 2025-10-15 16:13:00 +08:00
lyzno1 4164e1191e fix: hide checklist navigation for missing nodes 2025-10-15 16:10:34 +08:00
Harry bd31c6f90b refactor(trigger): Reinstate DraftWorkflowTriggerNodeApi with improved structure
- Restored the `DraftWorkflowTriggerNodeApi` class to handle polling for trigger events in draft workflows.
- Enhanced the implementation to utilize `TriggerDebugEvent` and `TriggerDebugEventPoller` for better event management.
- Improved error handling and response structure for node execution, ensuring clarity in API responses.
- Updated API documentation to reflect the restored functionality and parameters.
2025-10-15 14:45:00 +08:00
Harry 8f7bef9509 fix(trigger): Update API routes for draft workflow trigger
- Changed the endpoint for triggering draft workflows from `/trigger/plugin/run` to `/trigger/run` in both backend and frontend to ensure consistency and clarity in the API structure.
- Adjusted the URL construction in the `useWorkflowRun` hook to reflect the updated route.
2025-10-15 14:44:00 +08:00
Harry 06c91fbcbd refactor(trigger): Unify the Trigger Debug interface and event handling and enhance error management
- Updated `DraftWorkflowTriggerNodeApi` to utilize the new `TriggerDebugEvent` and `TriggerDebugEventPoller` for improved event polling.
- Removed deprecated `poll_debug_event` methods from `TriggerService`, `ScheduleService`, and `WebhookService`, consolidating functionality into the new event structure.
- Enhanced error handling in `invoke_trigger_event` to utilize `TriggerPluginInvokeError` for better clarity on invocation issues.
- Updated frontend API routes to reflect changes in trigger event handling, ensuring consistency across the application.
2025-10-15 14:41:53 +08:00
Harry dab4e521af feat(trigger): enhance trigger event handling and introduce new debug event polling
- Refactored the `DraftWorkflowTriggerNodeApi` and related services to utilize the new `TriggerService` for polling debug events, improving modularity and clarity.
- Added `poll_debug_event` methods in `TriggerService`, `ScheduleService`, and `WebhookService` to streamline event handling for different trigger types.
- Introduced `ScheduleDebugEvent` and updated `PluginTriggerDebugEvent` to include a more structured approach for event data.
- Enhanced the `invoke_trigger_event` method to improve error handling and data validation during trigger invocations.
- Updated frontend API calls to align with the new event structure, removing deprecated parameters for cleaner integration.
2025-10-15 11:04:09 +08:00
lyzno1 b20f61356c Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-15 09:53:03 +08:00
yessenia 4ec23eea00 fix: add i18n key 2025-10-14 21:23:24 +08:00
Yeuoly 270fd9cb07 fix: discorrect entity reference 2025-10-14 20:14:13 +08:00
yessenia c7c5e07d43 fix(trigger): add tooltip when only one creation type 2025-10-14 18:39:22 +08:00
zhsama c1ba83f0d4 feat(trigger): add validation for subscription in PluginTrigger node 2025-10-14 18:13:02 +08:00
zhsama d71200ee32 feat: enhance block selector and change block components with flow type handling 2025-10-14 16:42:21 +08:00
yessenia 16ac05ebd5 feat: support search in checkbox list 2025-10-14 16:24:44 +08:00
zhsama ac77b9b735 Merge remote-tracking branch 'origin/feat/trigger' into feat/trigger 2025-10-14 15:28:35 +08:00
zhsama 0fa4b77ff8 feat(style): adjust minimum and maximum width for block-selector and data source components 2025-10-14 15:23:28 +08:00
Harry 6773dda657 feat(trigger): enhance trigger handling with new data validation and logging improvements
- Added validation for `PluginTriggerData` and `ScheduleTriggerData` in the `WorkflowService` to support new trigger types.
- Updated debug event return strings in `PluginTriggerDebugEvent` and `WebhookDebugEvent` for clarity and consistency.
- Enhanced logging in `dispatch_triggered_workflows_async` to include subscription and provider IDs, improving traceability during trigger dispatching.
2025-10-14 14:36:52 +08:00
zhsama bf42386c5b feat(trigger): add PluginTrigger node support and enhance output variable handling 2025-10-14 11:55:12 +08:00
Harry 90fc06a494 refactor(trigger): update TriggerApiEntity description type to TypeWithI18N
- Changed the description field type in `TriggerApiEntity` from `TriggerDescription` to `TypeWithI18N` for improved internationalization support.
- Adjusted the usage of the description field in the `convertToTriggerWithProvider` function to align with the new type definition.
2025-10-13 22:24:12 +08:00
Harry 8dfe693529 refactor(trigger): rename TriggerApiEntity to EventApiEntity and update related references
- Changed `TriggerApiEntity` to `EventApiEntity` in the trigger provider and subscription models to better reflect its purpose.
- Updated the description field type from `EventDescription` to `I18nObject` for improved consistency in event descriptions.
- Adjusted imports and references across multiple files to accommodate the renaming and type changes, ensuring proper functionality in trigger processing.
2025-10-13 21:10:31 +08:00
yessenia d65d27a6bb fix: creating button style 2025-10-13 20:53:06 +08:00
lyzno1 e6a6bde8e2 feat(i18n): add draft reminder to app overview tooltips 2025-10-13 20:18:54 +08:00
lyzno1 c7d0a7be04 feat(trigger): enable triggers by default after workflow publish 2025-10-13 19:59:39 +08:00
Harry e0f1b03cf0 fix(trigger): clear subscription_id in trigger plugin processing
- Updated the `AppDslService` to clear the `subscription_id` when processing nodes of type `TRIGGER_PLUGIN`. This change ensures that sensitive subscription data is not retained unnecessarily, enhancing data security during workflow execution.
2025-10-13 18:42:54 +08:00
Harry 902737b262 feat(trigger): enhance subscription decryption in trigger processing
- Added functionality to decrypt subscription credentials and properties within the `dispatch_triggered_workflows_async` method. This ensures that sensitive data is securely handled before processing, improving the overall security of trigger invocations.
2025-10-13 18:10:53 +08:00
Harry 429cd05a0f fix(trigger): serialize subscription model in trigger invocation
- Updated the `PluginTriggerManager` to serialize the `subscription` parameter using `model_dump()` before passing it during trigger invocation. This change ensures that the subscription data is correctly formatted for processing.
2025-10-13 18:07:51 +08:00
Harry 46e7e99c5a feat(trigger): add subscription parameter to trigger invocation methods
- Enhanced `PluginTriggerManager`, `PluginTriggerProviderController`, and `TriggerManager` to accept a `subscription` parameter in their trigger invocation methods.
- Updated `TriggerService` to pass the subscription entity when invoking trigger events, improving the handling of subscription-related data during trigger execution.
2025-10-13 17:47:40 +08:00
lyzno1 d19ce15f3d Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-13 17:28:47 +08:00
lyzno1 49af7eb370 fix(trigger-schedule): make timezone field optional to match actual usage 2025-10-13 17:28:40 +08:00
lyzno1 8e235dc92c feat(workflow): hide timezone in node next execution, keep in panel next 5 executions 2025-10-13 17:28:40 +08:00
lyzno1 3b3963b055 refactor(workflow): remove timezone required validation as it is auto-filled by use-config 2025-10-13 17:28:40 +08:00
lyzno1 378c2afcd3 fix(workflow): remove hardcoded UTC timezone from new schedule node to use user timezone 2025-10-13 17:28:40 +08:00
lyzno1 d709f20e1f fix(workflow): update community feedback link to plugin request template 2025-10-13 17:28:40 +08:00
lyzno1 99d9657af8 feat(workflow): integrate timezone display into execution time format for better readability 2025-10-13 17:28:40 +08:00
lyzno1 62efdd7f7a fix(workflow): preserve saved timezone in trigger-schedule to match backend fixed-timezone design 2025-10-13 17:28:39 +08:00
lyzno1 ebcf98c137 revert(workflow): remove timezone label from trigger-schedule node display 2025-10-13 17:28:39 +08:00
lyzno1 7560e2427d fix(timezone): support half-hour and 45-minute timezone offsets
Critical regression fix for convertTimezoneToOffsetStr:

Issues Fixed:
- Previous regex /^([+-]?\d{1,2}):00/ only matched :00 offsets
- This caused half-hour offsets (e.g., India +05:30) to return UTC+0
- Even if matched, parseInt only parsed hours, losing minute info

Changes:
- Update regex to /^([+-]?\d{1,2}):(\d{2})/ to match all offset formats
- Parse both hours and minutes separately
- Output format: "UTC+5:30" for non-zero minutes, "UTC+8" for whole hours
- Preserve leading zeros in minute part (e.g., "UTC+5:30" not "UTC+5:3")

Test Coverage:
- Added 8 comprehensive tests covering:
  * Default/invalid timezone handling
  * Whole hour offsets (positive/negative)
  * Zero offset (UTC)
  * Half-hour offsets (India +5:30, Australia +9:30)
  * 45-minute offset (Chatham +12:45)
  * Leading zero preservation in minutes

All 14 tests passing. Verified with timezone.json entries at lines 967, 1135, 1251.
2025-10-13 17:28:39 +08:00
lyzno1 920a608e5d fix(trigger-schedule): prevent timezone label truncation in node
- Change layout to ensure timezone label always visible with shrink-0
- Time text can truncate but timezone label stays intact
- Improves readability in constrained node space
2025-10-13 17:28:39 +08:00
lyzno1 4dfb8b988c feat(time-picker): add showTimezone prop with comprehensive tests
- Add showTimezone prop to TimePickerProps for optional inline timezone display
- Integrate TimezoneLabel component into TimePicker when showTimezone=true
- Add 6 comprehensive test cases covering all showTimezone scenarios:
  * Default behavior (no timezone label)
  * Explicit disable with showTimezone=false
  * Enable with showTimezone=true
  * Inline prop correctly passed
  * No display when timezone is missing
  * Correct styling classes applied
- Update trigger-schedule panel to use showTimezone prop
- All 15 tests passing with good coverage
2025-10-13 17:28:39 +08:00
lyzno1 af6dae3498 fix(timezone): fix UTC offset display bug and add timezone labels
- Fixed convertTimezoneToOffsetStr() that only extracted first digit
  * UTC-11 was incorrectly displayed as UTC-1, UTC+10 as UTC+0
  * Now correctly extracts full offset using regex and removes leading zeros
- Created reusable TimezoneLabel component with inline mode support
- Added comprehensive unit tests with 100% coverage
- Integrated timezone labels into 3 locations:
  * Panel time picker (next to time input)
  * Node next execution display
  * Panel next 5 executions list
2025-10-13 17:28:39 +08:00
yessenia ee21b4d435 feat: support copy to clipboard in input component 2025-10-13 17:21:26 +08:00
zhsama 654adccfbf fix(trigger): implement plugin single run functionality and update node status handling 2025-10-13 17:02:44 +08:00
Harry b283a2b3d9 feat(trigger): add API endpoint to retrieve trigger plugin icons and enhance workflow response handling
- Introduced `TriggerProviderIconApi` to fetch icons for trigger plugins based on tenant and provider ID.
- Updated `WorkflowResponseConverter` to include trigger plugin icons in the response.
- Implemented `get_trigger_plugin_icon` method in `TriggerManager` for icon retrieval logic.
- Adjusted `Node` class to correctly set provider information for trigger plugins.
- Modified TypeScript types to accommodate new provider ID field in workflow nodes.
2025-10-13 16:50:32 +08:00
lyzno1 cce729916a fix(trigger-schedule): pass time string directly to TimePicker to avoid double timezone conversion 2025-10-13 16:00:13 +08:00
yessenia 4f8bf97935 fix: creating modal style 2025-10-13 14:54:24 +08:00
zhsama ba88c7b25b fix(workflow): handle plugin run mode correctly by setting status 2025-10-13 14:50:12 +08:00
yessenia 0ec5d53e5b fix(trigger): log style 2025-10-13 14:46:08 +08:00
lyzno1 f3b415c095 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-13 13:21:51 +08:00
Harry 6fb657a89e refactor(subscription): enhance subscription count handling in selector view
- Introduced a subscriptionCount variable to improve readability and performance when checking the number of subscriptions.
- Updated the rendering logic to use subscriptionCount, ensuring consistent and clear display of subscription information in the component.
2025-10-13 11:22:25 +08:00
Harry 90240cb6db refactor(subscription): optimize subscription count handling in list view
- Replaced direct length checks on subscriptions with a computed subscriptionCount variable for improved readability and performance.
- Updated the CreateSubscriptionButton to conditionally render based on the new subscriptionCount variable, enhancing clarity in the component logic.
- Adjusted className logic for the button to account for multiple supported methods, ensuring better user experience.
2025-10-12 23:56:27 +08:00
Harry cca48f07aa feat(trigger): implement atomic update and verification for subscription builders
- Introduced atomic operations for updating and verifying subscription builders to prevent race conditions.
- Added distributed locking mechanism to ensure data consistency during concurrent updates and builds.
- Refactored existing methods to utilize the new atomic update and verification logic, enhancing the reliability of trigger subscription handling.
2025-10-12 21:27:38 +08:00
Harry beff639c3d fix(trigger): improve trigger subscription query with AppTrigger join
- Updated the trigger subscription query to join with the AppTrigger model, ensuring only enabled app triggers are considered.
- Enhanced the filtering criteria for retrieving subscribers based on the AppTrigger status, improving the accuracy of the trigger subscription handling.
2025-10-12 19:24:54 +08:00
Harry 00359830c2 refactor(trigger): streamline response handling in trigger subscription dispatch
- Removed the redundant response extraction from the dispatch call and directly assigned the response to a variable for clarity.
- Enhanced logging by appending the request log after dispatching, ensuring better traceability of requests and responses in the trigger subscription workflow.
2025-10-11 22:16:18 +08:00
Harry f23e098b9a fix(trigger): handle exceptions in trigger subscription dispatch
- Wrapped the dispatch call in a try-except block to catch exceptions and return a 500 error response if an error occurs.
- Enhanced logging of the request and error response for better traceability in the trigger subscription workflow.
2025-10-11 22:13:36 +08:00
Harry 42f75b6602 feat(trigger): enhance trigger subscription handling with credential support
- Added `credentials` and `credential_type` parameters to various methods in `PluginTriggerManager`, `PluginTriggerProviderController`, and `TriggerManager` to support improved credential management for trigger subscriptions.
- Updated the `Subscription` model to include `parameters` for better subscription data handling.
- Refactored related services to accommodate the new credential handling, ensuring consistency across the trigger workflow.
2025-10-11 21:12:27 +08:00
yessenia 4f65cc312d feat: delete confirm opt 2025-10-11 20:19:27 +08:00
yessenia 854a091f82 feat: add validation status for formitem 2025-10-11 19:50:05 +08:00
zhsama 63dbc7c63d fix(trigger): update provider_id reference to plugin_id in useToolIcon hook 2025-10-11 19:05:57 +08:00
zhsama a4e80640fe chore(trigger): remove debug console logs 2025-10-11 18:54:47 +08:00
zhsama fe0a139c89 fix(trigger): update provider_id references to plugin_id in BasePanel component 2025-10-11 18:52:15 +08:00
zhsama ac2616545b fix(trigger): update provider_id field in TriggerPluginActionItem component 2025-10-11 17:10:29 +08:00
zhsama c9e7922a14 refactor(trigger): update trigger-related types and field names / values 2025-10-11 17:06:43 +08:00
yessenia 12a7402291 fix: create button not working in manual creation mode 2025-10-11 15:36:37 +08:00
yessenia 33d7b48e49 fix: error when fetching info while switching plugins 2025-10-11 15:00:35 +08:00
Harry ee89e9eb2f refactor(trigger): update type parameter naming in PluginTriggerManager
- Changed the parameter name from 'type' to 'type_' in multiple method calls within the PluginTriggerManager class to avoid conflicts with the built-in type function and improve code clarity.
2025-10-11 13:09:25 +08:00
Harry e793f9e871 refactor(trigger): remove unnecessary whitespace in trigger-related files
- Cleaned up the code by removing extraneous whitespace in `trigger.py` and `workflow_plugin_trigger_service.py`, improving readability and maintaining code style consistency.
2025-10-11 12:44:54 +08:00
Harry 18b02370a2 chore(workflows): update deployment configurations
- Modified the build-push workflow to trigger on all branches under "deploy/**" for broader deployment coverage.
- Changed the SSH host secret in the deploy-dev workflow from RAG_SSH_HOST to DEV_SSH_HOST for improved clarity.
- Removed the obsolete deploy-rag-dev workflow to streamline the CI/CD process.
2025-10-11 12:26:31 +08:00
Harry d53399e546 refactor(trigger): rename trigger-related fields and methods for consistency
- Updated the naming convention from 'trigger_name' to 'event_name' across various models and services to align with the new event-driven architecture.
- Refactored methods in PluginTriggerManager and PluginTriggerProviderController to use 'invoke_trigger_event' instead of 'invoke_trigger'.
- Adjusted database migration scripts to reflect changes in the schema, including the addition of 'event_name' and 'subscription_id' fields in the workflow_plugin_triggers table.
- Removed deprecated trigger-related methods in WorkflowPluginTriggerService to streamline the codebase.
2025-10-11 12:26:08 +08:00
yessenia 622d12137a feat: change subscription field in workflow 2025-10-10 20:58:56 +08:00
lyzno1 bae8e44b32 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-10 19:43:23 +08:00
zhsama 24b5387fd1 fix(workflow): streamline stopping workflow run process 2025-10-10 18:45:43 +08:00
Harry 0c65824cad fix(workflow): update API route for DraftWorkflowTriggerRunApi
- Changed the route from "/apps/<uuid:app_id>/workflows/draft/trigger/run" to "/apps/<uuid:app_id>/workflows/draft/trigger/plugin/run" to reflect the new plugin-based trigger structure.
- Updated corresponding URL in the useWorkflowRun hook to maintain consistency across the application.
2025-10-10 18:13:28 +08:00
Harry 31c9d9da3f fix(workflow): enhance response structure in DraftWorkflowTriggerRunApi
- Added a "retry_in" field to the response when no event is found, improving the API's feedback during workflow execution.
2025-10-10 18:05:50 +08:00
Harry 8f854e6a45 fix(workflow): add root_node_id to DraftWorkflowTriggerRunApi for improved response handling
- Included root_node_id in the API call to enhance the response structure during workflow execution.
2025-10-10 18:05:50 +08:00
yessenia 75b3f5ac5a feat: change subscription field 2025-10-10 17:37:20 +08:00
zhsama 323e183775 refactor(trigger): improve config value formatting in PluginTriggerNode 2025-10-10 17:28:41 +08:00
Harry 380ef52331 refactor(trigger): update API and service to use 'event' terminology
- Renamed 'trigger_name' to 'event_name' in the DraftWorkflowTriggerNodeApi for consistency with the new naming convention.
- Added 'provider_id' to the API request model to enhance functionality.
- Updated the PluginTriggerDebugEvent and TriggerDebugService to reflect changes in naming and improve address formatting.
- Adjusted frontend utility to align with the updated variable names.
2025-10-10 15:48:42 +08:00
lyzno1 b8862293b6 fix: resolve semantic conflict in TimePicker notClearable logic 2025-10-10 15:17:19 +08:00
lyzno1 85f1cf1d90 Merge branch 'main' into feat/trigger 2025-10-10 15:16:00 +08:00
lyzno1 1d4e36d58f fix: display correct icon for trigger nodes in listening panel 2025-10-10 15:04:58 +08:00
Harry 90ae5e5865 refactor(trigger): enhance update method to use explicit None checks
- Updated the `update` method in `SubscriptionBuilderUpdater` to use 'is not None' checks instead of truthy evaluations for better handling of empty values.
- This change improves clarity and ensures that empty dictionaries or strings are correctly processed during updates.
2025-10-10 14:52:03 +08:00
zhsama 755fb96a33 feat(trigger): add plugin trigger test-run handling to workflow 2025-10-10 10:43:13 +08:00
Harry b8ca480b07 refactor(trigger): update variable names for clarity and consistency
- Renamed variables related to triggers to use 'trigger' terminology consistently across the codebase.
- Adjusted filtering logic in `TriggerPluginList` to reference 'events' instead of 'triggers' for improved clarity.
- Updated the `getTriggerIcon` function to reflect the new naming conventions and ensure proper icon rendering.
2025-10-09 12:23:48 +08:00
lyzno1 8a5fbf183b Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-09 09:07:01 +08:00
Harry 91318d3d04 refactor(trigger): rename trigger references to event for consistency
- Updated variable names and types from 'trigger' to 'event' across multiple files to enhance clarity and maintain consistency in the codebase.
- Adjusted related data structures and API responses to reflect the new naming convention.
- Improved type annotations and error handling in the workflow trigger run API and associated services.
2025-10-09 03:12:35 +08:00
Harry a33d04d1ac refactor(trigger): unify debug event handling and improve polling mechanism
- Introduced a base class for debug events to streamline event handling.
- Refactored `TriggerDebugService` to support multiple event types through a generic dispatch/poll interface.
- Updated webhook and plugin trigger debug services to utilize the new event structure.
- Enhanced the dispatch logic in `dispatch_triggered_workflows_async` to accommodate the new event model.
2025-10-08 17:31:16 +08:00
lyzno1 02222752f0 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-07 18:25:43 +08:00
lyzno1 04d94e3337 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-06 19:12:16 +08:00
hjlarry b98c36db48 fix trigger related api 404 2025-10-06 14:36:07 +08:00
hjlarry d05d11e67f add webhook node draft single run 2025-10-06 14:35:12 +08:00
lyzno1 3370736e09 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-04 11:30:26 +08:00
hjlarry cc5a315039 fix pyright exception 2025-10-02 20:26:29 +08:00
hjlarry 6ea10cdaaf debug webhook don't require publish the app 2025-10-02 20:07:57 +08:00
lyzno1 9643fa1c9a fix: use StopCircle icon in variable inspect listening panel 2025-10-02 10:02:19 +08:00
lyzno1 937a58d0dd Merge remote-tracking branch 'origin/main' into feat/trigger 2025-10-02 09:18:21 +08:00
hjlarry d9faa1329a move workflow_plugin_trigger_service to trigger sub dir 2025-10-02 00:31:33 +08:00
hjlarry fec09e7ed3 move trigger_service to trigger sub dir 2025-10-02 00:29:53 +08:00
hjlarry 31b15b492e move trigger_debug_service to trigger sub dir 2025-10-02 00:27:48 +08:00
hjlarry f96bd4eb18 move schedule service to trigger sub dir 2025-10-02 00:24:32 +08:00
hjlarry a4109088c9 move webhook service to trigger sub dir 2025-10-02 00:18:37 +08:00
hjlarry f827e8e1b7 add more code comment 2025-10-02 00:14:35 +08:00
hjlarry 82f2f76dc4 ruff format code 2025-10-01 23:39:46 +08:00
hjlarry e6a44a0860 can debug when disable webhook 2025-10-01 23:39:37 +08:00
hjlarry 604651873e refactor webhook service 2025-10-01 12:46:42 +08:00
lyzno1 9114881623 fix: update frontend trigger field mapping from triggers to events
- Update TriggerProviderApiEntity type to use events field (aligned with backend commit 32f4d1af8)
- Update conversion function in use-triggers.ts to map provider.events to TriggerWithProvider.triggers
- Fix trigger-events-list.tsx to use providerInfo.events (TriggerProviderApiEntity type)
- Fix parameters-form.tsx to use provider.triggers (TriggerWithProvider type)
2025-10-01 09:53:45 +08:00
hjlarry 080cdda4fa query param of webhook backend support 2025-09-30 21:21:39 +08:00
Harry 32f4d1af8b Refactor: Rename triggers to events in trigger-related entities and services
- Updated class and variable names from 'triggers' to 'events' across multiple files to improve clarity and consistency.
- Adjusted related data structures and methods to reflect the new naming convention, including changes in API entities, service methods, and trigger management logic.
- Ensured all references to triggers are replaced with events to align with the updated terminology.
2025-09-30 20:18:33 +08:00
lyzno1 1bfa8e6662 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-09-30 18:56:21 +08:00
lyzno1 7c97ea4a9e fix: correct entry node alignment for wrapper offset
- Add ENTRY_NODE_WRAPPER_OFFSET constant (x: 0, y: 21) for Start/Trigger nodes
- Implement getNodeAlignPosition() to calculate actual inner node positions
- Fix horizontal/vertical helpline rendering to account for wrapper offset
- Fix snap-to-align logic to properly align inner nodes instead of wrapper
- Correct helpline width/height calculation by subtracting offset for entry nodes
- Ensure backward compatibility: only affects Start/Trigger nodes with EntryNodeContainer wrapper

This fix ensures that Start and Trigger nodes (which have an EntryNodeContainer wrapper
with status indicator) align based on their inner node boundaries rather than the wrapper
boundaries, matching the alignment behavior of regular nodes.
2025-09-30 18:36:49 +08:00
lyzno1 bea11b08d7 refactor: hide workflow features button in workflow mode, keep it visible in chatflow mode 2025-09-30 17:51:01 +08:00
lyzno1 8547032a87 Revert "refactor: app publisher"
This reverts commit 8feef2c1a9.
2025-09-30 17:46:27 +08:00
hjlarry 43574c852d add variable type to webhook request parameters panel 2025-09-30 16:31:21 +08:00
hjlarry 5ecc006805 add listening status for variable panel 2025-09-30 15:18:07 +08:00
lyzno1 15413108f0 chore: remove unused empty enums.py file 2025-09-30 13:52:33 +08:00
lyzno1 831c888b84 feat: sort output variables by table display order in webhook trigger 2025-09-30 12:34:09 +08:00
f0ed09a8d4 feat: add output variables display to webhook trigger node (#26478)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-30 12:26:42 +08:00
hjlarry a80f30f9ef add nginx /triggers endpoint 2025-09-30 11:08:14 +08:00
hjlarry fd2f0df097 useStore to isListening status 2025-09-30 10:48:38 +08:00
lyzno1 d72a3e1879 fix: translations 2025-09-30 10:01:33 +08:00
lyzno1 4a6903fdb4 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-09-30 08:00:16 +08:00
zxhlyh 8106df1d7d fix: types 2025-09-29 20:53:50 +08:00
Harry 5e3e6b0bd8 refactor(api): update subscription handling in trigger provider
- Replaced SubscriptionSchema with SubscriptionConstructor in various parts of the trigger provider implementation to streamline subscription management.
- Enhanced the PluginTriggerProviderController to utilize the new subscription constructor for retrieving default properties and credential schemas.
- Removed the deprecated get_provider_subscription_schema method from TriggerManager.
- Updated TriggerSubscriptionBuilderService to reflect changes in subscription handling, ensuring compatibility with the new structure.

These changes improve the clarity and maintainability of the subscription handling within the trigger provider architecture.
2025-09-29 18:28:10 +08:00
Harry a06d2892f8 fix(plugin): handle optional property in llm_description assignment
- Updated the llm_description assignment in the ToolParameter to safely access the en_US property of paramDescription, ensuring it defaults to an empty string if not present. This change improves the robustness of the parameter handling in the plugin detail panel.
2025-09-29 18:28:10 +08:00
Harry e377e90666 feat(api): add CHECKBOX parameter type to plugin and tool entities
- Introduced CHECKBOX as a new parameter type in CommonParameterType and PluginParameterType.
- Updated as_normal_type and cast_parameter_value functions to handle CHECKBOX type.
- Enhanced ToolParameter class to include CHECKBOX for consistency across parameter types.

These changes expand the parameter capabilities within the API, allowing for more versatile input options.
2025-09-29 18:28:10 +08:00
Harry 19cc67561b refactor(api): improve error handling in trigger providers
- Removed unnecessary ValueError handling in TriggerSubscriptionBuilderCreateApi and TriggerSubscriptionBuilderBuildApi, allowing for more streamlined exception management.
- Updated TriggerSubscriptionBuilderVerifyApi and TriggerSubscriptionBuilderBuildApi to raise ValueError with the original exception context for better debugging.
- Enhanced trigger_endpoint in trigger.py to log errors and return a JSON response for not found endpoints, improving user feedback and error reporting.

These changes enhance the robustness and clarity of error handling across the API.
2025-09-29 18:28:10 +08:00
hjlarry 92f2ca1866 add listening status in the run panel result 2025-09-29 17:55:53 +08:00
hjlarry 1949074e2f add shortcut for open test run panel 2025-09-29 14:39:44 +08:00
hjlarry 1c0068e95b fix can't stop webhook debug 2025-09-29 13:34:05 +08:00
lyzno1 4b43196295 feat: add specialized trigger icons to workflow logs
- Create TriggerByDisplay component with appropriate colored icons
- Add dedicated Code icon for debugging triggers (blue background)
- Add KnowledgeRetrieval icon for RAG pipeline triggers (green background)
- Use existing webhook, schedule, and plugin icons with proper colors
- Add comprehensive i18n translations for Chinese, Japanese, and English
- Integrate icon display into workflow logs table
- Follow project color standards from block-icon component
2025-09-29 12:53:35 +08:00
lyzno1 2c3cf9a25e Merge remote-tracking branch 'origin/main' into feat/trigger 2025-09-29 12:13:39 +08:00
lyzno1 67fbfc0b8f fix: adjust MoreActions menu position based on sidebar state 2025-09-29 12:13:18 +08:00
hjlarry 6e6198c64e debug webhook node 2025-09-29 09:28:19 +08:00
lyzno1 6b677c16ce refactor: use Tailwind className for MiniMap node colors instead of CSS variables 2025-09-29 08:09:38 +08:00
yessenia 973b937ba5 feat: add subscription in node 2025-09-28 22:40:31 +08:00
zhsama 48597ef193 feat: enhance minimap node color handling 2025-09-28 21:11:46 +08:00
zhsama ffbc007f82 feat(i18n): add tooltip and placeholder for callback URL in plugin-trigger translations 2025-09-28 20:13:10 +08:00
lyzno1 8fc88f8cbf Merge remote-tracking branch 'origin/main' into feat/trigger 2025-09-28 19:32:33 +08:00
zhsama a4b932c78b feat: integrate chat mode detection in ChangeBlock component 2025-09-28 17:10:09 +08:00
hjlarry 2ff4af8ce3 add debug run schedule node 2025-09-28 16:37:37 +08:00
zhsama 6795015d00 refactor: enhance type definitions and update import paths in form input and trigger components 2025-09-28 15:42:38 +08:00
zhsama b100ce15cd refactor: update import paths and remove unused props in block selector components 2025-09-28 15:21:44 +08:00
yessenia 3edf1e2f59 feat: add checkbox list 2025-09-28 15:12:17 +08:00
lyzno1 4d49db0ff9 Unify SearchBox styles with Input component and add autoFocus 2025-09-28 14:33:27 +08:00
lyzno1 7da22e4915 Add toast notifications to TriggerCard toggle operations 2025-09-28 14:21:51 +08:00
lyzno1 8d4a9df6b1 fix: more button dropdown menu visibility and auto-close behavior 2025-09-28 14:15:33 +08:00
lyzno1 f620e78b20 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-09-28 13:40:46 +08:00
hjlarry 8df80781d9 _node_type has changed to node_type in new version 2025-09-28 09:36:45 +08:00
hjlarry edec065fee fix can't subtract offset-naive and offset-aware datetimes 2025-09-28 09:10:21 +08:00
lyzno1 0fe529c3aa Merge remote-tracking branch 'origin/main' into feat/trigger 2025-09-27 21:32:38 +08:00
zhsama bcfdd07f85 feat(plugin): enhance trigger events list with dynamic tool integration
- Refactor TriggerEventsList component to utilize provider information for dynamic tool rendering.
- Implement locale-aware text handling for trigger descriptions and labels.
- Introduce utility functions for better management of tool parameters and trigger descriptions.
- Improve user experience by ensuring consistent display of trigger events based on available provider data.

This update enhances the functionality and maintainability of the trigger events list, aligning with the project's metadata-driven approach.
2025-09-26 23:27:27 +08:00
lyzno1 a9a118aaf9 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-09-26 22:48:15 +08:00
lyzno1 60c86dd8d1 fix(workflow): replace hardcoded trigger node logic with metadata-driven approach
- Add isStart: true to all trigger nodes (TriggerWebhook, TriggerSchedule, TriggerPlugin)
- Replace hardcoded BlockEnum checks in use-checklist.ts with metadata-driven logic
- Update trigger node tests to validate metadata instead of obsolete methods
- Add webhook URL validation to TriggerWebhook node
- Ensure backward compatibility with existing workflow configurations

This change migrates from hardcoded trigger node identification to a
centralized metadata-driven approach, improving maintainability and
consistency across the workflow system.
2025-09-26 22:35:21 +08:00
lyzno1 8feef2c1a9 refactor: app publisher 2025-09-26 22:06:05 +08:00
lyzno1 4ba99db88c feat: Restore complete test run functionality and fix workflow block selector system
This comprehensive restore includes:

## Test Run System Restoration
- Restore test-run-menu.tsx component with multi-trigger support and keyboard shortcuts
- Restore use-dynamic-test-run-options.tsx hook for dynamic trigger option generation
- Restore workflow-entry.ts utilities for entry node detection and validation
- Integrate complete test run functionality back into run-mode.tsx

## Block Selector System Fixes
- Fix workflow block selector constants by uncommenting BLOCKS and START_BLOCKS arrays
- Restore proper i18n translations for trigger node descriptions using workflow.blocksAbout keys
- Filter trigger types from Blocks tab to prevent duplication with Start tab
- Fix trigger node handle display to match start node behavior (hide left input handles)

## Workflow Validation System Improvements
- Restore unified workflow validation using correct getValidTreeNodes(nodes, edges) signature
- Remove duplicate Start node validation from isRequired mechanism
- Eliminate "user input must be added" validation error by setting Start node isRequired: false
- Fix end node connectivity validation to properly detect valid workflow chains

## Component Integration
- Verify all dependencies exist (TriggerAll icon, useAllTriggerPlugins hook)
- Maintain keyboard shortcut integration (Alt+R, ~, 0-9 keys)
- Preserve portal-based dropdown positioning and tooltip structure
- Support multiple trigger types: user_input, schedule, webhook, plugin, all

This restores the complete test run functionality that was missing from feat/trigger branch
by systematically analyzing and restoring components from feat/trigger-backup-before-merge.
2025-09-26 21:34:08 +08:00
lyzno1 b4801adfbd refactor(workflow): Remove Start node from isRequired mechanism
- Set Start node isRequired: false since entry node validation is handled by unified logic
- Remove conditional skip logic in checklist since Start is no longer in isRequiredNodesType
- Cleaner separation of concerns: unified entry node check vs individual required nodes
- Eliminates architectural inconsistency where Start was both individually required and part of group validation
2025-09-26 21:09:48 +08:00
lyzno1 08e8f8676e fix(workflow): Remove duplicate Start node validation
- Skip Start node requirement in isRequiredNodesType loop since it's already covered by unified entry node validation
- Eliminates duplicate 'User Input must be added' error when trigger nodes are present
- Both useChecklist and useChecklistBeforePublish now consistently handle entry node validation
- Resolves UI showing redundant validation errors for Start vs Trigger nodes
2025-09-26 21:08:21 +08:00
lyzno1 2dca0c20db fix: restore unified workflow validation system
Major fixes to workflow checklist validation:

## Fixed getValidTreeNodes function (workflow.ts)
- Restore original function signature: (nodes, edges) instead of (startNode, nodes, edges)
- Re-implement automatic start node discovery for all entry types
- Unified traversal from Start, TriggerWebhook, TriggerSchedule, TriggerPlugin nodes
- Single call now discovers all valid connected nodes correctly

## Simplified useChecklist validation (use-checklist.ts)
- Remove complex manual start node iteration and result aggregation
- Unified entry node validation concept for all start node types
- Remove dependency on getStartNodes() utility
- Simplified validation logic matching backup branch approach

## Resolved Issues
-  End node connectivity: Now correctly detects connections from any entry node
-  Unified entry validation: All start types (Start/Triggers) validated consistently
-  Simplified architecture: Restored proven validation approach from backup branch

This restores the reliable workflow validation system while maintaining trigger node support.
2025-09-26 20:54:28 +08:00
lyzno1 6f57aa3f53 fix: hide left input handles for all trigger node types
- Extend handle hiding logic to include TriggerWebhook, TriggerSchedule, TriggerPlugin
- Make trigger nodes behave like Start nodes without left-side input handles
- Apply fix to both main workflow and preview node handle components
- Ensures consistent UX where all start-type nodes have no input handles
2025-09-26 20:39:29 +08:00
lyzno1 1aafe915e4 fix: trigger tooltip descriptions and filter trigger types from Nodes tab
- Fix trigger tooltip descriptions to use workflow.blocksAbout translations
- Filter TriggerWebhook/TriggerSchedule/TriggerPlugin from Blocks component
- Ensure trigger types only appear in Start tab, not Nodes tab
2025-09-26 20:28:59 +08:00
lyzno1 6d4d25ee6f feat(workflow): Restore block selector functionality
- Restore BLOCKS constant array and useBlocks hook
- Add intelligent fallback mechanism for blocks prop
- Fix metadata access in StartBlocks tooltip
- Restore defaultActiveTab support in NodeSelector
- Improve component robustness with graceful degradation
- Fix TypeScript errors and component interfaces

Phase 1-3 of atomic refactoring complete:
- Critical fixes: Constants, hooks, components
- Interface fixes: Props, tabs, modal integration
- Architecture improvements: Metadata, wrappers
2025-09-26 20:05:59 +08:00
yessenia 6b94d30a5f fix: oauth subscription 2025-09-26 17:44:57 +08:00
lyzno1andGitHub 1a9798c559 fix(workflow): Fix onboarding node creation after knowledge pipeline refactor (#26289) 2025-09-26 16:43:36 +08:00
lyzno1andClaude 764436ed8e feat(workflow): Enable keyboard delete for all node types including Start
Removes explicit Start node exclusion from handleNodesDelete function:
- Remove BlockEnum.Start filter from bundled nodes selection
- Remove BlockEnum.Start filter from selected node detection
- Allows DEL/Backspace keys to delete Start nodes same as other nodes
- Button delete already worked, now keyboard delete works too

Fixes: Start nodes can now be deleted via both button and keyboard shortcuts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 14:10:28 +08:00
lyzno1andClaude 2a1c5ff57b feat(workflow): Enable entry node deletion and fix draft sync
Complete workflow liberalization following PR #24627:

1. Remove Start node deletion restriction by removing isUndeletable property
2. Fix draft sync blocking when no Start node exists
3. Restore isWorkflowDataLoaded protection to prevent race conditions
4. Ensure all entry nodes (Start + 3 trigger types) have equal deletion rights

This allows workflows with only trigger nodes and fixes the issue where
added nodes would disappear after page refresh due to sync API blocking.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 13:54:52 +08:00
lyzno1 cc4ba1a3a9 chore: add settings.local.json to .gitignore 2025-09-26 13:26:51 +08:00
lyzno1andClaude d68a9f1850 Merge remote-tracking branch 'origin/main' into feat/trigger
Resolve merge conflict in use-workflow.ts:
- Keep trigger branch workflow-entry utilities imports
- Preserve SUPPORT_OUTPUT_VARS_NODE from main branch
- Remove unused PARALLEL_DEPTH_LIMIT import

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 13:17:14 +08:00
Harry 4f460160d2 refactor(api): reorganize migration files 2025-09-26 12:31:44 +08:00
Harry d5ff89f6d3 refactor(api): enhance request handling and time management
- Initialized `response` variable in `trigger.py` to ensure proper handling in the trigger endpoint.
- Updated `http_parser.py` to conditionally set `CONTENT_TYPE` and `CONTENT_LENGTH` headers for improved robustness.
- Changed `datetime.utcnow()` to `datetime.now(UTC)` in `sqlalchemy_workflow_trigger_log_repository.py` and `rate_limiter.py` for consistent time zone handling.
- Refactored `async_workflow_service.py` to use the public method `get_tenant_owner_timezone` for better encapsulation.
- Simplified subscription retrieval logic in `plugin_parameter_service.py` for clarity.

These changes improve code reliability and maintainability while ensuring accurate time management and request processing.
2025-09-25 19:46:52 +08:00
Harry 452588dded refactor(api): fix pyright check
- Replaced `is_editor` checks with `has_edit_permission` in `workflow_trigger.py` and `workflow.py` to enhance clarity and consistency in permission handling.
- Updated the rate limiter to use `datetime.now(UTC)` instead of `datetime.utcnow()` for accurate time handling.
- Added `__all__` declaration in `trigger/__init__.py` for better module export management.
- Initialized `debug_dispatched` variable in `trigger_processing_tasks.py` to ensure proper tracking during workflow dispatching.

These changes improve code readability and maintainability while ensuring correct permission checks and time management.
2025-09-25 18:32:22 +08:00
Harry aef862d9ce refactor(api): remove unused PluginTriggerApi route
- Removed the `PluginTriggerApi` resource route from `workflow_trigger.py` to streamline the API and improve maintainability. This change contributes to a cleaner and more organized codebase.
2025-09-25 18:23:17 +08:00
Harry 896f3252b8 refactor(api): refactor all
- Replaced direct imports of `TriggerProviderID` and `ToolProviderID` from `core.plugin.entities.plugin` with imports from `models.provider_ids` for better organization.
- Refactored workflow node classes to inherit from a unified `Node` class, improving consistency and maintainability.
- Removed unused code and comments to clean up the implementation, particularly in the `workflow_trigger.py` and `builtin_tools_manage_service.py` files.

These changes enhance the clarity and structure of the codebase, facilitating easier future modifications.
2025-09-25 18:22:30 +08:00
yessenia 6853a699e1 Merge branch 'main' into feat/trigger 2025-09-25 17:43:39 +08:00
yessenia cd07eef639 Merge remote-tracking branch 'origin/main' into feat/trigger 2025-09-25 17:14:24 +08:00
Harry ef9a741781 feat(trigger): enhance trigger management with new error handling and response structure
- Added `TriggerInvokeError` and `TriggerIgnoreEventError` for better error categorization during trigger invocation.
- Updated `TriggerInvokeResponse` to include a `cancelled` field, indicating if a trigger was ignored.
- Enhanced `TriggerManager` to handle specific errors and return appropriate responses.
- Refactored `dispatch_triggered_workflows` to improve workflow execution logic and error handling.

These changes improve the robustness and clarity of the trigger management system.
2025-09-23 16:01:59 +08:00
Harry c5de91ba94 refactor(trigger): update cache expiration constants and log key format
- Renamed validation-related constants to builder-related ones for clarity.
- Updated cache expiration from milliseconds to seconds for consistency.
- Adjusted log key format to reflect the builder context instead of validation.

These changes enhance the readability and maintainability of the TriggerSubscriptionBuilderService.
2025-09-22 13:37:46 +08:00
Harry bc1e6e011b fix(trigger): update cache key format in TriggerSubscriptionBuilderService
- Changed the cache key format in the `encode_cache_key` method from `trigger:subscription:validation:{subscription_id}` to `trigger:subscription:builder:{subscription_id}` to better reflect its purpose.

This update improves clarity in cache key usage for trigger subscriptions.
2025-09-22 13:37:46 +08:00
lyzno1 906028b1fb fix: start node validation 2025-09-22 12:58:20 +08:00
lyzno1andGitHub 034602969f feat(schedule-trigger): enhance cron parser with mature library and comprehensive testing (#26002) 2025-09-22 10:01:48 +08:00
非法操作andGitHub 4ca14bfdad chore: improve webhook (#25998) 2025-09-21 12:16:31 +08:00
lyzno1andGitHub 59f56d8c94 feat: schedule trigger default daily midnight (#25937) 2025-09-19 08:05:00 +08:00
yessenia 63d26f0478 fix: api key params 2025-09-18 17:35:34 +08:00
yessenia eae65e55ce feat: oauth config opt & add dynamic options 2025-09-18 17:12:48 +08:00
lyzno1andlyzno1 0edf06329f fix: apply suggestions 2025-09-18 17:04:02 +08:00
lyzno1andlyzno1 6943a379c9 feat: show placeholder '--' for invalid cron expressions in node display
- Return '--' placeholder when cron mode has empty or invalid expressions
- Prevents displaying fallback dates that confuse users
- Maintains consistent UX for invalid schedule configurations
2025-09-18 17:04:02 +08:00
lyzno1andlyzno1 e49534b70c fix: make frequency optional 2025-09-18 17:04:02 +08:00
lyzno1andlyzno1 344616ca2f fix: clear opposite mode data only when editing, preserve data during mode switching 2025-09-18 17:04:02 +08:00
lyzno1 0e287a9c93 chore: add missing translations 2025-09-18 13:25:57 +08:00
lyzno1 8141f53af5 fix: add preventDefaultSubmit prop to BaseForm to prevent unwanted page refresh on Enter key 2025-09-18 12:48:26 +08:00
lyzno1 5a6cb0d887 feat: enhance API key modal step indicator with active dots and improved styling 2025-09-18 12:44:11 +08:00
lyzno1 26e7677595 fix: align width and use rounded xl 2025-09-18 12:08:21 +08:00
yessenia 814b0e1fe8 feat: oauth config init 2025-09-18 00:00:50 +08:00
Harry a173dc5c9d feat(provider): add multiple option support in ProviderConfig
- Introduced a new field `multiple` in the `ProviderConfig` class to allow for multiple selections, enhancing the configuration capabilities for providers.
- This addition improves flexibility in provider settings and aligns with the evolving requirements for provider configurations.
2025-09-17 22:12:01 +08:00
Harry a567facf2b refactor(trigger): streamline encrypter creation in TriggerProviderService
- Replaced calls to `create_trigger_provider_encrypter` and `create_trigger_provider_oauth_encrypter` with a unified `create_provider_encrypter` method, simplifying the encrypter creation process.
- Updated the parameters passed to the new method to enhance configuration management and cache handling.

These changes improve code clarity and maintainability in the trigger provider service.
2025-09-17 21:47:11 +08:00
Harry e76d80defe fix(trigger): update client parameter handling in TriggerProviderService
- Modified the `create_provider_encrypter` call to include a cache assignment, ensuring proper management of encryption resources.
- Added a cache deletion step after updating client parameters, enhancing the integrity of the parameter handling process.

These changes improve the reliability of client parameter updates within the trigger provider service.
2025-09-17 20:57:52 +08:00
Harry 4a17025467 fix(trigger): update session management in TriggerProviderService
- Changed session management in `TriggerProviderService` from `autoflush=True` to `expire_on_commit=False` for improved control over session state.
- This change enhances the reliability of database interactions by preventing automatic expiration of objects after commit, ensuring data consistency during trigger operations.

These updates contribute to better session handling and stability in trigger-related functionalities.
2025-09-16 18:01:44 +08:00
Harry bd1fcd3525 feat(trigger): add TriggerProviderInfoApi and enhance trigger provider service
- Introduced `TriggerProviderInfoApi` to retrieve information for a specific trigger provider, improving API capabilities.
- Added `get_trigger_provider` method in `TriggerProviderService` to fetch trigger provider details, enhancing data retrieval.
- Updated route configurations to include the new API endpoint for trigger provider information.

These changes enhance the functionality and usability of trigger provider interactions within the application.
2025-09-16 17:03:52 +08:00
Harry 0cb0cea167 feat(trigger): enhance trigger plugin data structure and error handling
- Added `plugin_unique_identifier` to `PluginTriggerData` and `TriggerProviderApiEntity` to improve identification of trigger plugins.
- Introduced `PluginTriggerDispatchData` for structured dispatch data in Celery tasks, enhancing the clarity of trigger dispatching.
- Updated `dispatch_triggered_workflows_async` to utilize the new dispatch data structure, improving error handling and logging for trigger invocations.
- Enhanced metadata handling in `TriggerPluginNode` to include trigger information, aiding in debugging and tracking.

These changes improve the robustness and maintainability of trigger plugin interactions within the workflow system.
2025-09-16 15:39:40 +08:00
Harry ee68a685a7 fix(workflow): enforce non-nullable arguments in DraftWorkflowTriggerRunApi
- Updated the argument definitions in the DraftWorkflowTriggerRunApi to include `nullable=False` for `node_id`, `trigger_name`, and `subscription_id`. This change ensures that these fields are always provided in the request, improving the robustness of the API.

This fix enhances input validation and prevents potential errors related to missing arguments.
2025-09-16 11:25:16 +08:00
Harry c78bd492af feat(trigger): add supported creation methods to TriggerProviderApiEntity
- Introduced a new field `supported_creation_methods` in `TriggerProviderApiEntity` to specify the available methods for creating triggers, including OAUTH, APIKEY, and MANUAL.
- Updated the `PluginTriggerProviderController` to populate this field based on the entity's schemas, enhancing the API's clarity and usability.

These changes improve the flexibility and configurability of trigger providers within the application.
2025-09-15 17:01:29 +08:00
Harry 6857bb4406 feat(trigger): implement plugin trigger synchronization and subscription management in workflow
- Added a new event handler for syncing plugin trigger relationships when a draft workflow is synced, ensuring that the database reflects the current state of plugin triggers.
- Introduced subscription management features in the frontend, allowing users to select, add, and remove subscriptions for trigger plugins.
- Updated various components to support subscription handling, including the addition of new UI elements for subscription selection and removal.
- Enhanced internationalization support by adding new translation keys related to subscription management.

These changes improve the overall functionality and user experience of trigger plugins within workflows.
2025-09-15 15:49:07 +08:00
Harry dcf3ee6982 fix(trigger): update trigger label assignment for improved clarity
- Changed the label assignment in the convertToTriggerWithProvider function from trigger.description.human to trigger.identity.label, ensuring the label reflects the correct identity format.

This update enhances the accuracy of trigger data representation in the application.
2025-09-15 14:50:56 +08:00
Harry 76850749e4 feat(trigger): enhance trigger debugging with polling API and new subscription retrieval
- Refactored DraftWorkflowTriggerNodeApi and DraftWorkflowTriggerRunApi to implement polling for trigger events instead of listening, improving responsiveness and reliability.
- Introduced TriggerSubscriptionBuilderGetApi to retrieve subscription instances for trigger providers, enhancing the API's capabilities.
- Removed deprecated trigger event classes and streamlined event handling in TriggerDebugService, ensuring a cleaner architecture.
- Updated Queue and Stream entities to reflect the changes in trigger event handling, improving overall clarity and maintainability.

These enhancements significantly improve the trigger debugging experience and API usability.
2025-09-14 19:12:31 +08:00
yessenia 91e5e33440 feat: add modal style opt 2025-09-12 20:22:33 +08:00
lyzno1andGitHub 11e55088c9 fix: restore id prop passing to node children in BaseNode (#25520) 2025-09-11 17:54:31 +08:00
Harry 57c0bc9fb6 feat(trigger): refactor trigger debug event handling and improve response structures
- Renamed and refactored trigger debug event classes to enhance clarity and consistency, including changes from `TriggerDebugEventData` to `TriggerEventData` and related response classes.
- Updated `DraftWorkflowTriggerNodeApi` and `DraftWorkflowTriggerRunApi` to utilize the new event structures, improving the handling of trigger events.
- Removed the `TriggerDebugEventGenerator` class, consolidating event generation directly within the API logic for streamlined processing.
- Enhanced error handling and response formatting for trigger events, ensuring structured outputs for better integration and debugging.

This refactor improves the overall architecture of trigger debugging, making it more intuitive and maintainable.
2025-09-11 16:55:58 +08:00
Harry c3ebb22a4b feat(trigger): add workflows_in_use field to TriggerProviderSubscriptionApiEntity
- Introduced a new field `workflows_in_use` to the TriggerProviderSubscriptionApiEntity to track the number of workflows utilizing each subscription.
- Enhanced the TriggerProviderService to populate this field by querying the WorkflowPluginTrigger model for usage counts associated with each subscription.

This addition improves the visibility of subscription usage within the trigger provider context.
2025-09-11 16:55:58 +08:00
Harry 1562d00037 feat(trigger): implement trigger debugging functionality
- Added DraftWorkflowTriggerNodeApi and DraftWorkflowTriggerRunApi for debugging trigger nodes and workflows.
- Enhanced TriggerDebugService to manage trigger debugging sessions and event listening.
- Introduced structured event responses for trigger debugging, including listening started, received, node finished, and workflow started events.
- Updated Queue and Stream entities to support new trigger debug events.
- Refactored trigger input handling to streamline the process of creating inputs from trigger data.

This implementation improves the debugging capabilities for trigger nodes and workflows, providing clearer event handling and structured responses.
2025-09-11 16:55:58 +08:00
Harry e9e843b27d fix(tool): standardize tool naming across components
- Updated references from `trigger_name` to `tool_name` in multiple components for consistency.
- Adjusted type definitions to reflect the change in naming convention, enhancing clarity in the codebase.
2025-09-11 16:55:57 +08:00
Harry ec33b9908e fix(trigger): improve formatting of OAuth client response in TriggerOAuthClientManageApi
- Refactored the return statement in the TriggerOAuthClientManageApi to enhance readability and maintainability.
- Ensured consistent formatting of the response structure for better clarity in API responses.
2025-09-11 16:55:57 +08:00
yessenia 67004368d9 feat: sub card style 2025-09-11 16:22:59 +08:00
Stream 94ecbd44e4 feat: add API endpoint to extract plugin assets 2025-09-11 14:48:42 +08:00
Stream ba76312248 feat: adapt to plugin_daemon endpoint 2025-09-11 14:46:12 +08:00
yessenia 50bff270b6 feat: add subscription 2025-09-10 23:21:33 +08:00
Harry bd5cf1c272 fix(trigger): enhance OAuth client response in TriggerOAuthClientManageApi
- Integrated TriggerManager to retrieve the trigger provider's OAuth client schema.
- Updated the return structure to include the redirect URI and OAuth client schema for improved API response clarity.
2025-09-10 17:35:30 +08:00
Yeuoly d22404994a chore: add comments on generate_webhook_id 2025-09-10 17:23:29 +08:00
YeuolyandClaude 9898730cc5 feat: add webhook node limit validation (max 5 per workflow)
- Add MAX_WEBHOOK_NODES_PER_WORKFLOW constant set to 5
- Validate webhook node count in sync_webhook_relationships method
- Raise ValueError when workflow exceeds webhook node limit
- Block workflow save when limit is exceeded to ensure data integrity
- Provide clear error message indicating current count and maximum allowed

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 17:22:09 +08:00
YeuolyandClaude b0f1e55a87 refactor: remove triggered_by field from webhook triggers and use automatic sync
- Remove triggered_by field from WorkflowWebhookTrigger model
- Replace manual webhook creation/deletion APIs with automatic sync via WebhookService
- Keep only GET API for retrieving webhook information
- Use same webhook ID for both debug and production environments (differentiated by endpoint)
- Add sync_webhook_relationships to automatically manage webhook lifecycle
- Update tests to remove triggered_by references
- Clean up unused imports and fix type checking issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 17:17:19 +08:00
Harry 6566824807 fix(trigger): update return type in TriggerSubscriptionBuilderService
- Changed the return type of the method in `TriggerSubscriptionBuilderService` from `SubscriptionBuilder` to `SubscriptionBuilderApiEntity` for improved clarity and alignment with API entity structures.
- Updated the return statement to utilize the new method for converting the builder to the API entity.
2025-09-10 15:48:32 +08:00
Harry 9249a2af0d fix(trigger): update event data publishing in TriggerDebugService
- Changed the event data publishing method in `TriggerDebugService` to use `model_dump()` for improved data structure handling when publishing to Redis Pub/Sub.
2025-09-10 15:48:32 +08:00
Yeuoly 112fc3b1d1 fix: clear schedule config when exporting data 2025-09-10 13:50:37 +08:00
Yeuoly 37299b3bd7 fix: rename migration 2025-09-10 13:41:50 +08:00
Yeuoly 8f65ce995a fix: migrations 2025-09-10 13:38:34 +08:00
诗浓GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4a743e6dc1 feat: add workflow schedule trigger support (#24428)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-10 13:24:23 +08:00
lyzno1andGitHub 07dda61929 fix/tooltip and onboarding ui (#25451) 2025-09-10 10:40:14 +08:00
Harry 0d8438ef40 fix(trigger): add 'trigger' category key to plugin constants for error avoid 2025-09-10 10:34:33 +08:00
Yeuoly 96bb638969 fix: limits 2025-09-09 23:32:51 +08:00
lyzno1andGitHub e74962272e fix: only workflow use trigger api (#25443) 2025-09-09 23:14:10 +08:00
Harry 5a15419baf feat(trigger): implement debug session capabilities for trigger nodes
- Added `DraftWorkflowTriggerNodeApi` to handle debugging of trigger nodes, allowing for real-time event listening and session management.
- Introduced `TriggerDebugService` for managing debug sessions and event dispatching using Redis Pub/Sub.
- Updated `TriggerService` to support dispatching events to debug sessions and refactored related methods for improved clarity and functionality.
- Enhanced data structures in `request.py` and `entities.py` to accommodate new debug event data requirements.

These changes significantly improve the debugging capabilities for trigger nodes in draft workflows, facilitating better development and troubleshooting processes.
2025-09-09 21:27:31 +08:00
Harry e8403977b9 feat(plugin): add triggers field to PluginDeclaration for enhanced functionality
- Introduced a new `triggers` field in the `PluginDeclaration` class to support trigger functionalities within plugins.
- This addition improves the integration of triggers in the plugin architecture, aligning with recent updates to the trigger entity structures.

These changes enhance the overall capabilities of the plugin system.
2025-09-09 17:22:11 +08:00
Harry add2ca85f2 refactor(trigger): update plugin and trigger entity structures
- Removed unnecessary newline in `TriggerPluginNode` class for consistency.
- Made `provider` in `TriggerIdentity` optional to enhance flexibility.
- Added `trigger` field to `PluginDeclaration` and updated `PluginCategory` to include `Trigger`, improving the integration of trigger functionalities within the plugin architecture.

These changes streamline the entity definitions and enhance the overall structure of the trigger and plugin components.
2025-09-09 17:16:44 +08:00
lyzno1GitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
fbb7b02e90 fix(webhook): prevent SimpleSelect from resetting user selections (#25423)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-09-09 17:11:11 +08:00
lyzno1andGitHub 249b62c9de fix: workflow header (#25411) 2025-09-09 15:34:15 +08:00
lyzno1andGitHub b433322e8d feat/trigger plugin apikey (#25388) 2025-09-09 15:01:06 +08:00
lyzno1andGitHub 1c8850fc95 feat: adjust scroll to selected node position to top-left area (#25403) 2025-09-09 14:58:42 +08:00
Harry dc16f1b65a refactor(trigger): simplify provider path handling in workflow components
- Updated various components to directly use `provider.name` instead of constructing a path with `provider.plugin_id` and `provider.name`.
- Adjusted related calls to `invalidateSubscriptions` and other functions to reflect this change.

These modifications enhance code clarity and streamline the handling of provider information in the trigger plugin components.
2025-09-09 00:17:20 +08:00
Harry ff30395dc1 fix(OAuthClientConfigModal): simplify provider path handling in OAuth configuration
- Updated the provider path handling in `OAuthClientConfigModal` to directly use `provider.name` instead of constructing a path with `provider.plugin_id` and `provider.name`.
- Adjusted the corresponding calls to `invalidateOAuthConfig` and `configureTriggerOAuth` to reflect this change.

These modifications enhance code clarity and streamline the OAuth configuration process in the trigger plugin component.
2025-09-09 00:10:04 +08:00
Harry 8e600f3302 feat(trigger): optimize trigger parameter schema handling in useConfig
- Refactored the trigger parameter schema construction in `useConfig` to utilize a Map for improved efficiency and clarity.
- Updated the return value to ensure unique schema entries, enhancing the integrity of the trigger configuration.

These changes streamline the management of trigger parameters, improving performance and maintainability in the workflow component.
2025-09-08 23:39:44 +08:00
Harry 5a1e0a8379 feat(FormInputItem): enhance UI components for improved user experience
- Added loading indicator using `RiLoader4Line` to `FormInputItem` for better feedback during option fetching.
- Refactored button and option styles for improved accessibility and visual consistency.
- Updated text color classes to enhance readability based on loading state and selection.

These changes improve the overall user experience and visual clarity of the form input components.
2025-09-08 23:19:33 +08:00
Harry 2a3ce6baa9 feat(trigger): enhance plugin and trigger integration with updated naming conventions
- Refactored `PluginFetchDynamicSelectOptionsApi` to replace the `extra` argument with `credential_id`, improving clarity in dynamic option fetching.
- Updated `ProviderConfigEncrypter` to rename `mask_tool_credentials` to `mask_credentials` for consistency, and added a new method to maintain backward compatibility.
- Enhanced `PluginParameterService` to utilize `credential_id` for fetching subscriptions, improving the handling of trigger credentials.
- Adjusted various components and types in the frontend to replace `tool_name` with `trigger_name`, ensuring consistency across the application.
- Introduced `multiple` property in `TriggerParameter` to support multi-select functionality.

These changes improve the integration of triggers and plugins, enhance code clarity, and align naming conventions across the codebase.
2025-09-08 23:14:50 +08:00
Harry 01b2f9cff6 feat: add providerType prop to form components for dynamic behavior
- Introduced `providerType` prop in `FormInputItem`, `ToolForm`, and `ToolFormItem` components to support both 'tool' and 'trigger' types, enhancing flexibility in handling different provider scenarios.
- Updated the `useFetchDynamicOptions` function to accept `provider_type` as 'tool' | 'trigger', allowing for more dynamic option fetching based on the provider type.

These changes improve the adaptability of the form components and streamline the integration of different provider types in the workflow.
2025-09-08 18:29:48 +08:00
Harry ac38614171 refactor(trigger): streamline trigger provider verification and update imports
- Updated `TriggerSubscriptionBuilderVerifyApi` to directly return the result of `verify_trigger_subscription_builder`, improving clarity.
- Refactored import statement in `trigger_plugin/__init__.py` to point to the correct module, enhancing code organization.
- Removed the obsolete `node.py` file, cleaning up the codebase by eliminating unused components.

These changes enhance the maintainability and clarity of the trigger provider functionality.
2025-09-08 18:25:04 +08:00
Harry eb95c5cd07 feat(trigger): enhance subscription builder management and update API
- Introduced `SubscriptionBuilderUpdater` class to streamline updates to subscription builders, encapsulating properties like name, parameters, and credentials.
- Refactored API endpoints to utilize the new updater class, improving code clarity and maintainability.
- Adjusted OAuth handling to create and update subscription builders more effectively, ensuring proper credential management.

This change enhances the overall functionality and organization of the trigger subscription builder API.
2025-09-08 15:09:47 +08:00
lyzno1andGitHub a799b54b9e feat: initialize trigger status at application level to prevent canvas refresh state issues (#25329) 2025-09-08 09:34:28 +08:00
lyzno1andGitHub 98ba0236e6 feat: implement trigger plugin authentication UI (#25310) 2025-09-07 21:53:22 +08:00
lyzno1andGitHub b6c552df07 fix: add stable sorting for trigger list to prevent position changes (#25328) 2025-09-07 21:52:41 +08:00
lyzno1andGitHub e2827e475d feat: implement trigger-plugin support with real-time status sync (#25326) 2025-09-07 21:29:53 +08:00
lyzno1andGitHub 58cbd337b5 fix: improve test run menu and checklist ui (#25300) 2025-09-06 22:54:36 +08:00
lyzno1andGitHub a91e59d544 feat: implement trigger plugin frontend integration (#25283) 2025-09-06 16:18:46 +08:00
Harry 814787677a feat(trigger): update plugin trigger API and model to use trigger_name
- Modified `PluginTriggerApi` to accept `trigger_name` as a JSON argument and return encoded plugin triggers.
- Updated `WorkflowPluginTrigger` model to replace `trigger_id` with `trigger_name` for better clarity.
- Adjusted `WorkflowPluginTriggerService` to handle the new `trigger_name` field and ensure proper error handling for subscriptions.
- Enhanced `workflow_trigger_fields` to include `trigger_name` in the plugin trigger schema.

This change improves the API's clarity and aligns the model with the updated naming conventions.
2025-09-05 15:56:13 +08:00
Harry 85caa5bd0c fix(trigger): clean up whitespace in encryption utility and trigger provider service
- Removed unnecessary blank lines in `encryption.py` and `trigger_provider_service.py` for improved code readability.
- This minor adjustment enhances the overall code quality without altering functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-09-05 15:56:13 +08:00
lyzno1andGitHub e04083fc0e feat: add icon support for trigger plugin workflow nodes (#25241) 2025-09-05 15:50:54 +08:00
HarryandClaude cf532e5e0d feat(trigger): add context caching for trigger providers
- Add plugin_trigger_providers and plugin_trigger_providers_lock to contexts module
- Implement caching mechanism in TriggerManager.get_trigger_provider() method
- Cache fetched trigger providers to reduce repeated daemon calls
- Use double-check locking pattern for thread-safe cache access

This follows the same pattern as ToolManager.get_plugin_provider() to improve performance
by avoiding redundant requests to the daemon when accessing trigger providers.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:30:10 +08:00
Harry c097fc2c48 refactor(trigger): add uuid import to trigger provider service
- Imported `uuid` in `trigger_provider_service.py` to support unique identifier generation.
- This change prepares the service for future enhancements that may require UUID functionality.
2025-09-05 14:30:10 +08:00
Harry 0371d71409 feat(trigger): enhance trigger subscription management and cache handling
- Added `name` parameter to `TriggerSubscriptionBuilderCreateApi` for better subscription identification.
- Implemented `delete_cache_for_subscription` function to clear cache associated with trigger subscriptions.
- Updated `WorkflowPluginTriggerService` to check for existing subscriptions before creating new plugin triggers, improving error handling.
- Refactored `TriggerProviderService` to utilize the new cache deletion method during provider deletion.

This improves the overall management of trigger subscriptions and enhances cache efficiency.
2025-09-05 14:30:10 +08:00
非法操作andGitHub 81ef7343d4 chore: (trigger) refactor webhook service (#25229) 2025-09-05 14:00:20 +08:00
zhangxuhe1andGitHub 8e4b59c90c feat: improve trigger plugin UI layout and responsiveness (#25232) 2025-09-05 14:00:14 +08:00
非法操作andGitHub 68f73410fc chore: (trigger) add WEBHOOK_REQUEST_BODY_MAX_SIZE (#25217) 2025-09-05 12:23:11 +08:00
lyzno1andGitHub 88af8ed374 fix: block selector ui (#25228) 2025-09-05 12:22:13 +08:00
Harry 015f82878e feat(trigger): integrate plugin icon retrieval into trigger provider
- Added `get_plugin_icon_url` method in `PluginService` to fetch plugin icons.
- Updated `PluginTriggerProviderController` to use the new method for icon handling.
- Refactored `ToolTransformService` to utilize `PluginService` for consistent icon URL generation.

This enhances the trigger provider's ability to manage plugin icons effectively.
2025-09-05 12:01:41 +08:00
Harry 3874e58dc2 refactor(trigger): enhance trigger provider deletion process and session management 2025-09-05 11:31:57 +08:00
lyzno1andGitHub 9f8c159583 feat(trigger): implement trigger plugin block selector following tools pattern (#25204) 2025-09-05 10:20:47 +08:00
非法操作andGitHub d8f6f9ce19 chore: (trigger)change content type from form to application/octet-stream (#25167) 2025-09-05 09:54:07 +08:00
Harry eab03e63d4 refactor(trigger): rename request logs API and enhance logging functionality
- Renamed `TriggerSubscriptionBuilderRequestLogsApi` to `TriggerSubscriptionBuilderLogsApi` for clarity.
- Updated the API endpoint to retrieve logs for subscription builders.
- Enhanced logging functionality in `TriggerSubscriptionBuilderService` to append and list logs more effectively.
- Refactored trigger processing tasks to improve naming consistency and clarity in logging.

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-09-04 21:11:25 +08:00
非法操作andGitHub 461829274a feat: (trigger) support file upload in webhook (#25159) 2025-09-04 18:33:42 +08:00
Harry e751c0c535 refactor(trigger): update trigger provider API and clean up unused classes
- Renamed the API endpoint for trigger providers from `/workspaces/current/trigger-providers` to `/workspaces/current/triggers` for consistency.
- Removed unused `TriggerProviderCredentialsCache` and `TriggerProviderOAuthClientParamsCache` classes to streamline the codebase.
- Enhanced the `TriggerProviderApiEntity` to include additional properties and improved the conversion logic in `PluginTriggerProviderController`.

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-09-04 17:45:15 +08:00
lyzno1andGitHub 1fffc79c32 fix: prevent empty workflow draft sync during page navigation (#25140) 2025-09-04 17:13:49 +08:00
非法操作andGitHub 83fab4bc19 chore: (webhook) when content type changed clear the body variables (#25136) 2025-09-04 15:09:54 +08:00
Harry f60e28d2f5 feat(trigger): enhance user role validation and add request logs API for trigger providers
- Updated user role validation in PluginTriggerApi and WebhookTriggerApi to assert current_user as an Account and check tenant ID.
- Introduced TriggerSubscriptionBuilderRequestLogsApi to retrieve request logs for subscription instances, ensuring proper user authentication and error handling.
- Added new API endpoint for accessing request logs related to trigger providers.

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-09-04 14:44:02 +08:00
HarryandClaude a62d7aa3ee feat(trigger): add plugin trigger workflow support and refactor trigger system
- Add new workflow plugin trigger service for managing plugin-based triggers
- Implement trigger provider encryption utilities for secure credential storage
- Add custom trigger errors module for better error handling
- Refactor trigger provider and manager classes for improved plugin integration
- Update API endpoints to support plugin trigger workflows
- Add database migration for plugin trigger workflow support

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 13:20:43 +08:00
非法操作andGitHub cc84a45244 chore: (webhook) use variable instead of InputVar (#25119) 2025-09-04 11:10:42 +08:00
cathyandGitHub 5cf3d24018 fix(webhook): selected type ui style (#25106) 2025-09-04 10:59:08 +08:00
lyzno1andGitHub 4bdbe617fe fix: uuidv7 (#25097) 2025-09-04 08:44:14 +08:00
lyzno1andGitHub 33c867fd8c feat(workflow): enhance webhook status code input with increment/decrement controls (#25099) 2025-09-03 22:26:00 +08:00
非法操作andGitHub 2013ceb9d2 chore: validate param type of application/json when call a webhook (#25074) 2025-09-03 15:49:07 +08:00
非法操作andGitHub 7120c6414c fix: content type of webhook (#25032) 2025-09-03 15:13:01 +08:00
Harry 5ce7b2d98d refactor(migrations): remove obsolete plugin_trigger migration file
- Deleted the plugin_trigger migration file as it is no longer needed in the codebase.
- Updated model imports in `__init__.py` to include new trigger-related classes for better organization.
2025-09-03 15:02:17 +08:00
Harry cb82198271 refactor(trigger): update trigger provider classes and API endpoints
- Renamed classes for trigger subscription management to improve clarity, including TriggerProviderSubscriptionListApi to TriggerSubscriptionListApi and TriggerSubscriptionsDeleteApi to TriggerSubscriptionDeleteApi.
- Updated API endpoint paths to reflect the new naming conventions for trigger subscriptions.
- Removed deprecated TriggerOAuthRefreshTokenApi class to streamline the codebase.
- Added trigger_providers import to the console controller for better organization.
2025-09-03 14:53:27 +08:00
Harry 5e5ffaa416 feat(tool-form): add extraParams prop to ToolForm and ToolFormItem components
- Introduced extraParams prop to both ToolForm and ToolFormItem components for enhanced flexibility in passing additional parameters.
- Updated component usage to accommodate the new prop, improving the overall functionality of the tool forms.
2025-09-03 14:53:27 +08:00
Harry 4b253e1f73 feat(trigger): plugin trigger workflow 2025-09-03 14:53:27 +08:00
Harry dd929dbf0e fix(dynamic_select): implement function 2025-09-03 14:53:27 +08:00
HarryandClaude 97a9d34e96 feat(trigger): introduce plugin trigger management and enhance trigger processing
- Remove the debug endpoint for cleaner API structure
- Add support for TRIGGER_PLUGIN in NodeType enumeration
- Implement WorkflowPluginTrigger model to map plugin triggers to workflow nodes
- Enhance TriggerService to process plugin triggers and store trigger data in Redis
- Update node mapping to include TriggerPluginNode for workflow execution

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 14:53:27 +08:00
HarryandClaude 602070ec9c refactor(trigger): improve method signature formatting in TriggerService
- Adjust the formatting of the `process_triggered_workflows` method signature for better readability
- Ensure consistent style across method definitions in the TriggerService class

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 14:53:27 +08:00
HarryandClaude afd8989150 feat(trigger): introduce subscription builder and enhance trigger management
- Refactor trigger provider classes to improve naming consistency, including renaming classes for subscription management
- Implement new TriggerSubscriptionBuilderService for creating and verifying subscription builders
- Update API endpoints to support subscription builder creation and verification
- Enhance data models to include new attributes for subscription builders
- Remove the deprecated TriggerSubscriptionValidationService to streamline the codebase

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 14:53:27 +08:00
HarryandClaude 694197a701 refactor(trigger): clean up imports and optimize trigger-related code
- Remove unused imports in trigger-related files for better clarity and maintainability
- Streamline import statements across various modules to enhance code quality

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 14:53:27 +08:00
HarryandClaude 2f08306695 feat(trigger): enhance trigger subscription management and processing
- Refactor trigger provider classes to improve naming consistency and clarity
- Introduce new methods for managing trigger subscriptions, including validation and dispatching
- Update API endpoints to reflect changes in subscription handling
- Implement logging and request management for endpoint interactions
- Enhance data models to support subscription attributes and lifecycle management

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 14:53:27 +08:00
HarryandClaude 6acc77d86d feat(trigger): refactor trigger provider to subscription model
- Rename classes and methods to reflect the transition from credentials to subscriptions
- Update API endpoints for managing trigger subscriptions
- Modify data models and entities to support subscription attributes
- Enhance service methods for listing, adding, updating, and deleting subscriptions
- Adjust encryption utilities to handle subscription data

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 14:53:27 +08:00
HarryandClaude 5ddd5e49ee feat(trigger): enhance subscription schema and provider configuration
- Update ProviderConfig to allow a list as a default value
- Introduce SubscriptionSchema for better organization of subscription-related configurations
- Modify TriggerProviderApiEntity to use Optional for subscription_schema
- Add custom_model_schema to TriggerProviderEntity for additional configuration options

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 14:53:27 +08:00
HarryandClaude 72f9e77368 refactor(trigger): clean up and optimize trigger-related code
- Remove unused classes and imports in encryption utilities
- Simplify method signatures for better readability
- Enhance code quality by adding newlines for clarity
- Update tests to reflect changes in import paths

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 14:53:26 +08:00
HarryandClaude a46c9238fa feat(trigger): implement complete OAuth authorization flow for trigger providers
- Add OAuth authorization URL generation API endpoint
- Implement OAuth callback handler for credential storage
- Support both system-level and tenant-level OAuth clients
- Add trigger provider credential encryption utilities
- Refactor trigger entities into separate modules
- Update trigger provider service with OAuth client management
- Add credential cache for trigger providers

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 14:53:26 +08:00
Harry 87120ad4ac feat(trigger): add trigger provider management and webhook handling functionality 2025-09-03 14:53:26 +08:00
非法操作andGitHub 7544b5ec9a fix: delete var of webhook (#25038) 2025-09-03 14:49:56 +08:00
非法操作andGitHub ff4a62d1e7 chore: limit webhook status code 200~399 (#25045) 2025-09-03 14:48:18 +08:00
lyzno1andGitHub 41daa51988 fix: missing key for translation path (#25059) 2025-09-03 14:43:40 +08:00
cathyandGitHub d522350c99 fix(webhook-trigger): request array type adjustment (#25005) 2025-09-02 23:20:12 +08:00
lyzno1andGitHub 1d1bb9451e fix: prevent workflow canvas clearing due to race condition and viewport errors (#25003) 2025-09-02 20:53:44 +08:00
lyzno1andGitHub 1fce1a61d4 feat(workflow-log): enhance workflow logs UI with sorting and status filters (#24978) 2025-09-02 16:43:11 +08:00
非法操作andGitHub 883a6caf96 feat: add trigger by of app log (#24973) 2025-09-02 16:04:08 +08:00
非法操作andGitHub a239c39f09 fix: webhook http method should case insensitive (#24957) 2025-09-02 14:47:24 +08:00
lyzno1andGitHub e925a8ab99 fix(app-cards): restrict toggle enable to Start nodes only (#24918) 2025-09-01 22:52:23 +08:00
Yeuoly bccaf939e6 fix: migrations 2025-09-01 18:07:21 +08:00
Yeuoly 676648e0b3 Merge branch 'main' into feat/trigger 2025-09-01 18:05:31 +08:00
cathyandGitHub 4ae19e6dde fix(webhook-trigger): remove error handling (#24902) 2025-09-01 17:11:49 +08:00
4d0ff5c281 feat: implement variable synchronization for webhook node (#24874)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-01 16:58:06 +08:00
327b354cc2 refactor: unify trigger node architecture and clean up technical debt (#24886)
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-01 15:47:44 +08:00
lyzno1andGitHub 6d307cc9fc Fix test run shortcut consistency and improve dropdown styling (#24849) 2025-09-01 14:47:21 +08:00
lyzno1andGitHub adc7134af5 fix: improve TimePicker footer layout and button styling (#24831) 2025-09-01 13:34:53 +08:00
cathyandGitHub 10f19cd0c2 fix(webhook): add content-type aware parameter type handling (#24865) 2025-09-01 10:06:26 +08:00
lyzno1andGitHub 9ed45594c6 fix: improve schedule trigger and quick settings app-operation btns ui (#24843) 2025-08-31 16:59:49 +08:00
c138f4c3a6 fix: check AppTrigger status before webhook execution (#24829)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-30 16:40:21 +08:00
lyzno1andGitHub a35be05790 Fix workflow card toggle logic and implement minimal state UI (#24822) 2025-08-30 16:35:34 +08:00
lyzno1andGitHub 60b5ed8e5d fix: enhance webhook trigger panel UI consistency and user experience (#24780) 2025-08-29 17:41:42 +08:00
d8ddbc4d87 feat: enhance webhook trigger panel UI consistency and interactivity (#24759)
Co-authored-by: hjlarry <hjlarry@163.com>
2025-08-29 14:24:23 +08:00
非法操作andGitHub 19c0fc85e2 feat: when add/delete webhook trigger call the API (#24755) 2025-08-29 14:23:50 +08:00
lyzno1andGitHub a58df35ead fix: improve trigger card layout spacing and remove dividers (#24756) 2025-08-29 13:37:44 +08:00
lyzno1andGitHub 9789bd02d8 feat: implement trigger card component with auto-refresh (#24743) 2025-08-29 11:57:08 +08:00
lyzno1andGitHub d94e54923f Improve tooltip design for trigger blocks (#24724) 2025-08-28 23:18:00 +08:00
lyzno1andGitHub 64c7be59b7 Improve workflow block selector search functionality (#24707) 2025-08-28 17:21:34 +08:00
非法操作andGitHub 89ad6ad902 feat: add app trigger list api (#24693) 2025-08-28 15:23:08 +08:00
lyzno1andGitHub 4f73bc9693 fix(schedule): add time logic to weekly frequency mode for consistent behavior with daily mode (#24673) 2025-08-28 14:40:11 +08:00
lyzno1andGitHub add6b79231 UI enhancements for workflow checklist component (#24647) 2025-08-28 10:10:10 +08:00
lyzno1andGitHub c90dad566f feat: enhance workflow error handling and internationalization (#24648) 2025-08-28 09:41:22 +08:00
lyzno1andGitHub 5cbe6bf8f8 fix(schedule): correct weekly frequency weekday calculation algorithm (#24641) 2025-08-27 18:20:09 +08:00
4ef6ff217e fix: improve code quality in webhook services and controllers (#24634)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 17:50:51 +08:00
lyzno1andGitHub 87abfbf515 Allow empty workflows and improve workflow validation (#24627) 2025-08-27 17:49:09 +08:00
lyzno1andGitHub 73e65fd838 feat: align trigger webhook style with schedule node and fix selection border truncation (#24635) 2025-08-27 17:47:14 +08:00
e53edb0fc2 refactor: optimize TenantDailyRateLimiter to use UTC internally with timezone-aware error messages (#24632)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-27 17:35:04 +08:00
非法操作andGitHub 17908fbf6b fix: only workflow should display start modal (#24623) 2025-08-27 16:20:31 +08:00
zhangxuhe1andGitHub 3dae108f84 refactor(sidebar): Restructure app operations with toggle functionality (#24625) 2025-08-27 16:20:17 +08:00
+40 5bbf685035 feat: fix i18n missing keys and merge upstream/main (#24615)
Signed-off-by: -LAN- <laipz8200@outlook.com>
Signed-off-by: kenwoodjw <blackxin55+@gmail.com>
Signed-off-by: Yongtao Huang <yongtaoh2022@gmail.com>
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
Signed-off-by: zhanluxianshen <zhanluxianshen@163.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: GuanMu <ballmanjq@gmail.com>
Co-authored-by: Davide Delbianco <davide.delbianco@outlook.com>
Co-authored-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
Co-authored-by: kenwoodjw <blackxin55+@gmail.com>
Co-authored-by: Yongtao Huang <yongtaoh2022@gmail.com>
Co-authored-by: Yongtao Huang <99629139+hyongtao-db@users.noreply.github.com>
Co-authored-by: Qiang Lee <18018968632@163.com>
Co-authored-by: 李强04 <liqiang04@gaotu.cn>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: Matri Qi <matrixdom@126.com>
Co-authored-by: huayaoyue6 <huayaoyue@163.com>
Co-authored-by: Bowen Liang <liangbowen@gf.com.cn>
Co-authored-by: znn <jubinkumarsoni@gmail.com>
Co-authored-by: crazywoola <427733928@qq.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: yihong <zouzou0208@gmail.com>
Co-authored-by: Muke Wang <shaodwaaron@gmail.com>
Co-authored-by: wangmuke <wangmuke@kingsware.cn>
Co-authored-by: Wu Tianwei <30284043+WTW0313@users.noreply.github.com>
Co-authored-by: quicksand <quicksandzn@gmail.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: Eric Guo <eric.guocz@gmail.com>
Co-authored-by: Zhedong Cen <cenzhedong2@126.com>
Co-authored-by: jiangbo721 <jiangbo721@163.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: hjlarry <25834719+hjlarry@users.noreply.github.com>
Co-authored-by: lxsummer <35754229+lxjustdoit@users.noreply.github.com>
Co-authored-by: 湛露先生 <zhanluxianshen@163.com>
Co-authored-by: Guangdong Liu <liugddx@gmail.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Yessenia-d <yessenia.contact@gmail.com>
Co-authored-by: huangzhuo1949 <167434202+huangzhuo1949@users.noreply.github.com>
Co-authored-by: huangzhuo <huangzhuo1@xiaomi.com>
Co-authored-by: 17hz <0x149527@gmail.com>
Co-authored-by: Amy <1530140574@qq.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: Nite Knite <nkCoding@gmail.com>
Co-authored-by: Yeuoly <45712896+Yeuoly@users.noreply.github.com>
Co-authored-by: Petrus Han <petrus.hanks@gmail.com>
Co-authored-by: iamjoel <2120155+iamjoel@users.noreply.github.com>
Co-authored-by: Kalo Chin <frog.beepers.0n@icloud.com>
Co-authored-by: Ujjwal Maurya <ujjwalsbx@gmail.com>
Co-authored-by: Maries <xh001x@hotmail.com>
2025-08-27 15:07:28 +08:00
非法操作GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a63d1e87b1 feat: webhook trigger backend api (#24387)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-27 14:42:45 +08:00
lyzno1andGitHub 7129de98cd feat: implement workflow onboarding modal system (#24551) 2025-08-27 13:31:22 +08:00
非法操作andGitHub 2984dbc0df fix: when workflow not has start node can't open service api (#24564) 2025-08-26 18:06:11 +08:00
非法操作andGitHub 392db7f611 fix: when workflow only has trigger node can't save (#24546) 2025-08-26 16:41:47 +08:00
lyzno1GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
5a427b8daa refactor: rename RunAllTriggers icon to TriggerAll for semantic clarity (#24478)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-25 17:51:04 +08:00
18f2e6f166 refactor: Use specific error types for workflow execution (#24475)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-25 16:19:12 +08:00
lyzno1andGitHub e78903302f feat(trigger-schedule): simplify timezone handling with user-centric approach (#24401) 2025-08-24 21:03:59 +08:00
cathyandGitHub 4084ade86c refactor(trigger-webhook): remove redundant WebhookParam type and sim… (#24390) 2025-08-24 00:21:47 +08:00
cathyandGitHub 6b0d919dbd feat: webhook trigger frontend (#24311) 2025-08-23 23:54:41 +08:00
a7b558b38b feat/trigger: support specifying root node (#24388)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-23 20:44:03 +08:00
YeuolyGitHubClaudeautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
6aed7e3ff4 feat/trigger universal entry (#24358)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-08-23 20:18:08 +08:00
lyzno1GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>zhangxuhe1
8e93a8a2e2 refactor: comprehensive schedule trigger component redesign (#24359)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: zhangxuhe1 <xuhezhang6@gmail.com>
2025-08-23 11:03:18 +08:00
Yeuoly e38a86e37b Merge branch 'main' into feat/trigger 2025-08-22 20:11:49 +08:00
lyzno1andGitHub 392e3530bf feat: replace mock data with dynamic workflow options in test run dropdown (#24320) 2025-08-22 16:36:09 +08:00
lyzno1andGitHub 833c902b2b feat(workflow): Plugin Trigger Node with Unified Entry Node System (#24205) 2025-08-20 23:49:10 +08:00
lyzno1andGitHub 6eaea64b3f feat: implement multi-select monthly trigger schedule (#24247) 2025-08-20 06:23:30 -07:00
lyzno1andGitHub 5303b50737 fix: initialize recur fields when switching to hourly frequency (#24181) 2025-08-20 09:32:05 +08:00
lyzno1andGitHub 6acbcfe679 UI improvements: fix translation and custom icons for schedule trigger (#24167) 2025-08-19 18:27:07 +08:00
lyzno1andGitHub 16ef5ebb97 fix: remove duplicate weekdays keys in i18n workflow files (#24157) 2025-08-19 14:55:16 +08:00
lyzno1andGitHub acfb95f9c2 Refactor Start node UI to User Input and optimize EntryNodeContainer (#24156) 2025-08-19 14:40:24 +08:00
lyzno1andGitHub aacea166d7 fix: resolve merge conflict between Features removal and validation enhancement (#24150) 2025-08-19 13:47:38 +08:00
lyzno1andGitHub f7bb3b852a feat: implement Schedule Trigger validation with multi-start node topology support (#24134) 2025-08-19 11:55:15 +08:00
lyzno1andGitHub d4ff1e031a Remove workflow features button (#24085) 2025-08-19 09:32:07 +08:00
6a3d135d49 fix: simplify trigger-schedule hourly mode calculation and improve UI consistency (#24082)
Co-authored-by: zhangxuhe1 <xuhezhang6@gmail.com>
2025-08-18 23:37:57 +08:00
lyzno1andGitHub 5c4bf7aabd feat: Test Run dropdown with dynamic trigger selection (#24113) 2025-08-18 17:46:36 +08:00
lyzno1andGitHub e9c7dc7464 feat: update workflow run button to Test Run with keyboard shortcut (#24071) 2025-08-18 10:44:17 +08:00
74ad21b145 feat: comprehensive trigger node system with Schedule Trigger implementation (#24039)
Co-authored-by: zhangxuhe1 <xuhezhang6@gmail.com>
2025-08-18 09:23:16 +08:00
f214eeb7b1 feat: add scroll to selected node button in workflow header (#24030)
Co-authored-by: zhangxuhe1 <xuhezhang6@gmail.com>
2025-08-16 19:26:44 +08:00
lyzno1andGitHub ae25f90f34 Replace export button with more actions button in workflow control panel (#24033) 2025-08-16 19:25:18 +08:00
144 changed files with 11634 additions and 269 deletions
@@ -26,13 +26,20 @@ import userEvent from '@testing-library/user-event'
// WHY: Mocks must be hoisted to top of file (Jest requirement).
// They run BEFORE imports, so keep them before component imports.
// i18n (always required in Dify)
// WHY: Returns key instead of translation so tests don't depend on i18n files
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// i18n (automatically mocked)
// WHY: Shared mock at web/__mocks__/react-i18next.ts is auto-loaded by Jest
// No explicit mock needed - it returns translation keys as-is
// Override only if custom translations are required:
// jest.mock('react-i18next', () => ({
// useTranslation: () => ({
// t: (key: string) => {
// const customTranslations: Record<string, string> = {
// 'my.custom.key': 'Custom Translation',
// }
// return customTranslations[key] || key
// },
// }),
// }))
// Router (if component uses useRouter, usePathname, useSearchParams)
// WHY: Isolates tests from Next.js routing, enables testing navigation behavior
+9 -1
View File
@@ -93,4 +93,12 @@ jobs:
# Create a detailed coverage summary
echo "### Test Coverage Summary :test_tube:" >> $GITHUB_STEP_SUMMARY
echo "Total Coverage: ${TOTAL_COVERAGE}%" >> $GITHUB_STEP_SUMMARY
uv run --project api coverage report --format=markdown >> $GITHUB_STEP_SUMMARY
{
echo ""
echo "<details><summary>File-level coverage (click to expand)</summary>"
echo ""
echo '```'
uv run --project api coverage report -m
echo '```'
echo "</details>"
} >> $GITHUB_STEP_SUMMARY
+1 -1
View File
@@ -670,4 +670,4 @@ ANNOTATION_IMPORT_MIN_RECORDS=1
ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
# Maximum number of concurrent annotation import tasks per tenant
ANNOTATION_IMPORT_MAX_CONCURRENT=5
ANNOTATION_IMPORT_MAX_CONCURRENT=5
+2
View File
@@ -54,6 +54,7 @@ from .app import (
completion,
conversation,
conversation_variables,
enduser_auth,
generator,
mcp_server,
message,
@@ -162,6 +163,7 @@ __all__ = [
"datasource_content_preview",
"email_register",
"endpoint",
"enduser_auth",
"extension",
"external",
"feature",
+230
View File
@@ -0,0 +1,230 @@
"""
End-user authentication API controllers.
Provides API endpoints for managing end-user credentials for tool authentication.
"""
from flask import request
from flask_restx import Resource
from werkzeug.exceptions import BadRequest
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from core.model_runtime.utils.encoders import jsonable_encoder
from libs.login import current_account_with_tenant, login_required
from models import AppMode
from services.tools.app_auth_requirement_service import AppAuthRequirementService
from services.tools.enduser_auth_service import EndUserAuthService
from services.tools.enduser_oauth_service import EndUserOAuthService
@console_ns.route("/apps/<uuid:app_id>/auth/providers")
class AppAuthProvidersApi(Resource):
"""
Get list of authentication providers required for an app.
Returns providers that require end-user authentication based on app configuration.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def get(self, app_model):
"""Get authentication providers required for the app."""
_, tenant_id = current_account_with_tenant()
providers = AppAuthRequirementService.get_required_providers(
tenant_id=tenant_id,
app_id=str(app_model.id),
)
return jsonable_encoder(providers)
@console_ns.route("/apps/<uuid:app_id>/auth/providers/<path:provider_id>/credentials")
class AppAuthProviderCredentialsApi(Resource):
"""
Manage end-user credentials for a specific provider.
Allows listing, creating, and deleting end-user credentials.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def get(self, app_model, provider_id: str):
"""List end-user's credentials for this provider."""
user, tenant_id = current_account_with_tenant()
# For console API, use the current account user as end_user_id
# In production, this would be the actual end-user ID from the chat/completion request
end_user_id = str(user.id)
credentials = EndUserAuthService.list_credentials(
tenant_id=tenant_id,
end_user_id=end_user_id,
provider_id=provider_id,
)
return jsonable_encoder(credentials)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def post(self, app_model, provider_id: str):
"""Create a new credential (API key only)."""
user, tenant_id = current_account_with_tenant()
end_user_id = str(user.id)
payload = request.get_json()
if not payload:
raise BadRequest("Request body is required")
credential_type = payload.get("credential_type")
credentials = payload.get("credentials")
if not credential_type or not credentials:
raise BadRequest("credential_type and credentials are required")
if credential_type != "api-key":
raise BadRequest(
"Only 'api-key' credential type can be created via this endpoint. "
"Use OAuth flow for OAuth credentials."
)
credential = EndUserAuthService.create_api_key_credential(
tenant_id=tenant_id,
end_user_id=end_user_id,
provider_id=provider_id,
credentials=credentials,
)
return jsonable_encoder(credential)
@console_ns.route("/apps/<uuid:app_id>/auth/providers/<path:provider_id>/credentials/<string:credential_id>")
class AppAuthProviderCredentialApi(Resource):
"""
Manage a specific end-user credential.
Allows getting, updating, or deleting a credential.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def delete(self, app_model, provider_id: str, credential_id: str):
"""Delete a credential."""
user, tenant_id = current_account_with_tenant()
end_user_id = str(user.id)
EndUserAuthService.delete_credential(
tenant_id=tenant_id,
end_user_id=end_user_id,
provider_id=provider_id,
credential_id=credential_id,
)
return {"result": "success"}
@console_ns.route("/apps/<uuid:app_id>/auth/oauth/<path:provider_id>/authorization-url")
class AppAuthOAuthAuthorizationUrlApi(Resource):
"""
Get OAuth authorization URL for end-user authentication.
Returns the URL where the user should be redirected to authenticate with the provider.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def get(self, app_model, provider_id: str):
"""Get OAuth authorization URL."""
user, tenant_id = current_account_with_tenant()
end_user_id = str(user.id)
result = EndUserOAuthService.get_authorization_url(
end_user_id=end_user_id,
tenant_id=tenant_id,
app_id=str(app_model.id),
provider=provider_id,
)
# Set OAuth context cookie for callback
response = jsonable_encoder({
"authorization_url": result["authorization_url"],
})
# Store context_id in response for frontend to set as cookie
response["context_id"] = result["context_id"]
return response
@console_ns.route("/apps/<uuid:app_id>/auth/oauth/<path:provider_id>/callback")
class AppAuthOAuthCallbackApi(Resource):
"""
Handle OAuth callback for end-user authentication.
This endpoint is called by the OAuth provider after user authorization.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def get(self, app_model, provider_id: str):
"""Handle OAuth callback and store credentials."""
# Get OAuth context ID from cookie
context_id = request.cookies.get("oauth_context_id")
if not context_id:
raise BadRequest("Missing OAuth context")
# Get OAuth error if any
error = request.args.get("error")
error_description = request.args.get("error_description")
if error:
raise BadRequest(f"OAuth error: {error} - {error_description}")
# Handle callback and create credential
result = EndUserOAuthService.handle_oauth_callback(
context_id=context_id,
request=request,
)
return jsonable_encoder(result)
@console_ns.route("/apps/<uuid:app_id>/auth/providers/<path:provider_id>/credentials/<string:credential_id>/refresh")
class AppAuthProviderRefreshApi(Resource):
"""
Manually refresh OAuth token for a credential.
This endpoint allows refreshing an expired OAuth token.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def post(self, app_model, provider_id: str, credential_id: str):
"""Refresh OAuth token."""
user, tenant_id = current_account_with_tenant()
end_user_id = str(user.id)
result = EndUserOAuthService.refresh_oauth_token(
credential_id=credential_id,
end_user_id=end_user_id,
tenant_id=tenant_id,
)
return jsonable_encoder(result)
+8 -1
View File
@@ -22,7 +22,12 @@ from controllers.console.error import (
NotAllowedCreateWorkspace,
WorkspacesLimitExceeded,
)
from controllers.console.wraps import email_password_login_enabled, setup_required
from controllers.console.wraps import (
decrypt_code_field,
decrypt_password_field,
email_password_login_enabled,
setup_required,
)
from events.tenant_event import tenant_was_created
from libs.helper import EmailStr, extract_remote_ip
from libs.login import current_account_with_tenant
@@ -79,6 +84,7 @@ class LoginApi(Resource):
@setup_required
@email_password_login_enabled
@console_ns.expect(console_ns.models[LoginPayload.__name__])
@decrypt_password_field
def post(self):
"""Authenticate user and login."""
args = LoginPayload.model_validate(console_ns.payload)
@@ -218,6 +224,7 @@ class EmailCodeLoginSendEmailApi(Resource):
class EmailCodeLoginApi(Resource):
@setup_required
@console_ns.expect(console_ns.models[EmailCodeLoginPayload.__name__])
@decrypt_code_field
def post(self):
args = EmailCodeLoginPayload.model_validate(console_ns.payload)
@@ -140,6 +140,18 @@ class DataSourceNotionListApi(Resource):
credential_id = request.args.get("credential_id", default=None, type=str)
if not credential_id:
raise ValueError("Credential id is required.")
# Get datasource_parameters from query string (optional, for GitHub and other datasources)
datasource_parameters_str = request.args.get("datasource_parameters", default=None, type=str)
datasource_parameters = {}
if datasource_parameters_str:
try:
datasource_parameters = json.loads(datasource_parameters_str)
if not isinstance(datasource_parameters, dict):
raise ValueError("datasource_parameters must be a JSON object.")
except json.JSONDecodeError:
raise ValueError("Invalid datasource_parameters JSON format.")
datasource_provider_service = DatasourceProviderService()
credential = datasource_provider_service.get_datasource_credentials(
tenant_id=current_tenant_id,
@@ -187,7 +199,7 @@ class DataSourceNotionListApi(Resource):
online_document_result: Generator[OnlineDocumentPagesMessage, None, None] = (
datasource_runtime.get_online_document_pages(
user_id=current_user.id,
datasource_parameters={},
datasource_parameters=datasource_parameters,
provider_type=datasource_runtime.datasource_provider_type(),
)
)
@@ -218,14 +230,14 @@ class DataSourceNotionListApi(Resource):
@console_ns.route(
"/notion/workspaces/<uuid:workspace_id>/pages/<uuid:page_id>/<string:page_type>/preview",
"/notion/pages/<uuid:page_id>/<string:page_type>/preview",
"/datasets/notion-indexing-estimate",
)
class DataSourceNotionApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, workspace_id, page_id, page_type):
def get(self, page_id, page_type):
_, current_tenant_id = current_account_with_tenant()
credential_id = request.args.get("credential_id", default=None, type=str)
@@ -239,11 +251,10 @@ class DataSourceNotionApi(Resource):
plugin_id="langgenius/notion_datasource",
)
workspace_id = str(workspace_id)
page_id = str(page_id)
extractor = NotionExtractor(
notion_workspace_id=workspace_id,
notion_workspace_id="",
notion_obj_id=page_id,
notion_page_type=page_type,
notion_access_token=credential.get("integration_secret"),
@@ -4,7 +4,7 @@ from typing import Any, Literal, cast
from uuid import UUID
from flask import abort, request
from flask_restx import Resource, marshal_with # type: ignore
from flask_restx import Resource, marshal_with, reqparse # type: ignore
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
@@ -975,6 +975,11 @@ class RagPipelineRecommendedPluginApi(Resource):
@login_required
@account_initialization_required
def get(self):
parser = reqparse.RequestParser()
parser.add_argument("type", type=str, location="args", required=False, default="all")
args = parser.parse_args()
type = args["type"]
rag_pipeline_service = RagPipelineService()
recommended_plugins = rag_pipeline_service.get_recommended_plugins()
recommended_plugins = rag_pipeline_service.get_recommended_plugins(type)
return recommended_plugins
+82
View File
@@ -9,10 +9,12 @@ from typing import ParamSpec, TypeVar
from flask import abort, request
from configs import dify_config
from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError
from controllers.console.workspace.error import AccountNotInitializedError
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.encryption import FieldEncryption
from libs.login import current_account_with_tenant
from models.account import AccountStatus
from models.dataset import RateLimitLog
@@ -25,6 +27,14 @@ from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogo
P = ParamSpec("P")
R = TypeVar("R")
# Field names for decryption
FIELD_NAME_PASSWORD = "password"
FIELD_NAME_CODE = "code"
# Error messages for decryption failures
ERROR_MSG_INVALID_ENCRYPTED_DATA = "Invalid encrypted data"
ERROR_MSG_INVALID_ENCRYPTED_CODE = "Invalid encrypted code"
def account_initialization_required(view: Callable[P, R]):
@wraps(view)
@@ -419,3 +429,75 @@ def annotation_import_concurrency_limit(view: Callable[P, R]):
return view(*args, **kwargs)
return decorated
def _decrypt_field(field_name: str, error_class: type[Exception], error_message: str) -> None:
"""
Helper to decode a Base64 encoded field in the request payload.
Args:
field_name: Name of the field to decode
error_class: Exception class to raise on decoding failure
error_message: Error message to include in the exception
"""
if not request or not request.is_json:
return
# Get the payload dict - it's cached and mutable
payload = request.get_json()
if not payload or field_name not in payload:
return
encoded_value = payload[field_name]
decoded_value = FieldEncryption.decrypt_field(encoded_value)
# If decoding failed, raise error immediately
if decoded_value is None:
raise error_class(error_message)
# Update payload dict in-place with decoded value
# Since payload is a mutable dict and get_json() returns the cached reference,
# modifying it will affect all subsequent accesses including console_ns.payload
payload[field_name] = decoded_value
def decrypt_password_field(view: Callable[P, R]):
"""
Decorator to decrypt password field in request payload.
Automatically decrypts the 'password' field if encryption is enabled.
If decryption fails, raises AuthenticationFailedError.
Usage:
@decrypt_password_field
def post(self):
args = LoginPayload.model_validate(console_ns.payload)
# args.password is now decrypted
"""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
_decrypt_field(FIELD_NAME_PASSWORD, AuthenticationFailedError, ERROR_MSG_INVALID_ENCRYPTED_DATA)
return view(*args, **kwargs)
return decorated
def decrypt_code_field(view: Callable[P, R]):
"""
Decorator to decrypt verification code field in request payload.
Automatically decrypts the 'code' field if encryption is enabled.
If decryption fails, raises EmailCodeError.
Usage:
@decrypt_code_field
def post(self):
args = EmailCodeLoginPayload.model_validate(console_ns.payload)
# args.code is now decrypted
"""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
_decrypt_field(FIELD_NAME_CODE, EmailCodeError, ERROR_MSG_INVALID_ENCRYPTED_CODE)
return view(*args, **kwargs)
return decorated
+2 -1
View File
@@ -3,7 +3,7 @@ from typing import Any, Union
from pydantic import BaseModel, Field
from core.tools.entities.tool_entities import ToolInvokeMessage, ToolProviderType
from core.tools.entities.tool_entities import ToolAuthType, ToolInvokeMessage, ToolProviderType
class AgentToolEntity(BaseModel):
@@ -17,6 +17,7 @@ class AgentToolEntity(BaseModel):
tool_parameters: dict[str, Any] = Field(default_factory=dict)
plugin_unique_identifier: str | None = None
credential_id: str | None = None
auth_type: ToolAuthType = ToolAuthType.WORKSPACE
class AgentPromptEntity(BaseModel):
@@ -10,7 +10,7 @@ class NotionInfo(BaseModel):
"""
credential_id: str | None = None
notion_workspace_id: str
notion_workspace_id: str | None = ""
notion_obj_id: str
notion_page_type: str
document: Document | None = None
+1 -1
View File
@@ -166,7 +166,7 @@ class ExtractProcessor:
elif extract_setting.datasource_type == DatasourceType.NOTION:
assert extract_setting.notion_info is not None, "notion_info is required"
extractor = NotionExtractor(
notion_workspace_id=extract_setting.notion_info.notion_workspace_id,
notion_workspace_id=extract_setting.notion_info.notion_workspace_id or "",
notion_obj_id=extract_setting.notion_info.notion_obj_id,
notion_page_type=extract_setting.notion_info.notion_page_type,
document_model=extract_setting.notion_info.document,
+1
View File
@@ -124,6 +124,7 @@ class ToolProviderCredentialApiEntity(BaseModel):
default=False, description="Whether the credential is the default credential for the provider in the workspace"
)
credentials: Mapping[str, object] = Field(description="The credentials of the provider", default_factory=dict)
expires_at: int = Field(default=-1, description="Unix timestamp when credential expires (-1 for no expiry)")
class ToolProviderCredentialInfoApiEntity(BaseModel):
+10
View File
@@ -123,6 +123,16 @@ class ApiProviderAuthType(StrEnum):
raise ValueError(f"invalid mode value '{value}', expected one of: {valid}")
class ToolAuthType(StrEnum):
"""
Enum class for tool authentication type.
Determines whether OAuth credentials are workspace-level or end-user-level.
"""
WORKSPACE = "workspace"
END_USER = "end_user"
class ToolInvokeMessage(BaseModel):
class TextMessage(BaseModel):
text: str
+138 -25
View File
@@ -49,6 +49,7 @@ from core.tools.entities.api_entities import ToolProviderApiEntity, ToolProvider
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import (
ApiProviderAuthType,
ToolAuthType,
ToolInvokeFrom,
ToolParameter,
ToolProviderType,
@@ -58,7 +59,7 @@ from core.tools.tool_label_manager import ToolLabelManager
from core.tools.utils.configuration import ToolParameterConfigurationManager
from core.tools.utils.encryption import create_provider_encrypter, create_tool_provider_encrypter
from core.tools.workflow_as_tool.tool import WorkflowTool
from models.tools import ApiToolProvider, BuiltinToolProvider, WorkflowToolProvider
from models.tools import ApiToolProvider, BuiltinToolProvider, EndUserAuthenticationProvider, WorkflowToolProvider
from services.tools.tools_transform_service import ToolTransformService
if TYPE_CHECKING:
@@ -78,6 +79,54 @@ class ToolManager:
_builtin_providers_loaded = False
_builtin_tools_labels: dict[str, Union[I18nObject, None]] = {}
@classmethod
def _refresh_oauth_credentials(
cls,
tenant_id: str,
provider_id: str,
user_id: str,
decrypted_credentials: Mapping[str, Any],
) -> tuple[dict[str, Any], int]:
"""
Refresh OAuth credentials for a provider.
This is a helper method to centralize the OAuth token refresh logic
used by both end-user and workspace authentication flows.
:param tenant_id: the tenant id
:param provider_id: the provider id
:param user_id: the user id (end_user_id or workspace user_id)
:param decrypted_credentials: the current decrypted credentials
:return: tuple of (refreshed credentials dict, expires_at timestamp)
"""
from core.plugin.impl.oauth import OAuthHandler
# Local import to avoid circular dependency at module level
# This import is necessary but creates a cycle: tool_manager -> builtin_tools_manage_service -> tool_manager
# TODO: Break the circular dependency by refactoring service layer
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
# Parse provider ID and build OAuth configuration
tool_provider = ToolProviderID(provider_id)
provider_name = tool_provider.provider_name
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback"
system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id)
# Refresh the credentials using OAuth handler
oauth_handler = OAuthHandler()
refreshed_credentials = oauth_handler.refresh_credentials(
tenant_id=tenant_id,
user_id=user_id,
plugin_id=tool_provider.plugin_id,
provider=provider_name,
redirect_uri=redirect_uri,
system_credentials=system_credentials or {},
credentials=decrypted_credentials,
)
return refreshed_credentials.credentials, refreshed_credentials.expires_at
@classmethod
def get_hardcoded_provider(cls, provider: str) -> BuiltinToolProviderController:
"""
@@ -165,6 +214,8 @@ class ToolManager:
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
tool_invoke_from: ToolInvokeFrom = ToolInvokeFrom.AGENT,
credential_id: str | None = None,
auth_type: ToolAuthType = ToolAuthType.WORKSPACE,
end_user_id: str | None = None,
) -> Union[BuiltinTool, PluginTool, ApiTool, WorkflowTool, MCPTool]:
"""
get the tool runtime
@@ -176,6 +227,8 @@ class ToolManager:
:param invoke_from: invoke from
:param tool_invoke_from: the tool invoke from
:param credential_id: the credential id
:param auth_type: the authentication type (workspace or end_user)
:param end_user_id: the end user id (required when auth_type is END_USER)
:return: the tool
"""
@@ -200,6 +253,75 @@ class ToolManager:
)
),
)
# Handle end-user authentication
if auth_type == ToolAuthType.END_USER:
if not end_user_id:
raise ToolProviderNotFoundError("end_user_id is required for END_USER auth_type")
# Query end-user credentials
enduser_provider = (
db.session.query(EndUserAuthenticationProvider)
.where(
EndUserAuthenticationProvider.tenant_id == tenant_id,
EndUserAuthenticationProvider.end_user_id == end_user_id,
EndUserAuthenticationProvider.provider == provider_id,
)
.order_by(EndUserAuthenticationProvider.created_at.asc())
.first()
)
if enduser_provider is None:
raise ToolProviderNotFoundError(
f"No end-user credentials found for provider {provider_id}"
)
# Decrypt end-user credentials
encrypter, cache = create_provider_encrypter(
tenant_id=tenant_id,
config=[
x.to_basic_provider_config()
for x in provider_controller.get_credentials_schema_by_type(enduser_provider.credential_type)
],
cache=ToolProviderCredentialsCache(
tenant_id=tenant_id, provider=provider_id, credential_id=enduser_provider.id
),
)
decrypted_credentials: Mapping[str, Any] = encrypter.decrypt(enduser_provider.credentials)
# Handle OAuth token refresh for end-users if expired
if enduser_provider.expires_at != -1 and (enduser_provider.expires_at - 60) < int(time.time()):
# Refresh credentials using the centralized helper method
refreshed_credentials, expires_at = cls._refresh_oauth_credentials(
tenant_id=tenant_id,
provider_id=provider_id,
user_id=end_user_id,
decrypted_credentials=decrypted_credentials,
)
# Update the provider with refreshed credentials
enduser_provider.encrypted_credentials = json.dumps(encrypter.encrypt(refreshed_credentials))
enduser_provider.expires_at = expires_at
db.session.commit()
decrypted_credentials = refreshed_credentials
cache.delete()
return cast(
BuiltinTool,
builtin_tool.fork_tool_runtime(
runtime=ToolRuntime(
tenant_id=tenant_id,
credentials=dict(decrypted_credentials),
credential_type=CredentialType.of(enduser_provider.credential_type),
runtime_parameters={},
invoke_from=invoke_from,
tool_invoke_from=tool_invoke_from,
)
),
)
# Handle workspace authentication (existing logic)
builtin_provider = None
if isinstance(provider_controller, PluginToolProviderController):
provider_id_entity = ToolProviderID(provider_id)
@@ -270,34 +392,19 @@ class ToolManager:
# check if the credentials is expired
if builtin_provider.expires_at != -1 and (builtin_provider.expires_at - 60) < int(time.time()):
# TODO: circular import
from core.plugin.impl.oauth import OAuthHandler
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
# refresh the credentials
tool_provider = ToolProviderID(provider_id)
provider_name = tool_provider.provider_name
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback"
system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id)
oauth_handler = OAuthHandler()
# refresh the credentials
refreshed_credentials = oauth_handler.refresh_credentials(
# Refresh credentials using the centralized helper method
refreshed_credentials, expires_at = cls._refresh_oauth_credentials(
tenant_id=tenant_id,
provider_id=provider_id,
user_id=builtin_provider.user_id,
plugin_id=tool_provider.plugin_id,
provider=provider_name,
redirect_uri=redirect_uri,
system_credentials=system_credentials or {},
credentials=decrypted_credentials,
decrypted_credentials=decrypted_credentials,
)
# update the credentials
builtin_provider.encrypted_credentials = json.dumps(
encrypter.encrypt(refreshed_credentials.credentials)
)
builtin_provider.expires_at = refreshed_credentials.expires_at
# Update the provider with refreshed credentials
builtin_provider.encrypted_credentials = json.dumps(encrypter.encrypt(refreshed_credentials))
builtin_provider.expires_at = expires_at
db.session.commit()
decrypted_credentials = refreshed_credentials.credentials
decrypted_credentials = refreshed_credentials
cache.delete()
return cast(
@@ -368,6 +475,7 @@ class ToolManager:
agent_tool: AgentToolEntity,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: Optional["VariablePool"] = None,
end_user_id: str | None = None,
) -> Tool:
"""
get the agent tool runtime
@@ -380,6 +488,8 @@ class ToolManager:
invoke_from=invoke_from,
tool_invoke_from=ToolInvokeFrom.AGENT,
credential_id=agent_tool.credential_id,
auth_type=agent_tool.auth_type,
end_user_id=end_user_id,
)
runtime_parameters = {}
parameters = tool_entity.get_merged_runtime_parameters()
@@ -410,6 +520,7 @@ class ToolManager:
workflow_tool: "ToolEntity",
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: Optional["VariablePool"] = None,
end_user_id: str | None = None,
) -> Tool:
"""
get the workflow tool runtime
@@ -423,6 +534,8 @@ class ToolManager:
invoke_from=invoke_from,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
credential_id=workflow_tool.credential_id,
auth_type=workflow_tool.auth_type,
end_user_id=end_user_id,
)
parameters = tool_runtime.get_merged_runtime_parameters()
+2 -1
View File
@@ -3,7 +3,7 @@ from typing import Any, Literal, Union
from pydantic import BaseModel, field_validator
from pydantic_core.core_schema import ValidationInfo
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.entities.tool_entities import ToolAuthType, ToolProviderType
from core.workflow.nodes.base.entities import BaseNodeData
@@ -16,6 +16,7 @@ class ToolEntity(BaseModel):
tool_configurations: dict[str, Any]
credential_id: str | None = None
plugin_unique_identifier: str | None = None # redundancy
auth_type: ToolAuthType = ToolAuthType.WORKSPACE # OAuth authentication level
@field_validator("tool_configurations", mode="before")
@classmethod
+14 -1
View File
@@ -66,6 +66,7 @@ class ToolNode(Node[ToolNodeData]):
# get tool runtime
try:
from core.tools.tool_manager import ToolManager
from models.enums import UserFrom
# This is an issue that caused problems before.
# Logically, we shouldn't use the node_data.version field for judgment
@@ -74,8 +75,20 @@ class ToolNode(Node[ToolNodeData]):
variable_pool: VariablePool | None = None
if self.node_data.version != "1" or self.node_data.tool_node_version is not None:
variable_pool = self.graph_runtime_state.variable_pool
# Determine end_user_id based on user_from
end_user_id = None
if self.user_from == UserFrom.END_USER:
end_user_id = self.user_id
tool_runtime = ToolManager.get_workflow_tool_runtime(
self.tenant_id, self.app_id, self._node_id, self.node_data, self.invoke_from, variable_pool
self.tenant_id,
self.app_id,
self._node_id,
self.node_data,
self.invoke_from,
variable_pool,
end_user_id,
)
except ToolNodeError as e:
yield StreamCompletedEvent(
+66
View File
@@ -0,0 +1,66 @@
"""
Field Encoding/Decoding Utilities
Provides Base64 decoding for sensitive fields (password, verification code)
received from the frontend.
Note: This uses Base64 encoding for obfuscation, not cryptographic encryption.
Real security relies on HTTPS for transport layer encryption.
"""
import base64
import logging
logger = logging.getLogger(__name__)
class FieldEncryption:
"""Handle decoding of sensitive fields during transmission"""
@classmethod
def decrypt_field(cls, encoded_text: str) -> str | None:
"""
Decode Base64 encoded field from frontend.
Args:
encoded_text: Base64 encoded text from frontend
Returns:
Decoded plaintext, or None if decoding fails
"""
try:
# Decode base64
decoded_bytes = base64.b64decode(encoded_text)
decoded_text = decoded_bytes.decode("utf-8")
logger.debug("Field decoding successful")
return decoded_text
except Exception:
# Decoding failed - return None to trigger error in caller
return None
@classmethod
def decrypt_password(cls, encrypted_password: str) -> str | None:
"""
Decrypt password field
Args:
encrypted_password: Encrypted password from frontend
Returns:
Decrypted password or None if decryption fails
"""
return cls.decrypt_field(encrypted_password)
@classmethod
def decrypt_verification_code(cls, encrypted_code: str) -> str | None:
"""
Decrypt verification code field
Args:
encrypted_code: Encrypted code from frontend
Returns:
Decrypted code or None if decryption fails
"""
return cls.decrypt_field(encrypted_code)
@@ -0,0 +1,71 @@
"""add enduser authentication provider
Revision ID: a7b4e8f2c9d1
Revises: fecff1c3da27
Create Date: 2025-11-18 14:00:00.000000
"""
import models as models
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "a7b4e8f2c9d1"
down_revision = "fecff1c3da27"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"tool_enduser_authentication_providers",
sa.Column(
"id",
models.types.StringUUID(),
nullable=False,
),
sa.Column(
"name",
sa.String(length=256),
server_default="API KEY 1",
nullable=False,
),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("end_user_id", models.types.StringUUID(), nullable=False),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("encrypted_credentials", sa.Text(), default="", nullable=False),
sa.Column(
"created_at",
sa.DateTime(),
server_default=sa.func.current_timestamp(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(),
server_default=sa.func.current_timestamp(),
nullable=False,
),
sa.Column(
"credential_type",
sa.String(length=32),
server_default="api-key",
nullable=False,
),
sa.Column("expires_at", sa.BigInteger(), server_default=sa.text("-1"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"end_user_id",
"provider",
),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("tool_enduser_authentication_providers")
# ### end Alembic commands ###
@@ -0,0 +1,25 @@
"""merge_heads
Revision ID: 3134f4e0620d
Revises: d57accd375ae, a7b4e8f2c9d1
Create Date: 2025-12-14 14:18:19.393720
"""
from alembic import op
import models as models
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3134f4e0620d'
down_revision = ('d57accd375ae', 'a7b4e8f2c9d1')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
@@ -0,0 +1,31 @@
"""add type column not null default tool
Revision ID: 03ea244985ce
Revises: d57accd375ae
Create Date: 2025-12-16 18:17:12.193877
"""
from alembic import op
import models as models
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '03ea244985ce'
down_revision = 'd57accd375ae'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('pipeline_recommended_plugins', schema=None) as batch_op:
batch_op.add_column(sa.Column('type', sa.String(length=50), server_default=sa.text("'tool'"), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('pipeline_recommended_plugins', schema=None) as batch_op:
batch_op.drop_column('type')
# ### end Alembic commands ###
+2
View File
@@ -80,6 +80,7 @@ from .task import CeleryTask, CeleryTaskSet
from .tools import (
ApiToolProvider,
BuiltinToolProvider,
EndUserAuthenticationProvider,
ToolConversationVariables,
ToolFile,
ToolLabelBinding,
@@ -149,6 +150,7 @@ __all__ = [
"DocumentSegment",
"Embedding",
"EndUser",
"EndUserAuthenticationProvider",
"ExternalKnowledgeApis",
"ExternalKnowledgeBindings",
"IconType",
+1
View File
@@ -1532,6 +1532,7 @@ class PipelineRecommendedPlugin(TypeBase):
)
plugin_id: Mapped[str] = mapped_column(LongText, nullable=False)
provider_name: Mapped[str] = mapped_column(LongText, nullable=False)
type: Mapped[str] = mapped_column(sa.String(50), nullable=False, server_default=sa.text("'tool'"))
position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
+55
View File
@@ -9,9 +9,11 @@ from deprecated import deprecated
from sqlalchemy import ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from core.plugin.entities.plugin_daemon import CredentialType
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_bundle import ApiToolBundle
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
from libs.uuid_utils import uuidv7
from .base import TypeBase
from .engine import db
@@ -115,6 +117,59 @@ class BuiltinToolProvider(TypeBase):
return cast(dict[str, Any], json.loads(self.encrypted_credentials))
class EndUserAuthenticationProvider(TypeBase):
"""
This table stores the authentication credentials for end users in tools.
Mimics the BuiltinToolProvider structure but for end users instead of tenants.
"""
__tablename__ = "tool_enduser_authentication_providers"
__table_args__ = (
sa.UniqueConstraint("end_user_id", "provider"),
)
# id of the authentication provider
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuidv7()), init=False)
# id of the tenant
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
# id of the end user
end_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
# name of the tool provider
provider: Mapped[str] = mapped_column(LongText, nullable=False)
name: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="API KEY 1",
)
# encrypted credentials for the end user
encrypted_credentials: Mapped[str] = mapped_column(LongText, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime,
nullable=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
init=False,
)
# credential type, e.g., "api-key", "oauth2"
credential_type: Mapped[CredentialType] = mapped_column(
String(32), nullable=False, default=CredentialType.API_KEY
)
# Unix timestamp in seconds since epoch (1970-01-01 UTC); -1 indicates no expiration
expires_at: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=-1)
@property
def credentials(self) -> dict[str, Any]:
if not self.encrypted_credentials:
return {}
try:
return cast(dict[str, Any], json.loads(self.encrypted_credentials))
except json.JSONDecodeError:
return {}
class ApiToolProvider(TypeBase):
"""
The table stores the api providers.
+6 -7
View File
@@ -1248,14 +1248,13 @@ class RagPipelineService:
session.commit()
return workflow_node_execution_db_model
def get_recommended_plugins(self) -> dict:
def get_recommended_plugins(self, type: str) -> dict:
# Query active recommended plugins
pipeline_recommended_plugins = (
db.session.query(PipelineRecommendedPlugin)
.where(PipelineRecommendedPlugin.active == True)
.order_by(PipelineRecommendedPlugin.position.asc())
.all()
)
query = db.session.query(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
if type and type != "all":
query = query.where(PipelineRecommendedPlugin.type == type)
pipeline_recommended_plugins = query.order_by(PipelineRecommendedPlugin.position.asc()).all()
if not pipeline_recommended_plugins:
return {
@@ -0,0 +1,227 @@
import logging
from typing import Any
from sqlalchemy.orm import Session
from core.tools.tool_manager import ToolManager
from extensions.ext_database import db
from models.workflow import Workflow
from services.tools.enduser_auth_service import EndUserAuthService
logger = logging.getLogger(__name__)
class AppAuthRequirementService:
"""
Service for analyzing authentication requirements in apps.
Examines workflow DSL to identify which providers need end-user authentication.
"""
@staticmethod
def get_tool_auth_requirements(
app_id: str,
tenant_id: str,
provider_type: str | None = None,
) -> list[dict[str, Any]]:
"""
Get all authentication requirements for tools in an app.
:param app_id: The application ID
:param tenant_id: The tenant ID
:param provider_type: Optional filter by provider type (e.g., "tool")
:return: List of provider requirements
"""
try:
# Get latest published workflow for the app
with Session(db.engine, autoflush=False) as session:
workflow = (
session.query(Workflow)
.filter_by(app_id=app_id, tenant_id=tenant_id)
.order_by(Workflow.created_at.desc())
.first()
)
if not workflow:
return []
# Parse workflow graph to find tool nodes
graph = workflow.graph_dict
if not graph or "nodes" not in graph:
return []
providers = []
seen_providers = set()
# Iterate through workflow nodes
for node in graph.get("nodes", []):
node_data = node.get("data", {})
node_type = node_data.get("type")
# Check if it's a tool node
if node_type == "tool":
provider_id = node_data.get("provider_id")
provider_name = node_data.get("provider_name")
tool_name = node_data.get("tool_name")
if not provider_id:
continue
# Avoid duplicates
if provider_id in seen_providers:
continue
seen_providers.add(provider_id)
# Get provider controller to check authentication requirements
try:
provider_controller = ToolManager.get_builtin_provider(provider_id, tenant_id)
# Check if provider needs credentials
if not provider_controller.need_credentials:
continue
# Get supported credential types
supported_types = provider_controller.get_supported_credential_types()
# Determine required credential type (prefer OAuth if supported)
required_type = None
if supported_types:
# Prefer OAuth2, then API key
from core.plugin.entities.plugin_daemon import CredentialType
if CredentialType.OAUTH2 in supported_types:
required_type = "oauth2"
elif CredentialType.API_KEY in supported_types:
required_type = "api-key"
else:
required_type = supported_types[0].value
providers.append(
{
"provider_id": provider_id,
"provider_name": provider_name or provider_id,
"supported_credential_types": [ct.value for ct in supported_types],
"required_credential_type": required_type,
"provider_type": "tool",
"feature_context": {
"node_ids": [node.get("id")],
"tool_names": [tool_name] if tool_name else [],
},
}
)
except Exception as e:
logger.warning("Error getting provider info for %s: %s", provider_id, e)
continue
# Filter by provider_type if specified
if provider_type:
providers = [p for p in providers if p.get("provider_type") == provider_type]
return providers
except Exception:
logger.exception("Error getting auth requirements for app %s", app_id)
return []
@staticmethod
def get_required_providers(
tenant_id: str,
app_id: str,
) -> list[dict[str, Any]]:
"""
Get list of providers that require end-user authentication for an app.
Simplified version of get_tool_auth_requirements for API use.
:param tenant_id: The tenant ID
:param app_id: The application ID
:return: List of provider information dictionaries
"""
requirements = AppAuthRequirementService.get_tool_auth_requirements(
app_id=app_id,
tenant_id=tenant_id,
)
# Transform to simpler format for API response
return [
{
"provider_id": req["provider_id"],
"provider_name": req["provider_name"],
"credential_type": req["required_credential_type"],
"is_required": True,
"oauth_config": None if req["required_credential_type"] != "oauth2" else {
"supported_types": req["supported_credential_types"],
},
}
for req in requirements
]
@staticmethod
def get_auth_status(
app_id: str,
end_user_id: str,
tenant_id: str,
) -> dict[str, Any]:
"""
Get overall authentication status for an app and end user.
Shows which providers are authenticated and which need authentication.
:param app_id: The application ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:return: Dict with authentication status for all providers
"""
try:
# Get required providers for this app
required_providers = AppAuthRequirementService.get_tool_auth_requirements(app_id, tenant_id)
# Check authentication status for each provider
provider_statuses = []
for provider_info in required_providers:
provider_id = provider_info["provider_id"]
# Get credentials for this provider
credentials = EndUserAuthService.list_credentials(tenant_id, end_user_id, provider_id)
# Build status
provider_status = {
"provider_id": provider_id,
"provider_name": provider_info["provider_name"],
"provider_type": provider_info["provider_type"],
"authenticated": len(credentials) > 0,
"credentials": [
{
"credential_id": cred.id,
"name": cred.name,
"type": cred.credential_type.value,
"is_default": cred.is_default,
"expires_at": cred.expires_at,
}
for cred in credentials
],
}
provider_statuses.append(provider_status)
return {"providers": provider_statuses}
except Exception:
logger.exception("Error getting auth status for app %s", app_id)
return {"providers": []}
@staticmethod
def is_provider_authenticated(
provider_id: str,
end_user_id: str,
tenant_id: str,
) -> bool:
"""
Check if a specific provider is authenticated for an end user.
:param provider_id: The provider identifier
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:return: True if authenticated, False otherwise
"""
try:
credentials = EndUserAuthService.list_credentials(tenant_id, end_user_id, provider_id)
return len(credentials) > 0
except Exception:
return False
+558
View File
@@ -0,0 +1,558 @@
import json
import logging
from sqlalchemy import exists, select
from sqlalchemy.orm import Session
from constants import HIDDEN_VALUE, UNKNOWN_VALUE
from core.helper.name_generator import generate_incremental_name
from core.helper.provider_cache import NoOpProviderCredentialCache
from core.plugin.entities.plugin_daemon import CredentialType
from core.tools.entities.api_entities import ToolProviderCredentialApiEntity
from core.tools.tool_manager import ToolManager
from core.tools.utils.encryption import create_provider_encrypter
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from models.tools import EndUserAuthenticationProvider
from services.tools.tools_transform_service import ToolTransformService
logger = logging.getLogger(__name__)
class EndUserAuthService:
"""
Service for managing end-user authentication credentials.
Follows similar patterns to BuiltinToolManageService but for end users.
"""
__MAX_CREDENTIALS_PER_PROVIDER__ = 100
@staticmethod
def list_credentials(
tenant_id: str, end_user_id: str, provider_id: str
) -> list[ToolProviderCredentialApiEntity]:
"""
List all credentials for a specific provider and end user.
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param provider_id: The provider identifier
:return: List of credential entities
"""
with Session(db.engine, autoflush=False) as session:
credentials = (
session.query(EndUserAuthenticationProvider)
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider_id)
.order_by(EndUserAuthenticationProvider.created_at.asc())
.all()
)
if not credentials:
return []
# Get provider controller to access credential schema
provider_controller = ToolManager.get_builtin_provider(provider_id, tenant_id)
result: list[ToolProviderCredentialApiEntity] = []
for credential in credentials:
try:
# Create encrypter for masking credentials
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, credential.credential_type
)
# Decrypt and mask credentials
decrypted = encrypter.decrypt(credential.credentials)
masked_credentials = encrypter.mask_plugin_credentials(decrypted)
# Convert to API entity
credential_entity = ToolTransformService.convert_enduser_provider_to_credential_entity(
provider=credential,
credentials=dict(masked_credentials),
)
result.append(credential_entity)
except Exception:
logger.exception("Error processing credential %s", credential.id)
continue
return result
@staticmethod
def get_credential(
credential_id: str, end_user_id: str, tenant_id: str, mask_credentials: bool = True
) -> ToolProviderCredentialApiEntity | None:
"""
Get a specific credential by ID.
:param credential_id: The credential ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param mask_credentials: Whether to mask secret fields
:return: Credential entity or None
"""
with Session(db.engine, autoflush=False) as session:
credential = (
session.query(EndUserAuthenticationProvider)
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
.first()
)
if not credential:
return None
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(credential.provider, tenant_id)
# Create encrypter
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, credential.credential_type
)
# Decrypt credentials
decrypted = encrypter.decrypt(credential.credentials)
# Mask if requested
if mask_credentials:
decrypted = encrypter.mask_plugin_credentials(decrypted)
# Convert to API entity
return ToolTransformService.convert_enduser_provider_to_credential_entity(
provider=credential,
credentials=dict(decrypted),
)
@staticmethod
def create_api_key_credential(
tenant_id: str,
end_user_id: str,
provider_id: str,
credentials: dict,
name: str | None = None,
) -> ToolProviderCredentialApiEntity:
"""
Create a new API key credential for an end user.
:param tenant_id: The tenant ID
:param end_user_id: The end user ID
:param provider_id: The provider identifier
:param credentials: The credential data
:param name: Optional custom name
:return: Created credential entity
"""
with Session(db.engine) as session:
try:
lock = f"enduser_credential_create_lock:{end_user_id}_{provider_id}"
with redis_client.lock(lock, timeout=20):
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(provider_id, tenant_id)
if not provider_controller.need_credentials:
raise ValueError(f"Provider {provider_id} does not need credentials")
# Check credential count
credential_count = (
session.query(EndUserAuthenticationProvider)
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider_id)
.count()
)
if credential_count >= EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__:
raise ValueError(
f"Maximum number of credentials ({EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__}) "
f"reached for provider {provider_id}"
)
# Validate credentials
credential_type = CredentialType.API_KEY
if CredentialType.of(credential_type).is_validate_allowed():
provider_controller.validate_credentials(end_user_id, credentials)
# Generate name if not provided
if name is None or name == "":
name = EndUserAuthService._generate_credential_name(
session=session,
end_user_id=end_user_id,
tenant_id=tenant_id,
provider=provider_id,
credential_type=credential_type,
)
else:
# Validate name length
if len(name) > 30:
raise ValueError("Credential name must be 30 characters or less")
# Check if name is already used
if session.scalar(
select(
exists().where(
EndUserAuthenticationProvider.end_user_id == end_user_id,
EndUserAuthenticationProvider.tenant_id == tenant_id,
EndUserAuthenticationProvider.provider == provider_id,
EndUserAuthenticationProvider.name == name,
)
)
):
raise ValueError(f"The credential name '{name}' is already used")
# Create encrypter
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, credential_type
)
# Create credential record
db_credential = EndUserAuthenticationProvider(
tenant_id=tenant_id,
end_user_id=end_user_id,
provider=provider_id,
encrypted_credentials=json.dumps(encrypter.encrypt(credentials)),
credential_type=credential_type,
name=name,
expires_at=-1, # API keys don't expire
)
session.add(db_credential)
session.commit()
session.refresh(db_credential)
# Return masked credentials
masked_credentials = encrypter.mask_plugin_credentials(credentials)
return ToolTransformService.convert_enduser_provider_to_credential_entity(
provider=db_credential,
credentials=dict(masked_credentials),
)
except Exception as e:
session.rollback()
logger.exception("Error creating API key credential")
raise ValueError(str(e))
@staticmethod
def create_oauth_credential(
end_user_id: str,
tenant_id: str,
provider: str,
credentials: dict,
expires_at: int = -1,
name: str | None = None,
) -> EndUserAuthenticationProvider:
"""
Create a new OAuth credential for an end user.
Used internally by OAuth callback handler.
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param provider: The provider identifier
:param credentials: The OAuth credentials (access_token, refresh_token, etc.)
:param expires_at: Unix timestamp when token expires (-1 for no expiry)
:param name: Optional custom name
:return: Created credential record
"""
with Session(db.engine) as session:
try:
lock = f"enduser_credential_create_lock:{end_user_id}_{provider}"
with redis_client.lock(lock, timeout=20):
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(provider, tenant_id)
# Check credential count
credential_count = (
session.query(EndUserAuthenticationProvider)
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider)
.count()
)
if credential_count >= EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__:
raise ValueError(
f"Maximum number of credentials ({EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__}) "
f"reached for provider {provider}"
)
# Generate name if not provided
credential_type = CredentialType.OAUTH2
if name is None or name == "":
name = EndUserAuthService._generate_credential_name(
session=session,
end_user_id=end_user_id,
tenant_id=tenant_id,
provider=provider,
credential_type=credential_type,
)
# Create encrypter
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, credential_type
)
# Create credential record
db_credential = EndUserAuthenticationProvider(
tenant_id=tenant_id,
end_user_id=end_user_id,
provider=provider,
encrypted_credentials=json.dumps(encrypter.encrypt(credentials)),
credential_type=credential_type,
name=name,
expires_at=expires_at,
)
session.add(db_credential)
session.commit()
session.refresh(db_credential)
return db_credential
except Exception as e:
session.rollback()
logger.exception("Error creating OAuth credential")
raise ValueError(str(e))
@staticmethod
def update_credential(
credential_id: str,
end_user_id: str,
tenant_id: str,
credentials: dict | None = None,
name: str | None = None,
) -> ToolProviderCredentialApiEntity:
"""
Update an existing credential (API key only).
:param credential_id: The credential ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param credentials: Updated credentials (optional)
:param name: Updated name (optional)
:return: Updated credential entity
"""
with Session(db.engine) as session:
try:
# Get credential
db_credential = (
session.query(EndUserAuthenticationProvider)
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
.first()
)
if not db_credential:
raise ValueError(f"Credential {credential_id} not found")
# Only API key credentials can be updated
if not CredentialType.of(db_credential.credential_type).is_editable():
raise ValueError("Only API key credentials can be updated via this endpoint")
# At least one field must be provided
if credentials is None and name is None:
raise ValueError("At least one field (credentials or name) must be provided")
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(db_credential.provider, tenant_id)
# Create encrypter
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, db_credential.credential_type
)
# Update credentials if provided
if credentials:
# Decrypt original credentials
original_credentials = encrypter.decrypt(db_credential.credentials)
# Merge with new credentials, keeping hidden values
new_credentials: dict = {
key: value if value != HIDDEN_VALUE else original_credentials.get(key, UNKNOWN_VALUE)
for key, value in credentials.items()
}
# Validate new credentials
if CredentialType.of(db_credential.credential_type).is_validate_allowed():
provider_controller.validate_credentials(end_user_id, new_credentials)
# Encrypt and save
db_credential.encrypted_credentials = json.dumps(encrypter.encrypt(new_credentials))
# Update name if provided
if name and name != db_credential.name:
# Validate name length
if len(name) > 30:
raise ValueError("Credential name must be 30 characters or less")
# Check if name is already used
if session.scalar(
select(
exists().where(
EndUserAuthenticationProvider.end_user_id == end_user_id,
EndUserAuthenticationProvider.tenant_id == tenant_id,
EndUserAuthenticationProvider.provider == db_credential.provider,
EndUserAuthenticationProvider.name == name,
)
)
):
raise ValueError(f"The credential name '{name}' is already used")
db_credential.name = name
session.commit()
session.refresh(db_credential)
# Return masked credentials
decrypted = encrypter.decrypt(db_credential.credentials)
masked_credentials = encrypter.mask_plugin_credentials(decrypted)
return ToolTransformService.convert_enduser_provider_to_credential_entity(
provider=db_credential,
credentials=dict(masked_credentials),
)
except Exception as e:
session.rollback()
logger.exception("Error updating credential")
raise ValueError(str(e))
@staticmethod
def delete_credential(
tenant_id: str, end_user_id: str, provider_id: str, credential_id: str
) -> bool:
"""
Delete a credential.
:param credential_id: The credential ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:return: True if deleted successfully
"""
with Session(db.engine) as session:
credential = (
session.query(EndUserAuthenticationProvider)
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
.first()
)
if not credential:
raise ValueError(f"Credential {credential_id} not found")
session.delete(credential)
session.commit()
return True
@staticmethod
def refresh_oauth_token(
credential_id: str, end_user_id: str, tenant_id: str, refreshed_credentials: dict, expires_at: int
) -> EndUserAuthenticationProvider:
"""
Update OAuth credentials after token refresh.
:param credential_id: The credential ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param refreshed_credentials: New credentials from OAuth refresh
:param expires_at: New expiration timestamp
:return: Updated credential record
"""
with Session(db.engine) as session:
try:
credential = (
session.query(EndUserAuthenticationProvider)
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
.first()
)
if not credential:
raise ValueError(f"Credential {credential_id} not found")
if credential.credential_type != CredentialType.OAUTH2:
raise ValueError("Only OAuth credentials can be refreshed")
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(credential.provider, tenant_id)
# Create encrypter
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, credential.credential_type
)
# Encrypt and save new credentials
credential.encrypted_credentials = json.dumps(encrypter.encrypt(refreshed_credentials))
credential.expires_at = expires_at
session.commit()
session.refresh(credential)
return credential
except Exception as e:
session.rollback()
logger.exception("Error refreshing OAuth token")
raise ValueError(str(e))
@staticmethod
def get_default_credential(
end_user_id: str, tenant_id: str, provider: str
) -> EndUserAuthenticationProvider | None:
"""
Get the default (oldest) credential for a provider.
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param provider: The provider identifier
:return: Credential record or None
"""
with Session(db.engine, autoflush=False) as session:
return (
session.query(EndUserAuthenticationProvider)
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider)
.order_by(EndUserAuthenticationProvider.created_at.asc())
.first()
)
@staticmethod
def _generate_credential_name(
session: Session,
end_user_id: str,
tenant_id: str,
provider: str,
credential_type: CredentialType,
) -> str:
"""
Generate a unique credential name.
:param session: Database session
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param provider: The provider identifier
:param credential_type: The credential type
:return: Generated name (e.g., "API KEY 1", "AUTH 1")
"""
existing_credentials = (
session.query(EndUserAuthenticationProvider)
.filter_by(
end_user_id=end_user_id,
tenant_id=tenant_id,
provider=provider,
credential_type=credential_type,
)
.order_by(EndUserAuthenticationProvider.created_at.desc())
.all()
)
return generate_incremental_name(
[credential.name for credential in existing_credentials],
f"{credential_type.get_name()}",
)
@staticmethod
def _create_encrypter(
tenant_id: str, provider_controller, credential_type: CredentialType | str
) -> tuple:
"""
Create an encrypter for credential encryption/decryption.
:param tenant_id: The tenant ID
:param provider_controller: The provider controller
:param credential_type: The credential type
:return: Tuple of (encrypter, cache)
"""
if isinstance(credential_type, str):
credential_type = CredentialType.of(credential_type)
return create_provider_encrypter(
tenant_id=tenant_id,
config=[
x.to_basic_provider_config()
for x in provider_controller.get_credentials_schema_by_type(credential_type)
],
cache=NoOpProviderCredentialCache(),
)
+264
View File
@@ -0,0 +1,264 @@
import logging
from typing import Any
from werkzeug import Request
from configs import dify_config
from core.plugin.entities.plugin_daemon import CredentialType
from core.plugin.impl.oauth import OAuthHandler
from core.tools.tool_manager import ToolManager
from models.provider_ids import ToolProviderID
from services.plugin.oauth_service import OAuthProxyService
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
from services.tools.enduser_auth_service import EndUserAuthService
logger = logging.getLogger(__name__)
class EndUserOAuthService:
"""
Service for managing end-user OAuth authentication flows.
Reuses existing OAuthProxyService and OAuthHandler infrastructure.
"""
@staticmethod
def get_authorization_url(
end_user_id: str,
tenant_id: str,
app_id: str,
provider: str,
) -> dict[str, str]:
"""
Initiate OAuth authorization flow for an end user.
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param app_id: The application ID
:param provider: The provider identifier
:return: Dict with authorization_url
"""
try:
# Get OAuth client configuration (reuse workspace-level logic)
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, provider)
if not oauth_client:
raise ValueError(f"OAuth client not configured for provider {provider}")
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(provider, tenant_id)
tool_provider_id = ToolProviderID(provider)
# Create OAuth context with end-user specific data
context_id = OAuthProxyService.create_proxy_context(
user_id=end_user_id, # Using end_user_id as user_id
tenant_id=tenant_id,
plugin_id=tool_provider_id.plugin_id,
provider=tool_provider_id.provider_name,
extra_data={
"app_id": app_id,
"provider_type": "tool", # For now, only tools support end-user auth
},
)
# Use the same redirect URI as workspace OAuth to reuse the same OAuth client
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{tool_provider_id}/tool/callback"
# Get authorization URL from OAuth handler
oauth_handler = OAuthHandler()
response = oauth_handler.get_authorization_url(
tenant_id=tenant_id,
user_id=end_user_id,
plugin_id=tool_provider_id.plugin_id,
provider=tool_provider_id.provider_name,
redirect_uri=redirect_uri,
system_credentials=oauth_client,
)
return {
"authorization_url": response.authorization_url,
"context_id": context_id, # Return for setting cookie
}
except Exception as e:
logger.exception("Error getting authorization URL for end user")
raise ValueError(f"Failed to initiate OAuth flow: {str(e)}")
@staticmethod
def handle_oauth_callback(
context_id: str,
request: Request,
) -> dict[str, Any]:
"""
Handle OAuth callback and create credential.
:param context_id: The OAuth context ID from cookie
:param request: The callback request with authorization code
:return: Dict with credential information
"""
try:
# Validate and retrieve context
context = OAuthProxyService.use_proxy_context(context_id)
# Extract context data
end_user_id = context.get("user_id") # user_id is actually end_user_id
tenant_id = context.get("tenant_id")
app_id = context.get("app_id")
plugin_id = context.get("plugin_id")
provider = context.get("provider")
if not all([end_user_id, tenant_id, app_id, plugin_id, provider]):
raise ValueError("Invalid OAuth context: missing required fields")
# Reconstruct full provider ID
full_provider = f"{plugin_id}/{provider}" if plugin_id != "langgenius" else provider
# Get OAuth client configuration
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, full_provider)
if not oauth_client:
raise ValueError(f"OAuth client not configured for provider {full_provider}")
# Use the same redirect URI as workspace OAuth (must match authorization request)
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{full_provider}/tool/callback"
# Exchange authorization code for credentials
oauth_handler = OAuthHandler()
credentials_response = oauth_handler.get_credentials(
tenant_id=tenant_id,
user_id=end_user_id,
plugin_id=plugin_id,
provider=provider,
redirect_uri=redirect_uri,
system_credentials=oauth_client,
request=request,
)
# Calculate expiration timestamp
expires_at = -1
if credentials_response.expires_in and credentials_response.expires_in > 0:
import time
expires_at = int(time.time()) + credentials_response.expires_in
# Create credential in database
credential = EndUserAuthService.create_oauth_credential(
end_user_id=end_user_id,
tenant_id=tenant_id,
provider=full_provider,
credentials=credentials_response.credentials,
expires_at=expires_at,
)
return {
"success": True,
"credential_id": credential.id,
"provider": full_provider,
"app_id": app_id,
}
except Exception as e:
logger.exception("Error handling OAuth callback for end user")
raise ValueError(f"Failed to complete OAuth flow: {str(e)}")
@staticmethod
def refresh_oauth_token(
credential_id: str,
end_user_id: str,
tenant_id: str,
) -> dict[str, Any]:
"""
Refresh an expired OAuth token.
:param credential_id: The credential ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:return: Dict with refresh status
"""
try:
# Get existing credential
credential = EndUserAuthService.get_credential(
credential_id=credential_id,
end_user_id=end_user_id,
tenant_id=tenant_id,
mask_credentials=False, # Need full credentials for refresh
)
if not credential:
raise ValueError(f"Credential {credential_id} not found")
if credential.credential_type != CredentialType.OAUTH2:
raise ValueError("Only OAuth credentials can be refreshed")
# Get OAuth client configuration
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, credential.provider)
if not oauth_client:
raise ValueError(f"OAuth client not configured for provider {credential.provider}")
# Get provider info
tool_provider_id = ToolProviderID(credential.provider)
# Use the same redirect URI as workspace OAuth to reuse the same OAuth client
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{credential.provider}/tool/callback"
# Refresh credentials via OAuth handler
oauth_handler = OAuthHandler()
refreshed_response = oauth_handler.refresh_credentials(
tenant_id=tenant_id,
user_id=end_user_id,
plugin_id=tool_provider_id.plugin_id,
provider=tool_provider_id.provider_name,
redirect_uri=redirect_uri,
system_credentials=oauth_client,
credentials=credential.credentials,
)
# Calculate new expiration timestamp
expires_at = -1
if refreshed_response.expires_in and refreshed_response.expires_in > 0:
import time
expires_at = int(time.time()) + refreshed_response.expires_in
# Update credential in database
updated_credential = EndUserAuthService.refresh_oauth_token(
credential_id=credential_id,
end_user_id=end_user_id,
tenant_id=tenant_id,
refreshed_credentials=refreshed_response.credentials,
expires_at=expires_at,
)
return {
"success": True,
"credential_id": updated_credential.id,
"expires_at": expires_at,
"refreshed_at": int(updated_credential.updated_at.timestamp()),
}
except Exception:
logger.exception("Error refreshing OAuth token for end user")
return {
"success": False,
"error": "Failed to refresh token",
}
@staticmethod
def get_oauth_client_info(tenant_id: str, provider: str) -> dict[str, Any]:
"""
Get OAuth client information for a provider.
Used to check if OAuth is available and configured.
:param tenant_id: The tenant ID
:param provider: The provider identifier
:return: Dict with OAuth client info
"""
try:
# Check if OAuth client exists (either system or custom)
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, provider)
return {
"configured": oauth_client is not None,
"system_configured": BuiltinToolManageService.is_oauth_system_client_exists(provider),
"custom_configured": BuiltinToolManageService.is_oauth_custom_client_enabled(tenant_id, provider),
}
except Exception:
logger.exception("Error getting OAuth client info")
return {
"configured": False,
"system_configured": False,
"custom_configured": False,
}
@@ -426,6 +426,27 @@ class ToolTransformService:
credentials=credentials,
)
@staticmethod
def convert_enduser_provider_to_credential_entity(
provider, credentials: dict
) -> ToolProviderCredentialApiEntity:
"""
Convert EndUserAuthenticationProvider to ToolProviderCredentialApiEntity.
:param provider: EndUserAuthenticationProvider instance
:param credentials: Decrypted/masked credentials dict
:return: ToolProviderCredentialApiEntity
"""
return ToolProviderCredentialApiEntity(
id=provider.id,
name=provider.name,
provider=provider.provider,
credential_type=CredentialType.of(provider.credential_type),
is_default=False, # End-user credentials don't have default flag (use oldest)
credentials=credentials,
expires_at=provider.expires_at,
)
@staticmethod
def convert_mcp_schema_to_parameter(schema: dict[str, Any]) -> list["ToolParameter"]:
"""
+7 -1
View File
@@ -410,9 +410,12 @@ class VariableTruncator(BaseTruncator):
@overload
def _truncate_json_primitives(self, val: None, target_size: int) -> _PartResult[None]: ...
@overload
def _truncate_json_primitives(self, val: File, target_size: int) -> _PartResult[File]: ...
def _truncate_json_primitives(
self,
val: UpdatedVariable | str | list[object] | dict[str, object] | bool | int | float | None,
val: UpdatedVariable | File | str | list[object] | dict[str, object] | bool | int | float | None,
target_size: int,
) -> _PartResult[Any]:
"""Truncate a value within an object to fit within budget."""
@@ -425,6 +428,9 @@ class VariableTruncator(BaseTruncator):
return self._truncate_array(val, target_size)
elif isinstance(val, dict):
return self._truncate_object(val, target_size)
elif isinstance(val, File):
# File objects should not be truncated, return as-is
return _PartResult(val, self.calculate_json_size(val), False)
elif val is None or isinstance(val, (bool, int, float)):
return _PartResult(val, self.calculate_json_size(val), False)
else:
@@ -113,16 +113,31 @@ class TestShardedRedisBroadcastChannelIntegration:
topic = broadcast_channel.topic(topic_name)
producer = topic.as_producer()
subscriptions = [topic.subscribe() for _ in range(subscriber_count)]
ready_events = [threading.Event() for _ in range(subscriber_count)]
def producer_thread():
time.sleep(0.2) # Allow all subscribers to connect
deadline = time.time() + 5.0
for ev in ready_events:
remaining = deadline - time.time()
if remaining <= 0:
break
if not ev.wait(timeout=max(0.0, remaining)):
pytest.fail("subscriber did not become ready before publish deadline")
producer.publish(message)
time.sleep(0.2)
for sub in subscriptions:
sub.close()
def consumer_thread(subscription: Subscription) -> list[bytes]:
def consumer_thread(subscription: Subscription, ready_event: threading.Event) -> list[bytes]:
received_msgs = []
# Prime subscription so the underlying Pub/Sub listener thread starts before publishing
try:
_ = subscription.receive(0.01)
except SubscriptionClosedError:
return received_msgs
finally:
ready_event.set()
while True:
try:
msg = subscription.receive(0.1)
@@ -137,7 +152,10 @@ class TestShardedRedisBroadcastChannelIntegration:
with ThreadPoolExecutor(max_workers=subscriber_count + 1) as executor:
producer_future = executor.submit(producer_thread)
consumer_futures = [executor.submit(consumer_thread, subscription) for subscription in subscriptions]
consumer_futures = [
executor.submit(consumer_thread, subscription, ready_events[idx])
for idx, subscription in enumerate(subscriptions)
]
producer_future.result(timeout=10.0)
msgs_by_consumers = []
@@ -1,5 +1,6 @@
"""Test authentication security to prevent user enumeration."""
import base64
from unittest.mock import MagicMock, patch
import pytest
@@ -11,6 +12,11 @@ from controllers.console.auth.error import AuthenticationFailedError
from controllers.console.auth.login import LoginApi
def encode_password(password: str) -> str:
"""Helper to encode password as Base64 for testing."""
return base64.b64encode(password.encode("utf-8")).decode()
class TestAuthenticationSecurity:
"""Test authentication endpoints for security against user enumeration."""
@@ -42,7 +48,9 @@ class TestAuthenticationSecurity:
# Act
with self.app.test_request_context(
"/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"}
"/login",
method="POST",
json={"email": "nonexistent@example.com", "password": encode_password("WrongPass123!")},
):
login_api = LoginApi()
@@ -72,7 +80,9 @@ class TestAuthenticationSecurity:
# Act
with self.app.test_request_context(
"/login", method="POST", json={"email": "existing@example.com", "password": "WrongPass123!"}
"/login",
method="POST",
json={"email": "existing@example.com", "password": encode_password("WrongPass123!")},
):
login_api = LoginApi()
@@ -104,7 +114,9 @@ class TestAuthenticationSecurity:
# Act
with self.app.test_request_context(
"/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"}
"/login",
method="POST",
json={"email": "nonexistent@example.com", "password": encode_password("WrongPass123!")},
):
login_api = LoginApi()
@@ -8,6 +8,7 @@ This module tests the email code login mechanism including:
- Workspace creation for new users
"""
import base64
from unittest.mock import MagicMock, patch
import pytest
@@ -25,6 +26,11 @@ from controllers.console.error import (
from services.errors.account import AccountRegisterError
def encode_code(code: str) -> str:
"""Helper to encode verification code as Base64 for testing."""
return base64.b64encode(code.encode("utf-8")).decode()
class TestEmailCodeLoginSendEmailApi:
"""Test cases for sending email verification codes."""
@@ -290,7 +296,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "test@example.com", "code": "123456", "token": "valid_token"},
json={"email": "test@example.com", "code": encode_code("123456"), "token": "valid_token"},
):
api = EmailCodeLoginApi()
response = api.post()
@@ -339,7 +345,12 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "newuser@example.com", "code": "123456", "token": "valid_token", "language": "en-US"},
json={
"email": "newuser@example.com",
"code": encode_code("123456"),
"token": "valid_token",
"language": "en-US",
},
):
api = EmailCodeLoginApi()
response = api.post()
@@ -365,7 +376,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "test@example.com", "code": "123456", "token": "invalid_token"},
json={"email": "test@example.com", "code": encode_code("123456"), "token": "invalid_token"},
):
api = EmailCodeLoginApi()
with pytest.raises(InvalidTokenError):
@@ -388,7 +399,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "different@example.com", "code": "123456", "token": "token"},
json={"email": "different@example.com", "code": encode_code("123456"), "token": "token"},
):
api = EmailCodeLoginApi()
with pytest.raises(InvalidEmailError):
@@ -411,7 +422,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "test@example.com", "code": "wrong_code", "token": "token"},
json={"email": "test@example.com", "code": encode_code("wrong_code"), "token": "token"},
):
api = EmailCodeLoginApi()
with pytest.raises(EmailCodeError):
@@ -497,7 +508,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "test@example.com", "code": "123456", "token": "token"},
json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
):
api = EmailCodeLoginApi()
with pytest.raises(WorkspacesLimitExceeded):
@@ -539,7 +550,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "test@example.com", "code": "123456", "token": "token"},
json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
):
api = EmailCodeLoginApi()
with pytest.raises(NotAllowedCreateWorkspace):
@@ -8,6 +8,7 @@ This module tests the core authentication endpoints including:
- Account status validation
"""
import base64
from unittest.mock import MagicMock, patch
import pytest
@@ -28,6 +29,11 @@ from controllers.console.error import (
from services.errors.account import AccountLoginError, AccountPasswordError
def encode_password(password: str) -> str:
"""Helper to encode password as Base64 for testing."""
return base64.b64encode(password.encode("utf-8")).decode()
class TestLoginApi:
"""Test cases for the LoginApi endpoint."""
@@ -106,7 +112,9 @@ class TestLoginApi:
# Act
with app.test_request_context(
"/login", method="POST", json={"email": "test@example.com", "password": "ValidPass123!"}
"/login",
method="POST",
json={"email": "test@example.com", "password": encode_password("ValidPass123!")},
):
login_api = LoginApi()
response = login_api.post()
@@ -158,7 +166,11 @@ class TestLoginApi:
with app.test_request_context(
"/login",
method="POST",
json={"email": "test@example.com", "password": "ValidPass123!", "invite_token": "valid_token"},
json={
"email": "test@example.com",
"password": encode_password("ValidPass123!"),
"invite_token": "valid_token",
},
):
login_api = LoginApi()
response = login_api.post()
@@ -186,7 +198,7 @@ class TestLoginApi:
# Act & Assert
with app.test_request_context(
"/login", method="POST", json={"email": "test@example.com", "password": "password"}
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("password")}
):
login_api = LoginApi()
with pytest.raises(EmailPasswordLoginLimitError):
@@ -209,7 +221,7 @@ class TestLoginApi:
# Act & Assert
with app.test_request_context(
"/login", method="POST", json={"email": "frozen@example.com", "password": "password"}
"/login", method="POST", json={"email": "frozen@example.com", "password": encode_password("password")}
):
login_api = LoginApi()
with pytest.raises(AccountInFreezeError):
@@ -246,7 +258,7 @@ class TestLoginApi:
# Act & Assert
with app.test_request_context(
"/login", method="POST", json={"email": "test@example.com", "password": "WrongPass123!"}
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("WrongPass123!")}
):
login_api = LoginApi()
with pytest.raises(AuthenticationFailedError):
@@ -277,7 +289,7 @@ class TestLoginApi:
# Act & Assert
with app.test_request_context(
"/login", method="POST", json={"email": "banned@example.com", "password": "ValidPass123!"}
"/login", method="POST", json={"email": "banned@example.com", "password": encode_password("ValidPass123!")}
):
login_api = LoginApi()
with pytest.raises(AccountBannedError):
@@ -322,7 +334,7 @@ class TestLoginApi:
# Act & Assert
with app.test_request_context(
"/login", method="POST", json={"email": "test@example.com", "password": "ValidPass123!"}
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("ValidPass123!")}
):
login_api = LoginApi()
with pytest.raises(WorkspacesLimitExceeded):
@@ -349,7 +361,11 @@ class TestLoginApi:
with app.test_request_context(
"/login",
method="POST",
json={"email": "different@example.com", "password": "ValidPass123!", "invite_token": "token"},
json={
"email": "different@example.com",
"password": encode_password("ValidPass123!"),
"invite_token": "token",
},
):
login_api = LoginApi()
with pytest.raises(InvalidEmailError):
@@ -0,0 +1,486 @@
"""
Unit tests for end-user tool authentication.
Tests the integration of end-user authentication with tool runtime resolution.
"""
import time
from unittest.mock import MagicMock, patch
import pytest
from core.app.entities.app_invoke_entities import InvokeFrom
from core.tools.entities.tool_entities import ToolAuthType, ToolInvokeFrom, ToolProviderType
from core.tools.errors import ToolProviderNotFoundError
from core.tools.tool_manager import ToolManager
@pytest.fixture
def mock_db_session():
"""Mock database session."""
with patch("core.tools.tool_manager.db") as mock_db:
yield mock_db.session
@pytest.fixture
def mock_provider_controller():
"""Mock builtin provider controller."""
controller = MagicMock()
controller.need_credentials = True
controller.get_tool = MagicMock(return_value=MagicMock())
controller.get_credentials_schema_by_type = MagicMock(return_value=[])
return controller
class TestEndUserToolAuthentication:
"""Test suite for end-user tool authentication."""
def test_end_user_auth_requires_end_user_id(self, mock_db_session, mock_provider_controller):
"""
Test that END_USER auth_type requires end_user_id parameter.
When auth_type is END_USER but end_user_id is None, should raise error.
"""
with patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller):
with pytest.raises(ToolProviderNotFoundError, match="end_user_id is required"):
ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="test_provider",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id=None, # Missing!
)
def test_end_user_auth_missing_credentials(self, mock_db_session, mock_provider_controller):
"""
Test that error is raised when end-user has no credentials for provider.
When auth_type is END_USER but no credentials exist, should raise error.
"""
# Mock no credentials found
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = None
with patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller):
with pytest.raises(ToolProviderNotFoundError, match="No end-user credentials found"):
ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="test_provider",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id="end_user_123",
)
def test_end_user_auth_with_credentials(self, mock_db_session, mock_provider_controller):
"""
Test successful end-user credential resolution.
When auth_type is END_USER and credentials exist, should return tool runtime.
"""
# Mock end-user provider
mock_enduser_provider = MagicMock()
mock_enduser_provider.id = "cred_123"
mock_enduser_provider.credential_type = "api-key"
mock_enduser_provider.credentials = '{"api_key": "encrypted"}'
mock_enduser_provider.expires_at = -1 # No expiry
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_enduser_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"api_key": "decrypted_key"}
mock_cache = MagicMock()
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="test_provider",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id="end_user_123",
)
# Verify tool runtime was created
assert tool_runtime is not None
# Verify encrypter was called with decrypted credentials
mock_encrypter.decrypt.assert_called_once()
def test_workspace_auth_backward_compatibility(self, mock_db_session, mock_provider_controller):
"""
Test that workspace authentication still works (backward compatibility).
When auth_type is WORKSPACE (default), should use workspace credentials.
"""
# Mock workspace provider
mock_workspace_provider = MagicMock()
mock_workspace_provider.id = "workspace_cred_123"
mock_workspace_provider.credential_type = "api-key"
mock_workspace_provider.credentials = '{"api_key": "workspace_encrypted"}'
mock_workspace_provider.expires_at = -1
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_workspace_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"api_key": "workspace_decrypted"}
mock_cache = MagicMock()
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
patch("core.helper.credential_utils.check_credential_policy_compliance"),
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="test_provider",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.WORKSPACE, # Workspace auth
end_user_id=None, # Not needed for workspace auth
)
# Verify tool runtime was created
assert tool_runtime is not None
def test_workflow_tool_runtime_passes_end_user_id(self, mock_db_session, mock_provider_controller):
"""
Test that get_workflow_tool_runtime correctly passes end_user_id to get_tool_runtime.
"""
from core.workflow.nodes.tool.entities import ToolEntity
# Create a mock ToolEntity with END_USER auth_type
workflow_tool = MagicMock(spec=ToolEntity)
workflow_tool.provider_type = ToolProviderType.BUILT_IN
workflow_tool.provider_id = "test_provider"
workflow_tool.tool_name = "test_tool"
workflow_tool.credential_id = None
workflow_tool.auth_type = ToolAuthType.END_USER
workflow_tool.tool_configurations = {}
# Mock end-user credentials
mock_enduser_provider = MagicMock()
mock_enduser_provider.id = "cred_123"
mock_enduser_provider.credential_type = "api-key"
mock_enduser_provider.credentials = '{"api_key": "encrypted"}'
mock_enduser_provider.expires_at = -1
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_enduser_provider
)
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"api_key": "decrypted"}
mock_cache = MagicMock()
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
):
tool_runtime = ToolManager.get_workflow_tool_runtime(
tenant_id="test_tenant",
app_id="test_app",
node_id="test_node",
workflow_tool=workflow_tool,
invoke_from=InvokeFrom.SERVICE_API,
variable_pool=None,
end_user_id="end_user_123", # Pass end_user_id
)
# Verify tool runtime was created
assert tool_runtime is not None
class TestOAuthTokenRefresh:
"""Test suite for OAuth token refresh functionality."""
def test_enduser_oauth_token_refresh_when_expired(self, mock_db_session, mock_provider_controller):
"""
Test that end-user OAuth tokens are automatically refreshed when expired.
When an OAuth token is expired (expires_at < current_time + 60s buffer),
the system should automatically refresh it before using.
"""
# Mock end-user provider with expired OAuth token
mock_enduser_provider = MagicMock()
mock_enduser_provider.id = "cred_123"
mock_enduser_provider.credential_type = "oauth2"
mock_enduser_provider.credentials = '{"access_token": "old_token", "refresh_token": "refresh"}'
# Set expiry to past (token expired)
mock_enduser_provider.expires_at = int(time.time()) - 100
mock_enduser_provider.encrypted_credentials = '{"access_token": "old_token", "refresh_token": "refresh"}'
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_enduser_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"access_token": "old_token", "refresh_token": "refresh"}
mock_encrypter.encrypt.return_value = {"access_token": "new_token", "refresh_token": "refresh"}
mock_cache = MagicMock()
# Mock OAuth refresh response
mock_refreshed_credentials = MagicMock()
mock_refreshed_credentials.credentials = {"access_token": "new_token", "refresh_token": "refresh"}
mock_refreshed_credentials.expires_at = int(time.time()) + 3600 # New expiry 1 hour from now
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
patch.object(
ToolManager,
"_refresh_oauth_credentials",
return_value=(
mock_refreshed_credentials.credentials,
mock_refreshed_credentials.expires_at,
),
) as mock_refresh,
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="github",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id="end_user_123",
)
# Verify refresh was called
mock_refresh.assert_called_once_with(
tenant_id="test_tenant",
provider_id="github",
user_id="end_user_123",
decrypted_credentials={"access_token": "old_token", "refresh_token": "refresh"},
)
# Verify provider was updated with new credentials
assert mock_enduser_provider.expires_at == mock_refreshed_credentials.expires_at
mock_db_session.commit.assert_called_once()
mock_cache.delete.assert_called_once()
# Verify tool runtime was created
assert tool_runtime is not None
def test_enduser_oauth_token_not_refreshed_when_valid(self, mock_db_session, mock_provider_controller):
"""
Test that valid OAuth tokens are NOT refreshed.
When an OAuth token is still valid (expires_at > current_time + 60s buffer),
the system should use it without refreshing.
"""
# Mock end-user provider with valid OAuth token
mock_enduser_provider = MagicMock()
mock_enduser_provider.id = "cred_123"
mock_enduser_provider.credential_type = "oauth2"
mock_enduser_provider.credentials = '{"access_token": "valid_token", "refresh_token": "refresh"}'
# Set expiry to future (token still valid with buffer)
mock_enduser_provider.expires_at = int(time.time()) + 3600 # Valid for 1 hour
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_enduser_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"access_token": "valid_token", "refresh_token": "refresh"}
mock_cache = MagicMock()
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
patch.object(ToolManager, "_refresh_oauth_credentials") as mock_refresh,
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="github",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id="end_user_123",
)
# Verify refresh was NOT called (token still valid)
mock_refresh.assert_not_called()
# Verify tool runtime was created with original credentials
assert tool_runtime is not None
def test_workspace_oauth_token_refresh_when_expired(self, mock_db_session, mock_provider_controller):
"""
Test that workspace OAuth tokens are automatically refreshed when expired.
This ensures the refactored _refresh_oauth_credentials helper works
for both end-user and workspace authentication flows.
"""
# Mock workspace provider with expired OAuth token
mock_workspace_provider = MagicMock()
mock_workspace_provider.id = "workspace_cred_123"
mock_workspace_provider.user_id = "workspace_user_456"
mock_workspace_provider.credential_type = "oauth2"
mock_workspace_provider.credentials = '{"access_token": "old_workspace_token"}'
mock_workspace_provider.expires_at = int(time.time()) - 100 # Expired
mock_workspace_provider.encrypted_credentials = '{"access_token": "old_workspace_token"}'
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_workspace_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"access_token": "old_workspace_token"}
mock_encrypter.encrypt.return_value = {"access_token": "new_workspace_token"}
mock_cache = MagicMock()
# Mock OAuth refresh response
refreshed_creds = {"access_token": "new_workspace_token"}
new_expires_at = int(time.time()) + 3600
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
patch("core.helper.credential_utils.check_credential_policy_compliance"),
patch.object(
ToolManager, "_refresh_oauth_credentials", return_value=(refreshed_creds, new_expires_at)
) as mock_refresh,
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="github",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.WORKSPACE,
)
# Verify refresh was called with workspace user_id
mock_refresh.assert_called_once_with(
tenant_id="test_tenant",
provider_id="github",
user_id="workspace_user_456",
decrypted_credentials={"access_token": "old_workspace_token"},
)
# Verify provider was updated
assert mock_workspace_provider.expires_at == new_expires_at
mock_db_session.commit.assert_called_once()
mock_cache.delete.assert_called_once()
# Verify tool runtime was created
assert tool_runtime is not None
def test_oauth_token_no_refresh_for_non_oauth_credentials(self, mock_db_session, mock_provider_controller):
"""
Test that non-OAuth credentials (API keys) are never refreshed.
API keys with expires_at = -1 should not trigger refresh logic.
"""
# Mock end-user provider with API key (no expiry)
mock_enduser_provider = MagicMock()
mock_enduser_provider.id = "cred_123"
mock_enduser_provider.credential_type = "api-key"
mock_enduser_provider.credentials = '{"api_key": "sk-1234567890"}'
mock_enduser_provider.expires_at = -1 # API keys don't expire
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_enduser_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"api_key": "sk-1234567890"}
mock_cache = MagicMock()
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
patch.object(ToolManager, "_refresh_oauth_credentials") as mock_refresh,
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="openai",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id="end_user_123",
)
# Verify refresh was NOT called (API key doesn't need refresh)
mock_refresh.assert_not_called()
# Verify tool runtime was created
assert tool_runtime is not None
def test_refresh_oauth_credentials_helper_method(self):
"""
Test the _refresh_oauth_credentials helper method directly.
This tests the centralized OAuth refresh logic that is used by both
end-user and workspace authentication flows.
"""
# Mock dependencies
mock_oauth_handler = MagicMock()
mock_refreshed = MagicMock()
mock_refreshed.credentials = {"access_token": "new_token", "refresh_token": "new_refresh"}
mock_refreshed.expires_at = int(time.time()) + 7200
mock_oauth_handler.refresh_credentials.return_value = mock_refreshed
with (
# Patch OAuthHandler where it's imported (inside the method)
patch("core.plugin.impl.oauth.OAuthHandler", return_value=mock_oauth_handler),
patch("core.tools.tool_manager.ToolProviderID") as mock_provider_id,
patch(
"services.tools.builtin_tools_manage_service.BuiltinToolManageService.get_oauth_client",
return_value={"client_id": "test"},
),
patch("core.tools.tool_manager.dify_config.CONSOLE_API_URL", "http://localhost:5001"),
):
# Setup provider ID mock
mock_provider_id.return_value.provider_name = "github"
mock_provider_id.return_value.plugin_id = "builtin"
# Call the helper method
credentials, expires_at = ToolManager._refresh_oauth_credentials(
tenant_id="test_tenant",
provider_id="langgenius/github/github",
user_id="user_123",
decrypted_credentials={"access_token": "old_token", "refresh_token": "old_refresh"},
)
# Verify OAuth handler was called correctly
mock_oauth_handler.refresh_credentials.assert_called_once_with(
tenant_id="test_tenant",
user_id="user_123",
plugin_id="builtin",
provider="github",
redirect_uri="http://localhost:5001/console/api/oauth/plugin/langgenius/github/github/tool/callback",
system_credentials={"client_id": "test"},
credentials={"access_token": "old_token", "refresh_token": "old_refresh"},
)
# Verify returned values
assert credentials == {"access_token": "new_token", "refresh_token": "new_refresh"}
assert expires_at == mock_refreshed.expires_at
@@ -0,0 +1,150 @@
"""
Unit tests for field encoding/decoding utilities.
These tests verify Base64 encoding/decoding functionality and
proper error handling and fallback behavior.
"""
import base64
from libs.encryption import FieldEncryption
class TestDecodeField:
"""Test cases for field decoding functionality."""
def test_decode_valid_base64(self):
"""Test decoding a valid Base64 encoded string."""
plaintext = "password123"
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
result = FieldEncryption.decrypt_field(encoded)
assert result == plaintext
def test_decode_non_base64_returns_none(self):
"""Test that non-base64 input returns None."""
non_base64 = "plain-password-!@#"
result = FieldEncryption.decrypt_field(non_base64)
# Should return None (decoding failed)
assert result is None
def test_decode_unicode_text(self):
"""Test decoding Base64 encoded Unicode text."""
plaintext = "密码Test123"
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
result = FieldEncryption.decrypt_field(encoded)
assert result == plaintext
def test_decode_empty_string(self):
"""Test decoding an empty string returns empty string."""
result = FieldEncryption.decrypt_field("")
# Empty string base64 decodes to empty string
assert result == ""
def test_decode_special_characters(self):
"""Test decoding with special characters."""
plaintext = "P@ssw0rd!#$%^&*()"
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
result = FieldEncryption.decrypt_field(encoded)
assert result == plaintext
class TestDecodePassword:
"""Test cases for password decoding."""
def test_decode_password_base64(self):
"""Test decoding a Base64 encoded password."""
password = "SecureP@ssw0rd!"
encoded = base64.b64encode(password.encode("utf-8")).decode()
result = FieldEncryption.decrypt_password(encoded)
assert result == password
def test_decode_password_invalid_returns_none(self):
"""Test that invalid base64 passwords return None."""
invalid = "PlainPassword!@#"
result = FieldEncryption.decrypt_password(invalid)
# Should return None (decoding failed)
assert result is None
class TestDecodeVerificationCode:
"""Test cases for verification code decoding."""
def test_decode_code_base64(self):
"""Test decoding a Base64 encoded verification code."""
code = "789012"
encoded = base64.b64encode(code.encode("utf-8")).decode()
result = FieldEncryption.decrypt_verification_code(encoded)
assert result == code
def test_decode_code_invalid_returns_none(self):
"""Test that invalid base64 codes return None."""
invalid = "123456" # Plain 6-digit code, not base64
result = FieldEncryption.decrypt_verification_code(invalid)
# Should return None (decoding failed)
assert result is None
class TestRoundTripEncodingDecoding:
"""
Integration tests for complete encoding-decoding cycle.
These tests simulate the full frontend-to-backend flow using Base64.
"""
def test_roundtrip_password(self):
"""Test encoding and decoding a password."""
original_password = "SecureP@ssw0rd!"
# Simulate frontend encoding (Base64)
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
# Backend decoding
decoded = FieldEncryption.decrypt_password(encoded)
assert decoded == original_password
def test_roundtrip_verification_code(self):
"""Test encoding and decoding a verification code."""
original_code = "123456"
# Simulate frontend encoding
encoded = base64.b64encode(original_code.encode("utf-8")).decode()
# Backend decoding
decoded = FieldEncryption.decrypt_verification_code(encoded)
assert decoded == original_code
def test_roundtrip_unicode_password(self):
"""Test encoding and decoding password with Unicode characters."""
original_password = "密码Test123!@#"
# Frontend encoding
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
# Backend decoding
decoded = FieldEncryption.decrypt_password(encoded)
assert decoded == original_password
def test_roundtrip_long_password(self):
"""Test encoding and decoding a long password."""
original_password = "ThisIsAVeryLongPasswordWithLotsOfCharacters123!@#$%^&*()"
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
decoded = FieldEncryption.decrypt_password(encoded)
assert decoded == original_password
def test_roundtrip_with_whitespace(self):
"""Test encoding and decoding with whitespace."""
original_password = "pass word with spaces"
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
decoded = FieldEncryption.decrypt_field(encoded)
assert decoded == original_password
@@ -518,6 +518,55 @@ class TestEdgeCases:
assert isinstance(result.result, StringSegment)
class TestTruncateJsonPrimitives:
"""Test _truncate_json_primitives method with different data types."""
@pytest.fixture
def truncator(self):
return VariableTruncator()
def test_truncate_json_primitives_file_type(self, truncator, file):
"""Test that File objects are handled correctly in _truncate_json_primitives."""
# Test File object is returned as-is without truncation
result = truncator._truncate_json_primitives(file, 1000)
assert result.value == file
assert result.truncated is False
# Size should be calculated correctly
expected_size = VariableTruncator.calculate_json_size(file)
assert result.value_size == expected_size
def test_truncate_json_primitives_file_type_small_budget(self, truncator, file):
"""Test that File objects are returned as-is even with small budget."""
# Even with a small size budget, File objects should not be truncated
result = truncator._truncate_json_primitives(file, 10)
assert result.value == file
assert result.truncated is False
def test_truncate_json_primitives_file_type_in_array(self, truncator, file):
"""Test File objects in arrays are handled correctly."""
array_with_files = [file, file]
result = truncator._truncate_json_primitives(array_with_files, 1000)
assert isinstance(result.value, list)
assert len(result.value) == 2
assert result.value[0] == file
assert result.value[1] == file
assert result.truncated is False
def test_truncate_json_primitives_file_type_in_object(self, truncator, file):
"""Test File objects in objects are handled correctly."""
obj_with_files = {"file1": file, "file2": file}
result = truncator._truncate_json_primitives(obj_with_files, 1000)
assert isinstance(result.value, dict)
assert len(result.value) == 2
assert result.value["file1"] == file
assert result.value["file2"] == file
assert result.truncated is False
class TestIntegrationScenarios:
"""Test realistic integration scenarios."""
+3 -3
View File
@@ -1229,7 +1229,7 @@ NGINX_SSL_PORT=443
# and modify the env vars below accordingly.
NGINX_SSL_CERT_FILENAME=dify.crt
NGINX_SSL_CERT_KEY_FILENAME=dify.key
NGINX_SSL_PROTOCOLS=TLSv1.1 TLSv1.2 TLSv1.3
NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3
# Nginx performance tuning
NGINX_WORKER_PROCESSES=auto
@@ -1421,7 +1421,7 @@ QUEUE_MONITOR_ALERT_EMAILS=
QUEUE_MONITOR_INTERVAL=30
# Swagger UI configuration
SWAGGER_UI_ENABLED=true
SWAGGER_UI_ENABLED=false
SWAGGER_UI_PATH=/swagger-ui.html
# Whether to encrypt dataset IDs when exporting DSL files (default: true)
@@ -1460,4 +1460,4 @@ ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
ANNOTATION_IMPORT_MAX_CONCURRENT=5
# The API key of amplitude
AMPLITUDE_API_KEY=
AMPLITUDE_API_KEY=
+1 -1
View File
@@ -414,7 +414,7 @@ services:
# and modify the env vars below in .env if HTTPS_ENABLED is true.
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
+3 -3
View File
@@ -528,7 +528,7 @@ x-shared-env: &shared-api-worker-env
NGINX_SSL_PORT: ${NGINX_SSL_PORT:-443}
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
@@ -631,7 +631,7 @@ x-shared-env: &shared-api-worker-env
QUEUE_MONITOR_THRESHOLD: ${QUEUE_MONITOR_THRESHOLD:-200}
QUEUE_MONITOR_ALERT_EMAILS: ${QUEUE_MONITOR_ALERT_EMAILS:-}
QUEUE_MONITOR_INTERVAL: ${QUEUE_MONITOR_INTERVAL:-30}
SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-true}
SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-false}
SWAGGER_UI_PATH: ${SWAGGER_UI_PATH:-/swagger-ui.html}
DSL_EXPORT_ENCRYPT_DATASET_ID: ${DSL_EXPORT_ENCRYPT_DATASET_ID:-true}
DATASET_MAX_SEGMENTS_PER_REQUEST: ${DATASET_MAX_SEGMENTS_PER_REQUEST:-0}
@@ -1071,7 +1071,7 @@ services:
# and modify the env vars below in .env if HTTPS_ENABLED is true.
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
+7 -1
View File
@@ -19,7 +19,13 @@
*/
export const useTranslation = () => ({
t: (key: string) => key,
t: (key: string, options?: Record<string, unknown>) => {
if (options?.returnObjects)
return [`${key}-feature-1`, `${key}-feature-2`]
if (options)
return `${key}:${JSON.stringify(options)}`
return key
},
i18n: {
language: 'en',
changeLanguage: jest.fn(),
@@ -0,0 +1,53 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import EditItem, { EditItemType } from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('AddAnnotationModal/EditItem', () => {
test('should render query inputs with user avatar and placeholder strings', () => {
render(
<EditItem
type={EditItemType.Query}
content="Why?"
onChange={jest.fn()}
/>,
)
expect(screen.getByText('appAnnotation.addModal.queryName')).toBeInTheDocument()
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toBeInTheDocument()
expect(screen.getByText('Why?')).toBeInTheDocument()
})
test('should render answer name and placeholder text', () => {
render(
<EditItem
type={EditItemType.Answer}
content="Existing answer"
onChange={jest.fn()}
/>,
)
expect(screen.getByText('appAnnotation.addModal.answerName')).toBeInTheDocument()
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toBeInTheDocument()
expect(screen.getByDisplayValue('Existing answer')).toBeInTheDocument()
})
test('should propagate changes when answer content updates', () => {
const handleChange = jest.fn()
render(
<EditItem
type={EditItemType.Answer}
content=""
onChange={handleChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), { target: { value: 'Because' } })
expect(handleChange).toHaveBeenCalledWith('Because')
})
})
@@ -0,0 +1,155 @@
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import AddAnnotationModal from './index'
import { useProviderContext } from '@/context/provider-context'
jest.mock('@/context/provider-context', () => ({
useProviderContext: jest.fn(),
}))
const mockToastNotify = jest.fn()
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(args => mockToastNotify(args)),
},
}))
jest.mock('@/app/components/billing/annotation-full', () => () => <div data-testid="annotation-full" />)
const mockUseProviderContext = useProviderContext as jest.Mock
const getProviderContext = ({ usage = 0, total = 10, enableBilling = false } = {}) => ({
plan: {
usage: { annotatedResponse: usage },
total: { annotatedResponse: total },
},
enableBilling,
})
describe('AddAnnotationModal', () => {
const baseProps = {
isShow: true,
onHide: jest.fn(),
onAdd: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
mockUseProviderContext.mockReturnValue(getProviderContext())
})
const typeQuestion = (value: string) => {
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder'), {
target: { value },
})
}
const typeAnswer = (value: string) => {
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), {
target: { value },
})
}
test('should render modal title when drawer is visible', () => {
render(<AddAnnotationModal {...baseProps} />)
expect(screen.getByText('appAnnotation.addModal.title')).toBeInTheDocument()
})
test('should capture query input text when typing', () => {
render(<AddAnnotationModal {...baseProps} />)
typeQuestion('Sample question')
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('Sample question')
})
test('should capture answer input text when typing', () => {
render(<AddAnnotationModal {...baseProps} />)
typeAnswer('Sample answer')
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('Sample answer')
})
test('should show annotation full notice and disable submit when quota exceeded', () => {
mockUseProviderContext.mockReturnValue(getProviderContext({ usage: 10, total: 10, enableBilling: true }))
render(<AddAnnotationModal {...baseProps} />)
expect(screen.getByTestId('annotation-full')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled()
})
test('should call onAdd with form values when create next enabled', async () => {
const onAdd = jest.fn().mockResolvedValue(undefined)
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
typeQuestion('Question value')
typeAnswer('Answer value')
fireEvent.click(screen.getByTestId('checkbox-create-next-checkbox'))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
})
expect(onAdd).toHaveBeenCalledWith({ question: 'Question value', answer: 'Answer value' })
})
test('should reset fields after saving when create next enabled', async () => {
const onAdd = jest.fn().mockResolvedValue(undefined)
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
typeQuestion('Question value')
typeAnswer('Answer value')
const createNextToggle = screen.getByText('appAnnotation.addModal.createNext').previousElementSibling as HTMLElement
fireEvent.click(createNextToggle)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
})
await waitFor(() => {
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('')
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('')
})
})
test('should show toast when validation fails for missing question', () => {
render(<AddAnnotationModal {...baseProps} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'appAnnotation.errorMessage.queryRequired',
}))
})
test('should show toast when validation fails for missing answer', () => {
render(<AddAnnotationModal {...baseProps} />)
typeQuestion('Filled question')
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'appAnnotation.errorMessage.answerRequired',
}))
})
test('should close modal when save completes and create next unchecked', async () => {
const onAdd = jest.fn().mockResolvedValue(undefined)
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
typeQuestion('Q')
typeAnswer('A')
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
})
expect(baseProps.onHide).toHaveBeenCalled()
})
test('should allow cancel button to close the drawer', () => {
render(<AddAnnotationModal {...baseProps} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(baseProps.onHide).toHaveBeenCalled()
})
})
@@ -101,7 +101,7 @@ const AddAnnotationModal: FC<Props> = ({
<div
className='flex items-center space-x-2'
>
<Checkbox checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} />
<Checkbox id='create-next-checkbox' checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} />
<div>{t('appAnnotation.addModal.createNext')}</div>
</div>
<div className='mt-2 flex space-x-2'>
@@ -0,0 +1,397 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EditItem, { EditItemType, EditTitle } from './index'
describe('EditTitle', () => {
it('should render title content correctly', () => {
// Arrange
const props = { title: 'Test Title' }
// Act
render(<EditTitle {...props} />)
// Assert
expect(screen.getByText(/test title/i)).toBeInTheDocument()
// Should contain edit icon (svg element)
expect(document.querySelector('svg')).toBeInTheDocument()
})
it('should apply custom className when provided', () => {
// Arrange
const props = {
title: 'Test Title',
className: 'custom-class',
}
// Act
const { container } = render(<EditTitle {...props} />)
// Assert
expect(screen.getByText(/test title/i)).toBeInTheDocument()
expect(container.querySelector('.custom-class')).toBeInTheDocument()
})
})
describe('EditItem', () => {
const defaultProps = {
type: EditItemType.Query,
content: 'Test content',
onSave: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render content correctly', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(/test content/i)).toBeInTheDocument()
// Should show item name (query or answer)
expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument()
})
it('should render different item types correctly', () => {
// Arrange
const props = {
...defaultProps,
type: EditItemType.Answer,
content: 'Answer content',
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(/answer content/i)).toBeInTheDocument()
expect(screen.getByText('appAnnotation.editModal.answerName')).toBeInTheDocument()
})
it('should show edit controls when not readonly', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
})
it('should hide edit controls when readonly', () => {
// Arrange
const props = {
...defaultProps,
readonly: true,
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should respect readonly prop for edit functionality', () => {
// Arrange
const props = {
...defaultProps,
readonly: true,
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(/test content/i)).toBeInTheDocument()
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
})
it('should display provided content', () => {
// Arrange
const props = {
...defaultProps,
content: 'Custom content for testing',
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(/custom content for testing/i)).toBeInTheDocument()
})
it('should render appropriate content based on type', () => {
// Arrange
const props = {
...defaultProps,
type: EditItemType.Query,
content: 'Question content',
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(/question content/i)).toBeInTheDocument()
expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should activate edit mode when edit button is clicked', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
// Assert
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
it('should save new content when save button is clicked', async () => {
// Arrange
const mockSave = jest.fn().mockResolvedValue(undefined)
const props = {
...defaultProps,
onSave: mockSave,
}
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
// Type new content
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Updated content')
// Save
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockSave).toHaveBeenCalledWith('Updated content')
})
it('should exit edit mode when cancel button is clicked', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
// Assert
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByText(/test content/i)).toBeInTheDocument()
})
it('should show content preview while typing', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
await user.type(textarea, 'New content')
// Assert
expect(screen.getByText(/new content/i)).toBeInTheDocument()
})
it('should call onSave with correct content when saving', async () => {
// Arrange
const mockSave = jest.fn().mockResolvedValue(undefined)
const props = {
...defaultProps,
onSave: mockSave,
}
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Test save content')
// Save
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockSave).toHaveBeenCalledWith('Test save content')
})
it('should show delete option when content changes', async () => {
// Arrange
const mockSave = jest.fn().mockResolvedValue(undefined)
const props = {
...defaultProps,
onSave: mockSave,
}
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
// Enter edit mode and change content
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Modified content')
// Save to trigger content change
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockSave).toHaveBeenCalledWith('Modified content')
})
it('should handle keyboard interactions in edit mode', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
// Test typing
await user.type(textarea, 'Keyboard test')
// Assert
expect(textarea).toHaveValue('Keyboard test')
expect(screen.getByText(/keyboard test/i)).toBeInTheDocument()
})
})
// State Management
describe('State Management', () => {
it('should reset newContent when content prop changes', async () => {
// Arrange
const { rerender } = render(<EditItem {...defaultProps} />)
// Act - Enter edit mode and type something
const user = userEvent.setup()
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'New content')
// Rerender with new content prop
rerender(<EditItem {...defaultProps} content="Updated content" />)
// Assert - Textarea value should be reset due to useEffect
expect(textarea).toHaveValue('')
})
it('should preserve edit state across content changes', async () => {
// Arrange
const { rerender } = render(<EditItem {...defaultProps} />)
const user = userEvent.setup()
// Act - Enter edit mode
await user.click(screen.getByText('common.operation.edit'))
// Rerender with new content
rerender(<EditItem {...defaultProps} content="Updated content" />)
// Assert - Should still be in edit mode
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle empty content', () => {
// Arrange
const props = {
...defaultProps,
content: '',
}
// Act
const { container } = render(<EditItem {...props} />)
// Assert - Should render without crashing
// Check that the component renders properly with empty content
expect(container.querySelector('.grow')).toBeInTheDocument()
// Should still show edit button
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
})
it('should handle very long content', () => {
// Arrange
const longContent = 'A'.repeat(1000)
const props = {
...defaultProps,
content: longContent,
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(longContent)).toBeInTheDocument()
})
it('should handle content with special characters', () => {
// Arrange
const specialContent = 'Content with & < > " \' characters'
const props = {
...defaultProps,
content: specialContent,
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(specialContent)).toBeInTheDocument()
})
it('should handle rapid edit/cancel operations', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
// Rapid edit/cancel operations
await user.click(screen.getByText('common.operation.edit'))
await user.click(screen.getByText('common.operation.cancel'))
await user.click(screen.getByText('common.operation.edit'))
await user.click(screen.getByText('common.operation.cancel'))
// Assert
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByText('Test content')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,408 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast, { type IToastProps, type ToastHandle } from '@/app/components/base/toast'
import EditAnnotationModal from './index'
// Mock only external dependencies
jest.mock('@/service/annotation', () => ({
addAnnotation: jest.fn(),
editAnnotation: jest.fn(),
}))
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: {
usage: { annotatedResponse: 5 },
total: { annotatedResponse: 10 },
},
enableBilling: true,
}),
}))
jest.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: () => '2023-12-01 10:30:00',
}),
}))
// Note: i18n is automatically mocked by Jest via __mocks__/react-i18next.ts
jest.mock('@/app/components/billing/annotation-full', () => ({
__esModule: true,
default: () => <div data-testid="annotation-full" />,
}))
type ToastNotifyProps = Pick<IToastProps, 'type' | 'size' | 'message' | 'duration' | 'className' | 'customComponent' | 'onClose'>
type ToastWithNotify = typeof Toast & { notify: (props: ToastNotifyProps) => ToastHandle }
const toastWithNotify = Toast as unknown as ToastWithNotify
const toastNotifySpy = jest.spyOn(toastWithNotify, 'notify').mockReturnValue({ clear: jest.fn() })
const { addAnnotation: mockAddAnnotation, editAnnotation: mockEditAnnotation } = jest.requireMock('@/service/annotation') as {
addAnnotation: jest.Mock
editAnnotation: jest.Mock
}
describe('EditAnnotationModal', () => {
const defaultProps = {
isShow: true,
onHide: jest.fn(),
appId: 'test-app-id',
query: 'Test query',
answer: 'Test answer',
onEdited: jest.fn(),
onAdded: jest.fn(),
onRemove: jest.fn(),
}
afterAll(() => {
toastNotifySpy.mockRestore()
})
beforeEach(() => {
jest.clearAllMocks()
mockAddAnnotation.mockResolvedValue({
id: 'test-id',
account: { name: 'Test User' },
})
mockEditAnnotation.mockResolvedValue({})
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render modal when isShow is true', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Check for modal title as it appears in the mock
expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
})
it('should not render modal when isShow is false', () => {
// Arrange
const props = { ...defaultProps, isShow: false }
// Act
render(<EditAnnotationModal {...props} />)
// Assert
expect(screen.queryByText('appAnnotation.editModal.title')).not.toBeInTheDocument()
})
it('should display query and answer sections', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Look for query and answer content
expect(screen.getByText('Test query')).toBeInTheDocument()
expect(screen.getByText('Test answer')).toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should handle different query and answer content', () => {
// Arrange
const props = {
...defaultProps,
query: 'Custom query content',
answer: 'Custom answer content',
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Check content is displayed
expect(screen.getByText('Custom query content')).toBeInTheDocument()
expect(screen.getByText('Custom answer content')).toBeInTheDocument()
})
it('should show remove option when annotationId is provided', () => {
// Arrange
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Remove option should be present (using pattern)
expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should enable editing for query and answer sections', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Edit links should be visible (using text content)
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
expect(editLinks).toHaveLength(2)
})
it('should show remove option when annotationId is provided', () => {
// Arrange
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert
expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
})
it('should save content when edited', async () => {
// Arrange
const mockOnAdded = jest.fn()
const props = {
...defaultProps,
onAdded: mockOnAdded,
}
const user = userEvent.setup()
// Mock API response
mockAddAnnotation.mockResolvedValueOnce({
id: 'test-annotation-id',
account: { name: 'Test User' },
})
// Act
render(<EditAnnotationModal {...props} />)
// Find and click edit link for query
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
// Find textarea and enter new content
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'New query content')
// Click save button
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Assert
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', {
question: 'New query content',
answer: 'Test answer',
message_id: undefined,
})
})
})
// API Calls
describe('API Calls', () => {
it('should call addAnnotation when saving new annotation', async () => {
// Arrange
const mockOnAdded = jest.fn()
const props = {
...defaultProps,
onAdded: mockOnAdded,
}
const user = userEvent.setup()
// Mock the API response
mockAddAnnotation.mockResolvedValueOnce({
id: 'test-annotation-id',
account: { name: 'Test User' },
})
// Act
render(<EditAnnotationModal {...props} />)
// Edit query content
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Updated query')
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Assert
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', {
question: 'Updated query',
answer: 'Test answer',
message_id: undefined,
})
})
it('should call editAnnotation when updating existing annotation', async () => {
// Arrange
const mockOnEdited = jest.fn()
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
messageId: 'test-message-id',
onEdited: mockOnEdited,
}
const user = userEvent.setup()
// Act
render(<EditAnnotationModal {...props} />)
// Edit query content
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Modified query')
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Assert
expect(mockEditAnnotation).toHaveBeenCalledWith(
'test-app-id',
'test-annotation-id',
{
message_id: 'test-message-id',
question: 'Modified query',
answer: 'Test answer',
},
)
})
})
// State Management
describe('State Management', () => {
it('should initialize with closed confirm modal', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Confirm dialog should not be visible initially
expect(screen.queryByText('appDebug.feature.annotation.removeConfirm')).not.toBeInTheDocument()
})
it('should show confirm modal when remove is clicked', async () => {
// Arrange
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
}
const user = userEvent.setup()
// Act
render(<EditAnnotationModal {...props} />)
await user.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
// Assert - Confirmation dialog should appear
expect(screen.getByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
})
it('should call onRemove when removal is confirmed', async () => {
// Arrange
const mockOnRemove = jest.fn()
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
onRemove: mockOnRemove,
}
const user = userEvent.setup()
// Act
render(<EditAnnotationModal {...props} />)
// Click remove
await user.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
// Click confirm
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
await user.click(confirmButton)
// Assert
expect(mockOnRemove).toHaveBeenCalled()
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle empty query and answer', () => {
// Arrange
const props = {
...defaultProps,
query: '',
answer: '',
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert
expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
})
it('should handle very long content', () => {
// Arrange
const longQuery = 'Q'.repeat(1000)
const longAnswer = 'A'.repeat(1000)
const props = {
...defaultProps,
query: longQuery,
answer: longAnswer,
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert
expect(screen.getByText(longQuery)).toBeInTheDocument()
expect(screen.getByText(longAnswer)).toBeInTheDocument()
})
it('should handle special characters in content', () => {
// Arrange
const specialQuery = 'Query with & < > " \' characters'
const specialAnswer = 'Answer with & < > " \' characters'
const props = {
...defaultProps,
query: specialQuery,
answer: specialAnswer,
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert
expect(screen.getByText(specialQuery)).toBeInTheDocument()
expect(screen.getByText(specialAnswer)).toBeInTheDocument()
})
it('should handle onlyEditResponse prop', () => {
// Arrange
const props = {
...defaultProps,
onlyEditResponse: true,
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Query should be readonly, answer should be editable
const editLinks = screen.queryAllByText(/common\.operation\.edit/i)
expect(editLinks).toHaveLength(1) // Only answer should have edit button
})
})
})
@@ -0,0 +1,22 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import CannotQueryDataset from './cannot-query-dataset'
describe('CannotQueryDataset WarningMask', () => {
test('should render dataset warning copy and action button', () => {
const onConfirm = jest.fn()
render(<CannotQueryDataset onConfirm={onConfirm} />)
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSet')).toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSetTip')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' })).toBeInTheDocument()
})
test('should invoke onConfirm when OK button clicked', () => {
const onConfirm = jest.fn()
render(<CannotQueryDataset onConfirm={onConfirm} />)
fireEvent.click(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,39 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import FormattingChanged from './formatting-changed'
describe('FormattingChanged WarningMask', () => {
test('should display translation text and both actions', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
render(
<FormattingChanged
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
expect(screen.getByText('appDebug.formattingChangedTitle')).toBeInTheDocument()
expect(screen.getByText('appDebug.formattingChangedText')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.refresh/ })).toBeInTheDocument()
})
test('should call callbacks when buttons are clicked', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
render(
<FormattingChanged
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.refresh/ }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,26 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import HasNotSetAPI from './has-not-set-api'
describe('HasNotSetAPI WarningMask', () => {
test('should show default title when trial not finished', () => {
render(<HasNotSetAPI isTrailFinished={false} onSetting={jest.fn()} />)
expect(screen.getByText('appDebug.notSetAPIKey.title')).toBeInTheDocument()
expect(screen.getByText('appDebug.notSetAPIKey.description')).toBeInTheDocument()
})
test('should show trail finished title when flag is true', () => {
render(<HasNotSetAPI isTrailFinished onSetting={jest.fn()} />)
expect(screen.getByText('appDebug.notSetAPIKey.trailFinished')).toBeInTheDocument()
})
test('should call onSetting when primary button clicked', () => {
const onSetting = jest.fn()
render(<HasNotSetAPI isTrailFinished={false} onSetting={onSetting} />)
fireEvent.click(screen.getByRole('button', { name: 'appDebug.notSetAPIKey.settingBtn' }))
expect(onSetting).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,25 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import WarningMask from './index'
describe('WarningMask', () => {
// Rendering of title, description, and footer content
describe('Rendering', () => {
test('should display provided title, description, and footer node', () => {
const footer = <button type="button">Retry</button>
// Arrange
render(
<WarningMask
title="Access Restricted"
description="Only workspace owners may modify this section."
footer={footer}
/>,
)
// Assert
expect(screen.getByText('Access Restricted')).toBeInTheDocument()
expect(screen.getByText('Only workspace owners may modify this section.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
})
})
@@ -0,0 +1,121 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ConfigString, { type IConfigStringProps } from './index'
const renderConfigString = (props?: Partial<IConfigStringProps>) => {
const onChange = jest.fn()
const defaultProps: IConfigStringProps = {
value: 5,
maxLength: 10,
modelId: 'model-id',
onChange,
}
render(<ConfigString {...defaultProps} {...props} />)
return { onChange }
}
describe('ConfigString', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render numeric input with bounds', () => {
renderConfigString({ value: 3, maxLength: 8 })
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(3)
expect(input).toHaveAttribute('min', '1')
expect(input).toHaveAttribute('max', '8')
})
it('should render empty input when value is undefined', () => {
const { onChange } = renderConfigString({ value: undefined })
expect(screen.getByRole('spinbutton')).toHaveValue(null)
expect(onChange).not.toHaveBeenCalled()
})
})
describe('Effect behavior', () => {
it('should clamp initial value to maxLength when it exceeds limit', async () => {
const onChange = jest.fn()
render(
<ConfigString
value={15}
maxLength={10}
modelId="model-id"
onChange={onChange}
/>,
)
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(10)
})
expect(onChange).toHaveBeenCalledTimes(1)
})
it('should clamp when updated prop value exceeds maxLength', async () => {
const onChange = jest.fn()
const { rerender } = render(
<ConfigString
value={4}
maxLength={6}
modelId="model-id"
onChange={onChange}
/>,
)
rerender(
<ConfigString
value={9}
maxLength={6}
modelId="model-id"
onChange={onChange}
/>,
)
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(6)
})
expect(onChange).toHaveBeenCalledTimes(1)
})
})
describe('User interactions', () => {
it('should clamp entered value above maxLength', () => {
const { onChange } = renderConfigString({ maxLength: 7 })
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
expect(onChange).toHaveBeenCalledWith(7)
})
it('should raise value below minimum to one', () => {
const { onChange } = renderConfigString()
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '0' } })
expect(onChange).toHaveBeenCalledWith(1)
})
it('should forward parsed value when within bounds', () => {
const { onChange } = renderConfigString({ maxLength: 9 })
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } })
expect(onChange).toHaveBeenCalledWith(7)
})
it('should pass through NaN when input is cleared', () => {
const { onChange } = renderConfigString()
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '' } })
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0]).toBeNaN()
})
})
})
@@ -0,0 +1,45 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import SelectTypeItem from './index'
import { InputVarType } from '@/app/components/workflow/types'
describe('SelectTypeItem', () => {
// Rendering pathways based on type and selection state
describe('Rendering', () => {
test('should render ok', () => {
// Arrange
const { container } = render(
<SelectTypeItem
type={InputVarType.textInput}
selected={false}
onClick={jest.fn()}
/>,
)
// Assert
expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})
// User interaction outcomes
describe('Interactions', () => {
test('should trigger onClick when item is pressed', () => {
const handleClick = jest.fn()
// Arrange
render(
<SelectTypeItem
type={InputVarType.paragraph}
selected={false}
onClick={handleClick}
/>,
)
// Act
fireEvent.click(screen.getByText('appDebug.variableConfig.paragraph'))
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
})
@@ -0,0 +1,142 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { RiArrowDownSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator'
import Authorize from '@/app/components/plugins/plugin-auth/authorize'
import Authorized from '@/app/components/plugins/plugin-auth/authorized'
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
import { usePluginAuth } from '@/app/components/plugins/plugin-auth/hooks/use-plugin-auth'
import type { Credential } from '@/app/components/plugins/plugin-auth/types'
import cn from '@/utils/classnames'
import type { CollectionType } from '@/app/components/tools/types'
type GroupAuthControlProps = {
providerId: string
providerName: string
providerType: CollectionType
credentialId?: string
onChange: (credentialId: string) => void
}
const GroupAuthControl: FC<GroupAuthControlProps> = ({
providerId,
providerName,
providerType,
credentialId,
onChange,
}) => {
const { t } = useTranslation()
const {
isAuthorized,
canOAuth,
canApiKey,
credentials,
disabled,
invalidPluginCredentialInfo,
notAllowCustomCredential,
} = usePluginAuth({
provider: providerName,
providerType,
category: AuthCategory.tool,
detail: { id: providerId, name: providerName, type: providerType } as any,
}, true)
const extraAuthorizationItems: Credential[] = [
{
id: '__workspace_default__',
name: t('plugin.auth.workspaceDefault'),
provider: '',
is_default: !credentialId,
isWorkspaceDefault: true,
},
]
const handleAuthorizationItemClick = useCallback((id: string) => {
onChange(id === '__workspace_default__' ? '' : id)
}, [onChange])
const renderTrigger = useCallback((open?: boolean) => {
let label = ''
let removed = false
let unavailable = false
let color = 'green'
if (!credentialId) {
label = t('plugin.auth.workspaceDefault')
}
else {
const credential = credentials.find(c => c.id === credentialId)
label = credential ? credential.name : t('plugin.auth.authRemoved')
removed = !credential
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
if (removed)
color = 'red'
else if (unavailable)
color = 'gray'
}
return (
<Button
className={cn(
'h-9',
open && 'bg-components-button-secondary-bg-hover',
removed && 'text-text-destructive',
)}
variant='secondary'
size='small'
>
<Indicator className='mr-2' color={color as any} />
{label}
{
unavailable && t('plugin.auth.unavailable')
}
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
</Button>
)
}, [credentialId, credentials, t])
if (!isAuthorized) {
return (
<Authorize
pluginPayload={{
provider: providerName,
providerType,
category: AuthCategory.tool,
detail: { id: providerId, name: providerName, type: providerType } as any,
}}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
/>
)
}
return (
<Authorized
pluginPayload={{
provider: providerName,
providerType,
category: AuthCategory.tool,
detail: { id: providerId, name: providerName, type: providerType } as any,
}}
credentials={credentials}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
disableSetDefault
onItemClick={handleAuthorizationItemClick}
extraAuthorizationItems={extraAuthorizationItems}
showItemSelectedIcon
renderTrigger={renderTrigger}
selectedCredentialId={credentialId || '__workspace_default__'}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
/>
)
}
export default React.memo(GroupAuthControl)
@@ -6,6 +6,7 @@ import { useContext } from 'use-context-selector'
import copy from 'copy-to-clipboard'
import { produce } from 'immer'
import {
RiArrowDownSLine,
RiDeleteBinLine,
RiEqualizer2Line,
RiInformation2Line,
@@ -24,7 +25,6 @@ import { type Collection, CollectionType } from '@/app/components/tools/types'
import { MAX_TOOLS_NUM } from '@/config'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import cn from '@/utils/classnames'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
@@ -33,8 +33,9 @@ import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTo
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { useMittContextSelector } from '@/context/mitt-context'
import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null
type AgentToolWithMoreInfo = (AgentTool & { icon: any; collection?: Collection; use_end_user_credentials?: boolean; end_user_credential_type?: string }) | null
const AgentTools: FC = () => {
const { t } = useTranslation()
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
@@ -93,6 +94,13 @@ const AgentTools: FC = () => {
}
const [isDeleting, setIsDeleting] = useState<number>(-1)
const [expandedProviders, setExpandedProviders] = useState<Record<string, boolean>>({})
const toggleProviderExpand = useCallback((providerId: string) => {
setExpandedProviders(prev => ({
...prev,
[providerId]: !prev[providerId],
}))
}, [])
const getToolValue = (tool: ToolDefaultValue) => {
const currToolInCollections = collectionList.find(c => c.id === tool.provider_id)
const currToolWithConfigs = currToolInCollections?.tools.find(t => t.name === tool.tool_name)
@@ -107,7 +115,9 @@ const AgentTools: FC = () => {
tool_parameters: paramsWithDefaultValue,
notAuthor: !tool.is_team_authorization,
enabled: true,
}
use_end_user_credentials: false,
end_user_credential_type: '',
} as any
}
const handleSelectTool = (tool: ToolDefaultValue) => {
const newModelConfig = produce(modelConfig, (draft) => {
@@ -143,6 +153,34 @@ const AgentTools: FC = () => {
formattingChangedDispatcher()
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
const handleEndUserCredentialChange = useCallback((enabled: boolean) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
if (tool)
(tool as AgentTool).use_end_user_credentials = enabled
})
setCurrentTool({
...currentTool,
use_end_user_credentials: enabled,
} as any)
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
const handleEndUserCredentialTypeChange = useCallback((type: string) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
if (tool)
(tool as AgentTool).end_user_credential_type = type
})
setCurrentTool({
...currentTool,
end_user_credential_type: type,
} as any)
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
return (
<>
<Panel
@@ -182,135 +220,307 @@ const AgentTools: FC = () => {
</div>
}
>
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
<div key={index}
className={cn(
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
)}
<div className='space-y-2'>
{Object.values(
tools.reduce((acc, item, idx) => {
const key = item.provider_id
if (!acc[key]) {
acc[key] = {
providerId: item.provider_id,
providerName: getProviderShowName(item) || '',
icon: item.icon,
providerType: item.provider_type,
tools: [] as (AgentTool & { __index: number })[],
}
}
acc[key].tools.push({ ...item, __index: idx })
return acc
}, {} as Record<string, { providerId: string; providerName: string; providerType: CollectionType; icon: any; tools: (AgentTool & { __index: number })[] }>),
).map(group => (
<div
key={group.providerId}
className='rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2 shadow-xs'
>
<div className='flex w-0 grow items-center'>
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
{!item.isDeleted && (
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
</div>
)}
<div
<div
className='flex cursor-pointer items-center gap-2 px-1'
onClick={() => toggleProviderExpand(group.providerId)}
>
<RiArrowDownSLine
className={cn(
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
'h-4 w-4 shrink-0 text-text-tertiary transition-transform',
!expandedProviders[group.providerId] && '-rotate-90',
)}
>
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
<span className='text-text-tertiary'>{item.tool_label}</span>
{!item.isDeleted && (
<Tooltip
popupContent={
<div className='w-[180px]'>
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
</div>
}
>
<div className='h-4 w-4'>
<div className='ml-0.5 hidden group-hover:inline-block'>
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</Tooltip>
)}
</div>
</div>
<div className='ml-1 flex shrink-0 items-center'>
{item.isDeleted && (
<div className='mr-2 flex items-center'>
<Tooltip
popupContent={t('tools.toolRemoved')}
>
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
</div>
</Tooltip>
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
/>
{typeof group.icon === 'string'
? <div className='h-5 w-5 shrink-0 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${group.icon})` }} />
: <AppIcon className='shrink-0 rounded-md' size='xs' icon={group.icon?.content} background={group.icon?.background} />}
<div className='system-sm-semibold truncate text-text-secondary'>{group.providerName}</div>
<div className='ml-auto flex shrink-0 items-center gap-2'>
<div className='system-xs-regular text-text-tertiary'>
{group.tools.filter(tool => tool.enabled).length}/{group.tools.length}&nbsp;{t('appDebug.agent.tools.enabled')}
</div>
)}
{!item.isDeleted && (
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
{!item.notAuthor && (
<Tooltip
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
needsDelay={false}
>
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</Tooltip>
)}
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
{group.tools.every(tool => tool.notAuthor) && (
<Button
variant='secondary'
size='small'
onClick={(e) => {
e.stopPropagation()
const first = group.tools[0]
setCurrentTool(first as any)
setIsShowSettingTool(true)
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
<div className={cn(item.isDeleted && 'opacity-50')}>
{!item.notAuthor && (
<Switch
defaultValue={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted}
size='md'
onChange={(enabled) => {
const newModelConfig = produce(modelConfig, (draft) => {
(draft.agentConfig.tools[index] as any).enabled = enabled
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}} />
)}
{item.notAuthor && (
<Button variant='secondary' size='small' onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}>
{t('tools.notAuthorized')}
<Indicator className='ml-2' color='orange' />
</Button>
)}
</div>
</div>
<div className={cn('space-y-1', expandedProviders[group.providerId] ? 'mt-1' : 'hidden')}>
{group.tools.map(item => (
<div
key={`${item.provider_id}-${item.tool_name}`}
className={cn(
'group relative flex w-full items-center justify-between rounded-lg pl-[21px] pr-2 hover:bg-state-base-hover',
isDeleting === item.__index && 'border border-state-destructive-border hover:bg-state-destructive-hover',
)}
>
<div className='flex w-0 grow items-center'>
<div
className={cn(
'system-xs-regular flex w-0 grow items-center truncate border-l-2 border-divider-subtle pl-4',
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
)}
>
<span className='system-xs-medium pr-1.5 text-text-secondary'>{item.tool_label}</span>
<span className='text-text-tertiary'>{item.tool_name}</span>
{!item.isDeleted && (
<Tooltip
popupContent={
<div className='w-[180px]'>
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
</div>
}
>
<div className='h-4 w-4'>
<div className='ml-0.5 hidden group-hover:inline-block'>
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</Tooltip>
)}
</div>
</div>
<div className='flex shrink-0 items-center space-x-2'>
{item.isDeleted && (
<div className='mr-2 flex items-center'>
<Tooltip
popupContent={t('tools.toolRemoved')}
>
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
</div>
</Tooltip>
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(item.__index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(item.__index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
{!item.isDeleted && (
<div className='pointer-events-none mr-2 flex items-center gap-1 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100'>
{!item.notAuthor && (
<Tooltip
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
>
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
setCurrentTool(item as any)
setIsShowSettingTool(true)
}}>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</Tooltip>
)}
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(item.__index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(item.__index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
<div className={cn(item.isDeleted && 'opacity-50')}>
{!item.notAuthor && (
<Switch
defaultValue={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted}
size='md'
onChange={(enabled) => {
const newModelConfig = produce(modelConfig, (draft) => {
(draft.agentConfig.tools[item.__index] as any).enabled = enabled
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}} />
)}
</div>
</div>
</div>
))}
</div>
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
<div key={index}
className={cn(
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
)}
>
<div className='flex w-0 grow items-center'>
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
{!item.isDeleted && (
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
</div>
)}
<div
className={cn(
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
)}
>
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
<span className='text-text-tertiary'>{item.tool_label}</span>
{!item.isDeleted && (
<Tooltip
popupContent={
<div className='w-[180px]'>
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
</div>
}
>
<div className='h-4 w-4'>
<div className='ml-0.5 hidden group-hover:inline-block'>
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</Tooltip>
)}
</div>
</div>
<div className='ml-1 flex shrink-0 items-center'>
{item.isDeleted && (
<div className='mr-2 flex items-center'>
<Tooltip
popupContent={t('tools.toolRemoved')}
>
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
</div>
</Tooltip>
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
{!item.isDeleted && (
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
{!item.notAuthor && (
<Tooltip
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
needsDelay={false}
>
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</Tooltip>
)}
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
<div className={cn(item.isDeleted && 'opacity-50')}>
{!item.notAuthor && (
<Switch
defaultValue={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted}
size='md'
onChange={(enabled) => {
const newModelConfig = produce(modelConfig, (draft) => {
(draft.agentConfig.tools[index] as any).enabled = enabled
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}} />
)}
{item.notAuthor && (
<Button variant='secondary' size='small' onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}>
{t('tools.notAuthorized')}
<Indicator className='ml-2' color='orange' />
</Button>
)}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div >
</Panel >
</div>
</Panel>
{isShowSettingTool && (
<SettingBuiltInTool
toolName={currentTool?.tool_name as string}
@@ -321,6 +531,10 @@ const AgentTools: FC = () => {
onHide={() => setIsShowSettingTool(false)}
credentialId={currentTool?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
useEndUserCredentialEnabled={currentTool?.use_end_user_credentials}
endUserCredentialType={currentTool?.end_user_credential_type}
onEndUserCredentialChange={handleEndUserCredentialChange}
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
/>
)}
</>
@@ -42,6 +42,10 @@ type Props = {
onSave?: (value: Record<string, any>) => void
credentialId?: string
onAuthorizationItemClick?: (id: string) => void
useEndUserCredentialEnabled?: boolean
endUserCredentialType?: string
onEndUserCredentialChange?: (enabled: boolean) => void
onEndUserCredentialTypeChange?: (type: string) => void
}
const SettingBuiltInTool: FC<Props> = ({
@@ -56,6 +60,10 @@ const SettingBuiltInTool: FC<Props> = ({
onSave,
credentialId,
onAuthorizationItemClick,
useEndUserCredentialEnabled,
endUserCredentialType,
onEndUserCredentialChange,
onEndUserCredentialTypeChange,
}) => {
const { locale } = useContext(I18n)
const language = getLanguage(locale)
@@ -216,6 +224,10 @@ const SettingBuiltInTool: FC<Props> = ({
}}
credentialId={credentialId}
onAuthorizationItemClick={onAuthorizationItemClick}
useEndUserCredentialEnabled={useEndUserCredentialEnabled}
endUserCredentialType={endUserCredentialType}
onEndUserCredentialChange={onEndUserCredentialChange}
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
/>
)
}
@@ -0,0 +1,242 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Item from './index'
import type React from 'react'
import type { DataSet } from '@/models/datasets'
import { ChunkingMode, DataSourceType, DatasetPermission } from '@/models/datasets'
import type { IndexingType } from '@/app/components/datasets/create/step-two'
import type { RetrievalConfig } from '@/types/app'
import { RETRIEVE_METHOD } from '@/types/app'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
jest.mock('../settings-modal', () => ({
__esModule: true,
default: ({ onSave, onCancel, currentDataset }: any) => (
<div>
<div>Mock settings modal</div>
<button onClick={() => onSave({ ...currentDataset, name: 'Updated dataset' })}>Save changes</button>
<button onClick={onCancel}>Close</button>
</div>
),
}))
jest.mock('@/hooks/use-breakpoints', () => {
const actual = jest.requireActual('@/hooks/use-breakpoints')
return {
__esModule: true,
...actual,
default: jest.fn(() => actual.MediaType.pc),
}
})
const mockedUseBreakpoints = useBreakpoints as jest.MockedFunction<typeof useBreakpoints>
const baseRetrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: 'provider',
reranking_model_name: 'rerank-model',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: 0,
}
const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => {
const {
retrieval_model,
retrieval_model_dict,
icon_info,
...restOverrides
} = overrides
const resolvedRetrievalModelDict = {
...baseRetrievalConfig,
...retrieval_model_dict,
}
const resolvedRetrievalModel = {
...baseRetrievalConfig,
...(retrieval_model ?? retrieval_model_dict),
}
const defaultIconInfo = {
icon: '📘',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
}
const resolvedIconInfo = ('icon_info' in overrides)
? icon_info
: defaultIconInfo
return {
id: 'dataset-id',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: resolvedIconInfo as DataSet['icon_info'],
description: 'A test dataset',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: defaultIndexingTechnique,
author_name: 'author',
created_by: 'creator',
updated_by: 'updater',
updated_at: 0,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 0,
total_document_count: 0,
total_available_documents: 0,
word_count: 0,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: resolvedRetrievalModelDict,
retrieval_model: resolvedRetrievalModel,
tags: [],
external_knowledge_info: {
external_knowledge_id: 'external-id',
external_knowledge_api_id: 'api-id',
external_knowledge_api_name: 'api-name',
external_knowledge_api_endpoint: 'https://endpoint',
},
external_retrieval_model: {
top_k: 2,
score_threshold: 0.5,
score_threshold_enabled: true,
},
built_in_field_enabled: true,
doc_metadata: [],
keyword_number: 3,
pipeline_id: 'pipeline-id',
is_published: true,
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
...restOverrides,
}
}
const renderItem = (config: DataSet, props?: Partial<React.ComponentProps<typeof Item>>) => {
const onSave = jest.fn()
const onRemove = jest.fn()
render(
<Item
config={config}
onSave={onSave}
onRemove={onRemove}
{...props}
/>,
)
return { onSave, onRemove }
}
describe('dataset-config/card-item', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedUseBreakpoints.mockReturnValue(MediaType.pc)
})
it('should render dataset details with indexing and external badges', () => {
const dataset = createDataset({
provider: 'external',
retrieval_model_dict: {
...baseRetrievalConfig,
search_method: RETRIEVE_METHOD.semantic,
},
})
renderItem(dataset)
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
const actionButtons = within(card).getAllByRole('button', { hidden: true })
expect(screen.getByText(dataset.name)).toBeInTheDocument()
expect(screen.getByText('dataset.indexingTechnique.high_quality · dataset.indexingMethod.semantic_search')).toBeInTheDocument()
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
expect(actionButtons).toHaveLength(2)
})
it('should open settings drawer from edit action and close after saving', async () => {
const user = userEvent.setup()
const dataset = createDataset()
const { onSave } = renderItem(dataset)
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
const [editButton] = within(card).getAllByRole('button', { hidden: true })
await user.click(editButton)
expect(screen.getByText('Mock settings modal')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeVisible()
})
await user.click(screen.getByText('Save changes'))
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' }))
})
await waitFor(() => {
expect(screen.getByText('Mock settings modal')).not.toBeVisible()
})
})
it('should call onRemove and toggle destructive state on hover', async () => {
const user = userEvent.setup()
const dataset = createDataset()
const { onRemove } = renderItem(dataset)
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
const buttons = within(card).getAllByRole('button', { hidden: true })
const deleteButton = buttons[buttons.length - 1]
expect(deleteButton.className).not.toContain('action-btn-destructive')
fireEvent.mouseEnter(deleteButton)
expect(deleteButton.className).toContain('action-btn-destructive')
expect(card.className).toContain('border-state-destructive-border')
fireEvent.mouseLeave(deleteButton)
expect(deleteButton.className).not.toContain('action-btn-destructive')
await user.click(deleteButton)
expect(onRemove).toHaveBeenCalledWith(dataset.id)
})
it('should use default icon information when icon details are missing', () => {
const dataset = createDataset({ icon_info: undefined })
renderItem(dataset)
const nameElement = screen.getByText(dataset.name)
const iconElement = nameElement.parentElement?.firstElementChild as HTMLElement
expect(iconElement).toHaveStyle({ background: '#FFF4ED' })
expect(iconElement.querySelector('em-emoji')).toHaveAttribute('id', '📙')
})
it('should apply mask overlay on mobile when drawer is open', async () => {
mockedUseBreakpoints.mockReturnValue(MediaType.mobile)
const user = userEvent.setup()
const dataset = createDataset()
renderItem(dataset)
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
const [editButton] = within(card).getAllByRole('button', { hidden: true })
await user.click(editButton)
expect(screen.getByText('Mock settings modal')).toBeInTheDocument()
const overlay = Array.from(document.querySelectorAll('[class]'))
.find(element => element.className.toString().includes('bg-black/30'))
expect(overlay).toBeInTheDocument()
})
})
@@ -0,0 +1,299 @@
import * as React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ContextVar from './index'
import type { Props } from './var-picker'
// Mock external dependencies only
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: jest.fn() }),
usePathname: () => '/test',
}))
type PortalToFollowElemProps = {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
type PortalToFollowElemTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode; asChild?: boolean }
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
jest.mock('@/app/components/base/portal-to-follow-elem', () => {
const PortalContext = React.createContext({ open: false })
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
return (
<PortalContext.Provider value={{ open: !!open }}>
<div data-testid="portal">{children}</div>
</PortalContext.Provider>
)
}
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
const { open } = React.useContext(PortalContext)
if (!open) return null
return (
<div data-testid="portal-content" {...props}>
{children}
</div>
)
}
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
...props,
'data-testid': 'portal-trigger',
} as React.HTMLAttributes<HTMLElement>)
}
return (
<div data-testid="portal-trigger" {...props}>
{children}
</div>
)
}
return {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
}
})
describe('ContextVar', () => {
const mockOptions: Props['options'] = [
{ name: 'Variable 1', value: 'var1', type: 'string' },
{ name: 'Variable 2', value: 'var2', type: 'number' },
]
const defaultProps: Props = {
value: 'var1',
options: mockOptions,
onChange: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should display query variable selector when options are provided', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
})
it('should show selected variable with proper formatting when value is provided', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('var1')).toBeInTheDocument()
expect(screen.getByText('{{')).toBeInTheDocument()
expect(screen.getByText('}}')).toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should display selected variable when value prop is provided', () => {
// Arrange
const props = { ...defaultProps, value: 'var2' }
// Act
render(<ContextVar {...props} />)
// Assert - Should display the selected value
expect(screen.getByText('var2')).toBeInTheDocument()
})
it('should show placeholder text when no value is selected', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<ContextVar {...props} />)
// Assert - Should show placeholder instead of variable
expect(screen.queryByText('var1')).not.toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should display custom tip message when notSelectedVarTip is provided', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
notSelectedVarTip: 'Select a variable',
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('Select a variable')).toBeInTheDocument()
})
it('should apply custom className to VarPicker when provided', () => {
// Arrange
const props = {
...defaultProps,
className: 'custom-class',
}
// Act
const { container } = render(<ContextVar {...props} />)
// Assert
expect(container.querySelector('.custom-class')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onChange when user selects a different variable', async () => {
// Arrange
const onChange = jest.fn()
const props = { ...defaultProps, onChange }
const user = userEvent.setup()
// Act
render(<ContextVar {...props} />)
const triggers = screen.getAllByTestId('portal-trigger')
const varPickerTrigger = triggers[triggers.length - 1]
await user.click(varPickerTrigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Select a different option
const options = screen.getAllByText('var2')
expect(options.length).toBeGreaterThan(0)
await user.click(options[0])
// Assert
expect(onChange).toHaveBeenCalledWith('var2')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
it('should toggle dropdown when clicking the trigger button', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<ContextVar {...props} />)
const triggers = screen.getAllByTestId('portal-trigger')
const varPickerTrigger = triggers[triggers.length - 1]
// Open dropdown
await user.click(varPickerTrigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Close dropdown
await user.click(varPickerTrigger)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle undefined value gracefully', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
expect(screen.queryByText('var1')).not.toBeInTheDocument()
})
it('should handle empty options array', () => {
// Arrange
const props = {
...defaultProps,
options: [],
value: undefined,
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should handle null value without crashing', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should handle options with different data types', () => {
// Arrange
const props = {
...defaultProps,
options: [
{ name: 'String Var', value: 'strVar', type: 'string' },
{ name: 'Number Var', value: '42', type: 'number' },
{ name: 'Boolean Var', value: 'true', type: 'boolean' },
],
value: 'strVar',
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('strVar')).toBeInTheDocument()
expect(screen.getByText('{{')).toBeInTheDocument()
expect(screen.getByText('}}')).toBeInTheDocument()
})
it('should render variable names with special characters safely', () => {
// Arrange
const props = {
...defaultProps,
options: [
{ name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' },
],
value: 'specialVar',
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('specialVar')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,392 @@
import * as React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import VarPicker, { type Props } from './var-picker'
// Mock external dependencies only
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: jest.fn() }),
usePathname: () => '/test',
}))
type PortalToFollowElemProps = {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
type PortalToFollowElemTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode; asChild?: boolean }
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
jest.mock('@/app/components/base/portal-to-follow-elem', () => {
const PortalContext = React.createContext({ open: false })
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
return (
<PortalContext.Provider value={{ open: !!open }}>
<div data-testid="portal">{children}</div>
</PortalContext.Provider>
)
}
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
const { open } = React.useContext(PortalContext)
if (!open) return null
return (
<div data-testid="portal-content" {...props}>
{children}
</div>
)
}
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
...props,
'data-testid': 'portal-trigger',
} as React.HTMLAttributes<HTMLElement>)
}
return (
<div data-testid="portal-trigger" {...props}>
{children}
</div>
)
}
return {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
}
})
describe('VarPicker', () => {
const mockOptions: Props['options'] = [
{ name: 'Variable 1', value: 'var1', type: 'string' },
{ name: 'Variable 2', value: 'var2', type: 'number' },
{ name: 'Variable 3', value: 'var3', type: 'boolean' },
]
const defaultProps: Props = {
value: 'var1',
options: mockOptions,
onChange: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render variable picker with dropdown trigger', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
expect(screen.getByText('var1')).toBeInTheDocument()
})
it('should display selected variable with type icon when value is provided', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('var1')).toBeInTheDocument()
expect(screen.getByText('{{')).toBeInTheDocument()
expect(screen.getByText('}}')).toBeInTheDocument()
// IconTypeIcon should be rendered (check for svg icon)
expect(document.querySelector('svg')).toBeInTheDocument()
})
it('should show placeholder text when no value is selected', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.queryByText('var1')).not.toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should display custom tip message when notSelectedVarTip is provided', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
notSelectedVarTip: 'Select a variable',
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('Select a variable')).toBeInTheDocument()
})
it('should render dropdown indicator icon', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<VarPicker {...props} />)
// Assert - Trigger should be present
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should apply custom className to wrapper', () => {
// Arrange
const props = {
...defaultProps,
className: 'custom-class',
}
// Act
const { container } = render(<VarPicker {...props} />)
// Assert
expect(container.querySelector('.custom-class')).toBeInTheDocument()
})
it('should apply custom triggerClassName to trigger button', () => {
// Arrange
const props = {
...defaultProps,
triggerClassName: 'custom-trigger-class',
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByTestId('portal-trigger')).toHaveClass('custom-trigger-class')
})
it('should display selected value with proper formatting', () => {
// Arrange
const props = {
...defaultProps,
value: 'customVar',
options: [
{ name: 'Custom Variable', value: 'customVar', type: 'string' },
],
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('customVar')).toBeInTheDocument()
expect(screen.getByText('{{')).toBeInTheDocument()
expect(screen.getByText('}}')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should open dropdown when clicking the trigger button', async () => {
// Arrange
const onChange = jest.fn()
const props = { ...defaultProps, onChange }
const user = userEvent.setup()
// Act
render(<VarPicker {...props} />)
await user.click(screen.getByTestId('portal-trigger'))
// Assert
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should call onChange and close dropdown when selecting an option', async () => {
// Arrange
const onChange = jest.fn()
const props = { ...defaultProps, onChange }
const user = userEvent.setup()
// Act
render(<VarPicker {...props} />)
// Open dropdown
await user.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Select a different option
const options = screen.getAllByText('var2')
expect(options.length).toBeGreaterThan(0)
await user.click(options[0])
// Assert
expect(onChange).toHaveBeenCalledWith('var2')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
it('should toggle dropdown when clicking trigger button multiple times', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<VarPicker {...props} />)
const trigger = screen.getByTestId('portal-trigger')
// Open dropdown
await user.click(trigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Close dropdown
await user.click(trigger)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
})
// State Management
describe('State Management', () => {
it('should initialize with closed dropdown', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
it('should toggle dropdown state on trigger click', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<VarPicker {...props} />)
const trigger = screen.getByTestId('portal-trigger')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
// Open dropdown
await user.click(trigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Close dropdown
await user.click(trigger)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
it('should preserve selected value when dropdown is closed without selection', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<VarPicker {...props} />)
// Open and close dropdown without selecting anything
const trigger = screen.getByTestId('portal-trigger')
await user.click(trigger)
await user.click(trigger)
// Assert
expect(screen.getByText('var1')).toBeInTheDocument() // Original value still displayed
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle undefined value gracefully', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
it('should handle empty options array', () => {
// Arrange
const props = {
...defaultProps,
options: [],
value: undefined,
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should handle null value without crashing', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should handle variable names with special characters safely', () => {
// Arrange
const props = {
...defaultProps,
options: [
{ name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' },
],
value: 'specialVar',
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('specialVar')).toBeInTheDocument()
})
it('should handle long variable names', () => {
// Arrange
const props = {
...defaultProps,
options: [
{ name: 'A very long variable name that should be truncated', value: 'longVar', type: 'string' },
],
value: 'longVar',
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('longVar')).toBeInTheDocument()
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,347 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AppCard from './index'
import type { AppIconType } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import type { App } from '@/models/explore'
jest.mock('@heroicons/react/20/solid', () => ({
PlusIcon: ({ className }: any) => <div data-testid="plus-icon" className={className} aria-label="Add icon">+</div>,
}))
const mockApp: App = {
app: {
id: 'test-app-id',
mode: AppModeEnum.CHAT,
icon_type: 'emoji' as AppIconType,
icon: '🤖',
icon_background: '#FFEAD5',
icon_url: '',
name: 'Test Chat App',
description: 'A test chat application for demonstration purposes',
use_icon_as_answer_icon: false,
},
app_id: 'test-app-id',
description: 'A comprehensive chat application template',
copyright: 'Test Corp',
privacy_policy: null,
custom_disclaimer: null,
category: 'Assistant',
position: 1,
is_listed: true,
install_count: 100,
installed: false,
editable: true,
is_agent: false,
}
describe('AppCard', () => {
const defaultProps = {
app: mockApp,
canCreate: true,
onCreate: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<AppCard {...defaultProps} />)
expect(container.querySelector('em-emoji')).toBeInTheDocument()
expect(screen.getByText('Test Chat App')).toBeInTheDocument()
expect(screen.getByText(mockApp.description)).toBeInTheDocument()
})
it('should render app type icon and label', () => {
const { container } = render(<AppCard {...defaultProps} />)
expect(container.querySelector('svg')).toBeInTheDocument()
expect(screen.getByText('app.typeSelector.chatbot')).toBeInTheDocument()
})
})
describe('Props', () => {
describe('canCreate behavior', () => {
it('should show create button when canCreate is true', () => {
render(<AppCard {...defaultProps} canCreate={true} />)
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
expect(button).toBeInTheDocument()
})
it('should hide create button when canCreate is false', () => {
render(<AppCard {...defaultProps} canCreate={false} />)
const button = screen.queryByRole('button', { name: /app\.newApp\.useTemplate/ })
expect(button).not.toBeInTheDocument()
})
})
it('should display app name from appBasicInfo', () => {
const customApp = {
...mockApp,
app: {
...mockApp.app,
name: 'Custom App Name',
},
}
render(<AppCard {...defaultProps} app={customApp} />)
expect(screen.getByText('Custom App Name')).toBeInTheDocument()
})
it('should display app description from app level', () => {
const customApp = {
...mockApp,
description: 'Custom description for the app',
}
render(<AppCard {...defaultProps} app={customApp} />)
expect(screen.getByText('Custom description for the app')).toBeInTheDocument()
})
it('should truncate long app names', () => {
const longNameApp = {
...mockApp,
app: {
...mockApp.app,
name: 'This is a very long app name that should be truncated with line-clamp-1',
},
}
render(<AppCard {...defaultProps} app={longNameApp} />)
const nameElement = screen.getByTitle('This is a very long app name that should be truncated with line-clamp-1')
expect(nameElement).toBeInTheDocument()
})
})
describe('App Modes - Data Driven Tests', () => {
const testCases = [
{
mode: AppModeEnum.CHAT,
expectedLabel: 'app.typeSelector.chatbot',
description: 'Chat application mode',
},
{
mode: AppModeEnum.AGENT_CHAT,
expectedLabel: 'app.typeSelector.agent',
description: 'Agent chat mode',
},
{
mode: AppModeEnum.COMPLETION,
expectedLabel: 'app.typeSelector.completion',
description: 'Completion mode',
},
{
mode: AppModeEnum.ADVANCED_CHAT,
expectedLabel: 'app.typeSelector.advanced',
description: 'Advanced chat mode',
},
{
mode: AppModeEnum.WORKFLOW,
expectedLabel: 'app.typeSelector.workflow',
description: 'Workflow mode',
},
]
testCases.forEach(({ mode, expectedLabel, description }) => {
it(`should display correct type label for ${description}`, () => {
const appWithMode = {
...mockApp,
app: {
...mockApp.app,
mode,
},
}
render(<AppCard {...defaultProps} app={appWithMode} />)
expect(screen.getByText(expectedLabel)).toBeInTheDocument()
})
})
})
describe('Icon Type Tests', () => {
it('should render emoji icon without image element', () => {
const appWithIcon = {
...mockApp,
app: {
...mockApp.app,
icon_type: 'emoji' as AppIconType,
icon: '🤖',
},
}
const { container } = render(<AppCard {...defaultProps} app={appWithIcon} />)
const card = container.firstElementChild as HTMLElement
expect(within(card).queryByRole('img', { name: 'app icon' })).not.toBeInTheDocument()
expect(card.querySelector('em-emoji')).toBeInTheDocument()
})
it('should prioritize icon_url when both icon and icon_url are provided', () => {
const appWithImageUrl = {
...mockApp,
app: {
...mockApp.app,
icon_type: 'image' as AppIconType,
icon: 'local-icon.png',
icon_url: 'https://example.com/remote-icon.png',
},
}
render(<AppCard {...defaultProps} app={appWithImageUrl} />)
expect(screen.getByRole('img', { name: 'app icon' })).toHaveAttribute('src', 'https://example.com/remote-icon.png')
})
})
describe('User Interactions', () => {
it('should call onCreate when create button is clicked', async () => {
const mockOnCreate = jest.fn()
render(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
await userEvent.click(button)
expect(mockOnCreate).toHaveBeenCalledTimes(1)
})
it('should handle click on card itself', async () => {
const mockOnCreate = jest.fn()
const { container } = render(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
const card = container.firstElementChild as HTMLElement
await userEvent.click(card)
// Note: Card click doesn't trigger onCreate, only the button does
expect(mockOnCreate).not.toHaveBeenCalled()
})
})
describe('Keyboard Accessibility', () => {
it('should allow the create button to be focused', async () => {
const mockOnCreate = jest.fn()
render(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
await userEvent.tab()
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ }) as HTMLButtonElement
// Test that button can be focused
expect(button).toHaveFocus()
// Test click event works (keyboard events on buttons typically trigger click)
await userEvent.click(button)
expect(mockOnCreate).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle app with null icon_type', () => {
const appWithNullIcon = {
...mockApp,
app: {
...mockApp.app,
icon_type: null,
},
}
const { container } = render(<AppCard {...defaultProps} app={appWithNullIcon} />)
const appIcon = container.querySelector('em-emoji')
expect(appIcon).toBeInTheDocument()
// AppIcon component should handle null icon_type gracefully
})
it('should handle app with empty description', () => {
const appWithEmptyDesc = {
...mockApp,
description: '',
}
const { container } = render(<AppCard {...defaultProps} app={appWithEmptyDesc} />)
const descriptionContainer = container.querySelector('.line-clamp-3')
expect(descriptionContainer).toBeInTheDocument()
expect(descriptionContainer).toHaveTextContent('')
})
it('should handle app with very long description', () => {
const longDescription = 'This is a very long description that should be truncated with line-clamp-3. '.repeat(5)
const appWithLongDesc = {
...mockApp,
description: longDescription,
}
render(<AppCard {...defaultProps} app={appWithLongDesc} />)
expect(screen.getByText(/This is a very long description/)).toBeInTheDocument()
})
it('should handle app with special characters in name', () => {
const appWithSpecialChars = {
...mockApp,
app: {
...mockApp.app,
name: 'App <script>alert("test")</script> & Special "Chars"',
},
}
render(<AppCard {...defaultProps} app={appWithSpecialChars} />)
expect(screen.getByText('App <script>alert("test")</script> & Special "Chars"')).toBeInTheDocument()
})
it('should handle onCreate function throwing error', async () => {
const errorOnCreate = jest.fn(() => {
throw new Error('Create failed')
})
// Mock console.error to avoid test output noise
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
render(<AppCard {...defaultProps} onCreate={errorOnCreate} />)
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
let capturedError: unknown
try {
await userEvent.click(button)
}
catch (err) {
capturedError = err
}
expect(errorOnCreate).toHaveBeenCalledTimes(1)
expect(consoleSpy).toHaveBeenCalled()
if (capturedError instanceof Error)
expect(capturedError.message).toContain('Create failed')
consoleSpy.mockRestore()
})
})
describe('Accessibility', () => {
it('should have proper elements for accessibility', () => {
const { container } = render(<AppCard {...defaultProps} />)
expect(container.querySelector('em-emoji')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should have title attribute for app name when truncated', () => {
render(<AppCard {...defaultProps} />)
const nameElement = screen.getByText('Test Chat App')
expect(nameElement).toHaveAttribute('title', 'Test Chat App')
})
it('should have accessible button with proper label', () => {
render(<AppCard {...defaultProps} />)
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
expect(button).toBeEnabled()
expect(button).toHaveTextContent('app.newApp.useTemplate')
})
})
describe('User-Visible Behavior Tests', () => {
it('should show plus icon in create button', () => {
render(<AppCard {...defaultProps} />)
expect(screen.getByTestId('plus-icon')).toBeInTheDocument()
})
})
})
@@ -15,6 +15,7 @@ export type AppCardProps = {
const AppCard = ({
app,
canCreate,
onCreate,
}: AppCardProps) => {
const { t } = useTranslation()
@@ -45,14 +46,16 @@ const AppCard = ({
{app.description}
</div>
</div>
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
<div className={cn('flex h-8 w-full items-center space-x-2')}>
<Button variant='primary' className='grow' onClick={() => onCreate()}>
<PlusIcon className='mr-1 h-4 w-4' />
<span className='text-xs'>{t('app.newApp.useTemplate')}</span>
</Button>
{canCreate && (
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
<div className={cn('flex h-8 w-full items-center space-x-2')}>
<Button variant='primary' className='grow' onClick={() => onCreate()}>
<PlusIcon className='mr-1 h-4 w-4' />
<span className='text-xs'>{t('app.newApp.useTemplate')}</span>
</Button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,209 @@
import type { RenderOptions } from '@testing-library/react'
import { fireEvent, render } from '@testing-library/react'
import { defaultPlan } from '@/app/components/billing/config'
import { noop } from 'lodash-es'
import type { ModalContextState } from '@/context/modal-context'
import APIKeyInfoPanel from './index'
// Mock the modules before importing the functions
jest.mock('@/context/provider-context', () => ({
useProviderContext: jest.fn(),
}))
jest.mock('@/context/modal-context', () => ({
useModalContext: jest.fn(),
}))
import { useProviderContext as actualUseProviderContext } from '@/context/provider-context'
import { useModalContext as actualUseModalContext } from '@/context/modal-context'
// Type casting for mocks
const mockUseProviderContext = actualUseProviderContext as jest.MockedFunction<typeof actualUseProviderContext>
const mockUseModalContext = actualUseModalContext as jest.MockedFunction<typeof actualUseModalContext>
// Default mock data
const defaultProviderContext = {
modelProviders: [],
refreshModelProviders: noop,
textGenerationModelList: [],
supportRetrievalMethods: [],
isAPIKeySet: false,
plan: defaultPlan,
isFetchedPlan: false,
enableBilling: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
datasetOperatorEnabled: false,
enableEducationPlan: false,
isEducationWorkspace: false,
isEducationAccount: false,
allowRefreshEducationVerify: false,
educationAccountExpireAt: null,
isLoadingEducationAccountInfo: false,
isFetchingEducationAccountInfo: false,
webappCopyrightEnabled: false,
licenseLimit: {
workspace_members: {
size: 0,
limit: 0,
},
},
refreshLicenseLimit: noop,
isAllowTransferWorkspace: false,
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
}
const defaultModalContext: ModalContextState = {
setShowAccountSettingModal: noop,
setShowApiBasedExtensionModal: noop,
setShowModerationSettingModal: noop,
setShowExternalDataToolModal: noop,
setShowPricingModal: noop,
setShowAnnotationFullModal: noop,
setShowModelModal: noop,
setShowExternalKnowledgeAPIModal: noop,
setShowModelLoadBalancingModal: noop,
setShowOpeningModal: noop,
setShowUpdatePluginModal: noop,
setShowEducationExpireNoticeModal: noop,
setShowTriggerEventsLimitModal: noop,
}
export type MockOverrides = {
providerContext?: Partial<typeof defaultProviderContext>
modalContext?: Partial<typeof defaultModalContext>
}
export type APIKeyInfoPanelRenderOptions = {
mockOverrides?: MockOverrides
} & Omit<RenderOptions, 'wrapper'>
// Setup function to configure mocks
export function setupMocks(overrides: MockOverrides = {}) {
mockUseProviderContext.mockReturnValue({
...defaultProviderContext,
...overrides.providerContext,
})
mockUseModalContext.mockReturnValue({
...defaultModalContext,
...overrides.modalContext,
})
}
// Custom render function
export function renderAPIKeyInfoPanel(options: APIKeyInfoPanelRenderOptions = {}) {
const { mockOverrides, ...renderOptions } = options
setupMocks(mockOverrides)
return render(<APIKeyInfoPanel />, renderOptions)
}
// Helper functions for common test scenarios
export const scenarios = {
// Render with API key not set (default)
withAPIKeyNotSet: (overrides: MockOverrides = {}) =>
renderAPIKeyInfoPanel({
mockOverrides: {
providerContext: { isAPIKeySet: false },
...overrides,
},
}),
// Render with API key already set
withAPIKeySet: (overrides: MockOverrides = {}) =>
renderAPIKeyInfoPanel({
mockOverrides: {
providerContext: { isAPIKeySet: true },
...overrides,
},
}),
// Render with mock modal function
withMockModal: (mockSetShowAccountSettingModal: jest.Mock, overrides: MockOverrides = {}) =>
renderAPIKeyInfoPanel({
mockOverrides: {
modalContext: { setShowAccountSettingModal: mockSetShowAccountSettingModal },
...overrides,
},
}),
}
// Common test assertions
export const assertions = {
// Should render main button
shouldRenderMainButton: () => {
const button = document.querySelector('button.btn-primary')
expect(button).toBeInTheDocument()
return button
},
// Should not render at all
shouldNotRender: (container: HTMLElement) => {
expect(container.firstChild).toBeNull()
},
// Should have correct panel styling
shouldHavePanelStyling: (panel: HTMLElement) => {
expect(panel).toHaveClass(
'border-components-panel-border',
'bg-components-panel-bg',
'relative',
'mb-6',
'rounded-2xl',
'border',
'p-8',
'shadow-md',
)
},
// Should have close button
shouldHaveCloseButton: (container: HTMLElement) => {
const closeButton = container.querySelector('.absolute.right-4.top-4')
expect(closeButton).toBeInTheDocument()
expect(closeButton).toHaveClass('cursor-pointer')
return closeButton
},
}
// Common user interactions
export const interactions = {
// Click the main button
clickMainButton: () => {
const button = document.querySelector('button.btn-primary')
if (button) fireEvent.click(button)
return button
},
// Click the close button
clickCloseButton: (container: HTMLElement) => {
const closeButton = container.querySelector('.absolute.right-4.top-4')
if (closeButton) fireEvent.click(closeButton)
return closeButton
},
}
// Text content keys for assertions
export const textKeys = {
selfHost: {
titleRow1: /appOverview\.apiKeyInfo\.selfHost\.title\.row1/,
titleRow2: /appOverview\.apiKeyInfo\.selfHost\.title\.row2/,
setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/,
tryCloud: /appOverview\.apiKeyInfo\.tryCloud/,
},
cloud: {
trialTitle: /appOverview\.apiKeyInfo\.cloud\.trial\.title/,
trialDescription: /appOverview\.apiKeyInfo\.cloud\.trial\.description/,
setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/,
},
}
// Setup and cleanup utilities
export function clearAllMocks() {
jest.clearAllMocks()
}
// Export mock functions for external access
export { mockUseProviderContext, mockUseModalContext, defaultModalContext }
@@ -0,0 +1,122 @@
import { cleanup, screen } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
assertions,
clearAllMocks,
defaultModalContext,
interactions,
mockUseModalContext,
scenarios,
textKeys,
} from './apikey-info-panel.test-utils'
// Mock config for Cloud edition
jest.mock('@/config', () => ({
IS_CE_EDITION: false, // Test Cloud edition
}))
afterEach(cleanup)
describe('APIKeyInfoPanel - Cloud Edition', () => {
const mockSetShowAccountSettingModal = jest.fn()
beforeEach(() => {
clearAllMocks()
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
})
})
describe('Rendering', () => {
it('should render without crashing when API key is not set', () => {
scenarios.withAPIKeyNotSet()
assertions.shouldRenderMainButton()
})
it('should not render when API key is already set', () => {
const { container } = scenarios.withAPIKeySet()
assertions.shouldNotRender(container)
})
it('should not render when panel is hidden by user', () => {
const { container } = scenarios.withAPIKeyNotSet()
interactions.clickCloseButton(container)
assertions.shouldNotRender(container)
})
})
describe('Cloud Edition Content', () => {
it('should display cloud version title', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByText(textKeys.cloud.trialTitle)).toBeInTheDocument()
})
it('should display emoji for cloud version', () => {
const { container } = scenarios.withAPIKeyNotSet()
expect(container.querySelector('em-emoji')).toBeInTheDocument()
expect(container.querySelector('em-emoji')).toHaveAttribute('id', '😀')
})
it('should display cloud version description', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByText(textKeys.cloud.trialDescription)).toBeInTheDocument()
})
it('should not render external link for cloud version', () => {
const { container } = scenarios.withAPIKeyNotSet()
expect(container.querySelector('a[href="https://cloud.dify.ai/apps"]')).not.toBeInTheDocument()
})
it('should display set API button text', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByText(textKeys.cloud.setAPIBtn)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call setShowAccountSettingModal when set API button is clicked', () => {
scenarios.withMockModal(mockSetShowAccountSettingModal)
interactions.clickMainButton()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
})
})
it('should hide panel when close button is clicked', () => {
const { container } = scenarios.withAPIKeyNotSet()
expect(container.firstChild).toBeInTheDocument()
interactions.clickCloseButton(container)
assertions.shouldNotRender(container)
})
})
describe('Props and Styling', () => {
it('should render button with primary variant', () => {
scenarios.withAPIKeyNotSet()
const button = screen.getByRole('button')
expect(button).toHaveClass('btn-primary')
})
it('should render panel container with correct classes', () => {
const { container } = scenarios.withAPIKeyNotSet()
const panel = container.firstChild as HTMLElement
assertions.shouldHavePanelStyling(panel)
})
})
describe('Accessibility', () => {
it('should have button with proper role', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should have clickable close button', () => {
const { container } = scenarios.withAPIKeyNotSet()
assertions.shouldHaveCloseButton(container)
})
})
})
@@ -0,0 +1,162 @@
import { cleanup, screen } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
assertions,
clearAllMocks,
defaultModalContext,
interactions,
mockUseModalContext,
scenarios,
textKeys,
} from './apikey-info-panel.test-utils'
// Mock config for CE edition
jest.mock('@/config', () => ({
IS_CE_EDITION: true, // Test CE edition by default
}))
afterEach(cleanup)
describe('APIKeyInfoPanel - Community Edition', () => {
const mockSetShowAccountSettingModal = jest.fn()
beforeEach(() => {
clearAllMocks()
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
})
})
describe('Rendering', () => {
it('should render without crashing when API key is not set', () => {
scenarios.withAPIKeyNotSet()
assertions.shouldRenderMainButton()
})
it('should not render when API key is already set', () => {
const { container } = scenarios.withAPIKeySet()
assertions.shouldNotRender(container)
})
it('should not render when panel is hidden by user', () => {
const { container } = scenarios.withAPIKeyNotSet()
interactions.clickCloseButton(container)
assertions.shouldNotRender(container)
})
})
describe('Content Display', () => {
it('should display self-host title content', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByText(textKeys.selfHost.titleRow1)).toBeInTheDocument()
expect(screen.getByText(textKeys.selfHost.titleRow2)).toBeInTheDocument()
})
it('should display set API button text', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByText(textKeys.selfHost.setAPIBtn)).toBeInTheDocument()
})
it('should render external link with correct href for self-host version', () => {
const { container } = scenarios.withAPIKeyNotSet()
const link = container.querySelector('a[href="https://cloud.dify.ai/apps"]')
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
expect(link).toHaveTextContent(textKeys.selfHost.tryCloud)
})
it('should have external link with proper styling for self-host version', () => {
const { container } = scenarios.withAPIKeyNotSet()
const link = container.querySelector('a[href="https://cloud.dify.ai/apps"]')
expect(link).toHaveClass(
'mt-2',
'flex',
'h-[26px]',
'items-center',
'space-x-1',
'p-1',
'text-xs',
'font-medium',
'text-[#155EEF]',
)
})
})
describe('User Interactions', () => {
it('should call setShowAccountSettingModal when set API button is clicked', () => {
scenarios.withMockModal(mockSetShowAccountSettingModal)
interactions.clickMainButton()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
})
})
it('should hide panel when close button is clicked', () => {
const { container } = scenarios.withAPIKeyNotSet()
expect(container.firstChild).toBeInTheDocument()
interactions.clickCloseButton(container)
assertions.shouldNotRender(container)
})
})
describe('Props and Styling', () => {
it('should render button with primary variant', () => {
scenarios.withAPIKeyNotSet()
const button = screen.getByRole('button')
expect(button).toHaveClass('btn-primary')
})
it('should render panel container with correct classes', () => {
const { container } = scenarios.withAPIKeyNotSet()
const panel = container.firstChild as HTMLElement
assertions.shouldHavePanelStyling(panel)
})
})
describe('State Management', () => {
it('should start with visible panel (isShow: true)', () => {
scenarios.withAPIKeyNotSet()
assertions.shouldRenderMainButton()
})
it('should toggle visibility when close button is clicked', () => {
const { container } = scenarios.withAPIKeyNotSet()
expect(container.firstChild).toBeInTheDocument()
interactions.clickCloseButton(container)
assertions.shouldNotRender(container)
})
})
describe('Edge Cases', () => {
it('should handle provider context loading state', () => {
scenarios.withAPIKeyNotSet({
providerContext: {
modelProviders: [],
textGenerationModelList: [],
},
})
assertions.shouldRenderMainButton()
})
})
describe('Accessibility', () => {
it('should have button with proper role', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should have clickable close button', () => {
const { container } = scenarios.withAPIKeyNotSet()
assertions.shouldHaveCloseButton(container)
})
})
})
@@ -24,6 +24,10 @@ import cn from '@/utils/classnames'
import type { FileEntity } from '../../file-uploader/types'
import { formatBooleanInputs } from '@/utils/model-config'
import Avatar from '../../avatar'
import ServiceConnectionPanel from '@/app/components/base/service-connection-panel'
import type { AuthType, ServiceConnectionItem as ServiceConnectionItemType } from '@/app/components/base/service-connection-panel'
import { Notion } from '@/app/components/base/icons/src/public/common'
import { Google } from '@/app/components/base/icons/src/public/plugins'
const ChatWrapper = () => {
const {
@@ -167,6 +171,53 @@ const ChatWrapper = () => {
const [collapsed, setCollapsed] = useState(!!currentConversationId)
// Demo: Service connection state
const [serviceConnections, setServiceConnections] = useState<ServiceConnectionItemType[]>([
{
id: 'notion',
name: 'Notion Page Search',
icon: <Notion className="h-6 w-6" />,
authType: 'oauth',
status: 'pending',
},
{
id: 'gmail',
name: 'Gmail Tools',
icon: <img src="https://www.gstatic.com/images/branding/product/1x/gmail_2020q4_32dp.png" alt="Gmail" className="h-6 w-6" />,
authType: 'oauth',
status: 'pending',
},
{
id: 'youtube',
name: 'YouTube Data Upload',
icon: <img src="https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png" alt="YouTube" className="h-6 w-6" />,
authType: 'oauth',
status: 'pending',
},
{
id: 'google-serp',
name: 'Google SerpApi Search',
icon: <Google className="h-6 w-6" />,
authType: 'api_key',
status: 'pending',
},
])
const [showServiceConnection, setShowServiceConnection] = useState(true)
const handleServiceConnect = useCallback((serviceId: string, _authType: AuthType) => {
// Demo: 模拟连接成功
setServiceConnections(prev => prev.map(service =>
service.id === serviceId
? { ...service, status: 'connected' as const }
: service,
))
}, [])
const handleServiceContinue = useCallback(() => {
setShowServiceConnection(false)
}, [])
const chatNode = useMemo(() => {
if (allInputsHidden || !inputsForms.length)
return null
@@ -253,6 +304,23 @@ const ChatWrapper = () => {
/>
: null
// 如果需要显示服务连接面板,则显示面板而非聊天界面
if (showServiceConnection) {
return (
<div className={cn(
'flex h-full items-center justify-center overflow-auto bg-chatbot-bg',
isMobile && 'px-4 py-8',
)}>
<ServiceConnectionPanel
services={serviceConnections}
onConnect={handleServiceConnect}
onContinue={handleServiceContinue}
className={cn(isMobile && 'max-w-full')}
/>
</div>
)
}
return (
<div
className='h-full overflow-hidden bg-chatbot-bg'
@@ -0,0 +1,79 @@
'use client'
import type { FC } from 'react'
import { memo, useMemo } from 'react'
import { RiArrowRightLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import ServiceItem from './service-item'
import type { ServiceConnectionPanelProps } from './types'
import cn from '@/utils/classnames'
const ServiceConnectionPanel: FC<ServiceConnectionPanelProps> = ({
title,
description,
services,
onConnect,
onContinue,
continueDisabled,
continueText,
className,
}) => {
const { t } = useTranslation()
const allConnected = useMemo(() => {
return services.every(service => service.status === 'connected')
}, [services])
const displayTitle = title || t('share.serviceConnection.title')
const displayDescription = description || t('share.serviceConnection.description', { count: services.length })
return (
<div className={cn(
'flex w-full max-w-[600px] flex-col items-center',
className,
)}>
<div className="mb-6 text-center">
<h2 className="system-xl-semibold mb-1 text-text-primary">
{displayTitle}
</h2>
<p className="system-sm-regular text-text-tertiary">
{displayDescription}
</p>
</div>
<div className="w-full space-y-2">
{services.map(service => (
<ServiceItem
key={service.id}
service={service}
onConnect={onConnect}
/>
))}
</div>
{onContinue && (
<div className="mt-6 flex w-full justify-end">
<Button
variant="primary"
disabled={continueDisabled ?? !allConnected}
onClick={onContinue}
>
{continueText || t('share.serviceConnection.continue')}
<RiArrowRightLine className="ml-1 h-4 w-4" />
</Button>
</div>
)}
</div>
)
}
export default memo(ServiceConnectionPanel)
export { default as ServiceItem } from './service-item'
export type {
ServiceConnectionPanelProps,
ServiceConnectionItem,
AuthType,
ServiceConnectionStatus,
} from './types'
@@ -0,0 +1,72 @@
'use client'
import type { FC } from 'react'
import { memo } from 'react'
import { RiAddLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import type { AuthType, ServiceConnectionItem } from './types'
import cn from '@/utils/classnames'
type ServiceItemProps = {
service: ServiceConnectionItem
onConnect: (serviceId: string, authType: AuthType) => void
}
const ServiceItem: FC<ServiceItemProps> = ({
service,
onConnect,
}) => {
const { t } = useTranslation()
const handleConnect = () => {
onConnect(service.id, service.authType)
}
const getButtonText = () => {
if (service.status === 'connected')
return t('share.serviceConnection.connected')
if (service.authType === 'api_key')
return t('share.serviceConnection.addApiKey')
return t('share.serviceConnection.connect')
}
const isConnected = service.status === 'connected'
return (
<div className={cn(
'flex items-center justify-between gap-3 rounded-xl border border-components-panel-border-subtle bg-components-panel-bg px-4 py-3',
'hover:border-components-panel-border hover:shadow-xs',
'transition-all duration-200',
)}>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
{service.icon}
</div>
<div className="flex flex-col">
<span className="system-sm-medium text-text-secondary">
{service.name}
</span>
{service.description && (
<span className="system-xs-regular text-text-tertiary">
{service.description}
</span>
)}
</div>
</div>
<Button
variant={isConnected ? 'secondary' : 'secondary-accent'}
size="small"
onClick={handleConnect}
disabled={isConnected}
>
{!isConnected && <RiAddLine className="mr-0.5 h-3.5 w-3.5" />}
{getButtonText()}
</Button>
</div>
)
}
export default memo(ServiceItem)
@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
export type AuthType = 'oauth' | 'api_key'
export type ServiceConnectionStatus = 'pending' | 'connected' | 'error'
export type ServiceConnectionItem = {
id: string
name: string
icon: ReactNode
authType: AuthType
status: ServiceConnectionStatus
description?: string
}
export type ServiceConnectionPanelProps = {
title?: string
description?: string
services: ServiceConnectionItem[]
onConnect: (serviceId: string, authType: AuthType) => void
onContinue?: () => void
continueDisabled?: boolean
continueText?: string
className?: string
}
@@ -33,7 +33,10 @@ const PlanUpgradeModal: FC<Props> = ({
const handleUpgrade = useCallback(() => {
onClose()
onUpgrade ? onUpgrade() : setShowPricingModal()
if (onUpgrade)
onUpgrade()
else
setShowPricingModal()
}, [onClose, onUpgrade, setShowPricingModal])
return (
@@ -0,0 +1,50 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import Button from './button'
import { Plan } from '../../../type'
describe('CloudPlanButton', () => {
describe('Disabled state', () => {
test('should disable button and hide arrow when plan is not available', () => {
const handleGetPayUrl = jest.fn()
// Arrange
render(
<Button
plan={Plan.team}
isPlanDisabled
btnText="Get started"
handleGetPayUrl={handleGetPayUrl}
/>,
)
const button = screen.getByRole('button', { name: /Get started/i })
// Assert
expect(button).toBeDisabled()
expect(button.className).toContain('cursor-not-allowed')
expect(handleGetPayUrl).not.toHaveBeenCalled()
})
})
describe('Enabled state', () => {
test('should invoke handler and render arrow when plan is available', () => {
const handleGetPayUrl = jest.fn()
// Arrange
render(
<Button
plan={Plan.sandbox}
isPlanDisabled={false}
btnText="Start now"
handleGetPayUrl={handleGetPayUrl}
/>,
)
const button = screen.getByRole('button', { name: /Start now/i })
// Act
fireEvent.click(button)
// Assert
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
expect(button).not.toBeDisabled()
})
})
})
@@ -0,0 +1,188 @@
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import CloudPlanItem from './index'
import { Plan } from '../../../type'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
import { useAppContext } from '@/context/app-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchBillingUrl, fetchSubscriptionUrls } from '@/service/billing'
import Toast from '../../../../base/toast'
import { ALL_PLANS } from '../../../config'
jest.mock('../../../../base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(),
},
}))
jest.mock('@/context/app-context', () => ({
useAppContext: jest.fn(),
}))
jest.mock('@/service/billing', () => ({
fetchBillingUrl: jest.fn(),
fetchSubscriptionUrls: jest.fn(),
}))
jest.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: jest.fn(),
}))
jest.mock('../../assets', () => ({
Sandbox: () => <div>Sandbox Icon</div>,
Professional: () => <div>Professional Icon</div>,
Team: () => <div>Team Icon</div>,
}))
const mockUseAppContext = useAppContext as jest.Mock
const mockUseAsyncWindowOpen = useAsyncWindowOpen as jest.Mock
const mockFetchBillingUrl = fetchBillingUrl as jest.Mock
const mockFetchSubscriptionUrls = fetchSubscriptionUrls as jest.Mock
const mockToastNotify = Toast.notify as jest.Mock
let assignedHref = ''
const originalLocation = window.location
beforeAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: {
get href() {
return assignedHref
},
set href(value: string) {
assignedHref = value
},
} as unknown as Location,
})
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
beforeEach(() => {
jest.clearAllMocks()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
mockUseAsyncWindowOpen.mockReturnValue(jest.fn(async open => await open()))
mockFetchBillingUrl.mockResolvedValue({ url: 'https://billing.example' })
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://subscription.example' })
assignedHref = ''
})
describe('CloudPlanItem', () => {
// Static content for each plan
describe('Rendering', () => {
test('should show plan metadata and free label for sandbox plan', () => {
render(
<CloudPlanItem
plan={Plan.sandbox}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
expect(screen.getByText('billing.plans.sandbox.name')).toBeInTheDocument()
expect(screen.getByText('billing.plans.sandbox.description')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.free')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })).toBeInTheDocument()
})
test('should display yearly pricing with discount when planRange is yearly', () => {
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
const professionalPlan = ALL_PLANS[Plan.professional]
expect(screen.getByText(`$${professionalPlan.price * 12}`)).toBeInTheDocument()
expect(screen.getByText(`$${professionalPlan.price * 10}`)).toBeInTheDocument()
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.year/)).toBeInTheDocument()
})
test('should disable CTA when workspace already on higher tier', () => {
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.team}
planRange={PlanRange.monthly}
canPay
/>,
)
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
expect(button).toBeDisabled()
})
})
// Payment actions triggered from the CTA
describe('Plan purchase flow', () => {
test('should show toast when non-manager tries to buy a plan', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'billing.buyPermissionDeniedTip',
}))
expect(mockFetchBillingUrl).not.toHaveBeenCalled()
})
test('should open billing portal when upgrading current paid plan', async () => {
const openWindow = jest.fn(async (cb: () => Promise<string>) => await cb())
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.professional}
planRange={PlanRange.monthly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
await waitFor(() => {
expect(mockFetchBillingUrl).toHaveBeenCalledTimes(1)
})
expect(openWindow).toHaveBeenCalledTimes(1)
})
test('should redirect to subscription url when selecting a new paid plan', async () => {
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
expect(assignedHref).toBe('https://subscription.example')
})
})
})
})
@@ -0,0 +1,30 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import List from './index'
import { Plan } from '../../../../type'
describe('CloudPlanItem/List', () => {
test('should show sandbox specific quotas', () => {
render(<List plan={Plan.sandbox} />)
expect(screen.getByText('billing.plansCommon.messageRequest.title:{"count":200}')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.triggerEvents.sandbox:{"count":3000}')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.startNodes.limited:{"count":2}')).toBeInTheDocument()
})
test('should show professional monthly quotas and tooltips', () => {
render(<List plan={Plan.professional} />)
expect(screen.getByText('billing.plansCommon.messageRequest.titlePerMonth:{"count":5000}')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.vectorSpaceTooltip')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.workflowExecution.faster')).toBeInTheDocument()
})
test('should show unlimited messaging details for team plan', () => {
render(<List plan={Plan.team} />)
expect(screen.getByText('billing.plansCommon.triggerEvents.unlimited')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.workflowExecution.priority')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.unlimitedApiRate')).toBeInTheDocument()
})
})
@@ -0,0 +1,87 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import Plans from './index'
import { Plan, type UsagePlanInfo } from '../../type'
import { PlanRange } from '../plan-switcher/plan-range-switcher'
jest.mock('./cloud-plan-item', () => ({
__esModule: true,
default: jest.fn(props => (
<div data-testid={`cloud-plan-${props.plan}`} data-current-plan={props.currentPlan}>
Cloud {props.plan}
</div>
)),
}))
jest.mock('./self-hosted-plan-item', () => ({
__esModule: true,
default: jest.fn(props => (
<div data-testid={`self-plan-${props.plan}`}>
Self {props.plan}
</div>
)),
}))
const buildPlan = (type: Plan) => {
const usage: UsagePlanInfo = {
buildApps: 0,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
vectorSpace: 0,
}
return {
type,
usage,
total: usage,
}
}
describe('Plans', () => {
// Cloud plans visible only when currentPlan is cloud
describe('Cloud plan rendering', () => {
test('should render sandbox, professional, and team cloud plans when workspace is cloud', () => {
render(
<Plans
plan={buildPlan(Plan.enterprise)}
currentPlan="cloud"
planRange={PlanRange.monthly}
canPay
/>,
)
expect(screen.getByTestId('cloud-plan-sandbox')).toBeInTheDocument()
expect(screen.getByTestId('cloud-plan-professional')).toBeInTheDocument()
expect(screen.getByTestId('cloud-plan-team')).toBeInTheDocument()
const cloudPlanItem = jest.requireMock('./cloud-plan-item').default as jest.Mock
const firstCallProps = cloudPlanItem.mock.calls[0][0]
expect(firstCallProps.plan).toBe(Plan.sandbox)
// Enterprise should be normalized to team when passed down
expect(firstCallProps.currentPlan).toBe(Plan.team)
})
})
// Self-hosted plans visible for self-managed workspaces
describe('Self-hosted plan rendering', () => {
test('should render all self-hosted plans when workspace type is self-hosted', () => {
render(
<Plans
plan={buildPlan(Plan.sandbox)}
currentPlan="self"
planRange={PlanRange.yearly}
canPay={false}
/>,
)
expect(screen.getByTestId('self-plan-community')).toBeInTheDocument()
expect(screen.getByTestId('self-plan-premium')).toBeInTheDocument()
expect(screen.getByTestId('self-plan-enterprise')).toBeInTheDocument()
const selfPlanItem = jest.requireMock('./self-hosted-plan-item').default as jest.Mock
expect(selfPlanItem).toHaveBeenCalledTimes(3)
})
})
})
@@ -0,0 +1,61 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import Button from './button'
import { SelfHostedPlan } from '../../../type'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
jest.mock('@/hooks/use-theme')
jest.mock('@/app/components/base/icons/src/public/billing', () => ({
AwsMarketplaceLight: () => <div>AwsMarketplaceLight</div>,
AwsMarketplaceDark: () => <div>AwsMarketplaceDark</div>,
}))
const mockUseTheme = useTheme as jest.MockedFunction<typeof useTheme>
beforeEach(() => {
jest.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light } as unknown as ReturnType<typeof useTheme>)
})
describe('SelfHostedPlanButton', () => {
test('should invoke handler when clicked', () => {
const handleGetPayUrl = jest.fn()
render(
<Button
plan={SelfHostedPlan.community}
handleGetPayUrl={handleGetPayUrl}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plans.community.btnText' }))
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
})
test('should render AWS marketplace badge for premium plan in light theme', () => {
const handleGetPayUrl = jest.fn()
render(
<Button
plan={SelfHostedPlan.premium}
handleGetPayUrl={handleGetPayUrl}
/>,
)
expect(screen.getByText('AwsMarketplaceLight')).toBeInTheDocument()
})
test('should switch to dark AWS badge in dark theme', () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark } as unknown as ReturnType<typeof useTheme>)
render(
<Button
plan={SelfHostedPlan.premium}
handleGetPayUrl={jest.fn()}
/>,
)
expect(screen.getByText('AwsMarketplaceDark')).toBeInTheDocument()
})
})
@@ -0,0 +1,143 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import SelfHostedPlanItem from './index'
import { SelfHostedPlan } from '../../../type'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
import { useAppContext } from '@/context/app-context'
import Toast from '../../../../base/toast'
const featuresTranslations: Record<string, string[]> = {
'billing.plans.community.features': ['community-feature-1', 'community-feature-2'],
'billing.plans.premium.features': ['premium-feature-1'],
'billing.plans.enterprise.features': ['enterprise-feature-1'],
}
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.returnObjects)
return featuresTranslations[key] || []
return key
},
}),
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
}))
jest.mock('../../../../base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(),
},
}))
jest.mock('@/context/app-context', () => ({
useAppContext: jest.fn(),
}))
jest.mock('../../assets', () => ({
Community: () => <div>Community Icon</div>,
Premium: () => <div>Premium Icon</div>,
Enterprise: () => <div>Enterprise Icon</div>,
PremiumNoise: () => <div>PremiumNoise</div>,
EnterpriseNoise: () => <div>EnterpriseNoise</div>,
}))
jest.mock('@/app/components/base/icons/src/public/billing', () => ({
Azure: () => <div>Azure</div>,
GoogleCloud: () => <div>Google Cloud</div>,
AwsMarketplaceDark: () => <div>AwsMarketplaceDark</div>,
AwsMarketplaceLight: () => <div>AwsMarketplaceLight</div>,
}))
const mockUseAppContext = useAppContext as jest.Mock
const mockToastNotify = Toast.notify as jest.Mock
let assignedHref = ''
const originalLocation = window.location
beforeAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: {
get href() {
return assignedHref
},
set href(value: string) {
assignedHref = value
},
} as unknown as Location,
})
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
beforeEach(() => {
jest.clearAllMocks()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
assignedHref = ''
})
describe('SelfHostedPlanItem', () => {
// Copy rendering for each plan
describe('Rendering', () => {
test('should display community plan info', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
expect(screen.getByText('billing.plans.community.name')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.description')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.price')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
expect(screen.getByText('community-feature-1')).toBeInTheDocument()
})
test('should show premium extras such as cloud provider notice', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
expect(screen.getByText('billing.plans.premium.price')).toBeInTheDocument()
expect(screen.getByText('billing.plans.premium.comingSoon')).toBeInTheDocument()
expect(screen.getByText('Azure')).toBeInTheDocument()
expect(screen.getByText('Google Cloud')).toBeInTheDocument()
})
})
// CTA behavior for each plan
describe('CTA interactions', () => {
test('should show toast when non-manager tries to proceed', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
fireEvent.click(screen.getByRole('button', { name: /billing\.plans\.premium\.btnText/ }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'billing.buyPermissionDeniedTip',
}))
})
test('should redirect to community url when community plan button clicked', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
fireEvent.click(screen.getByRole('button', { name: 'billing.plans.community.btnText' }))
expect(assignedHref).toBe(getStartedWithCommunityUrl)
})
test('should redirect to premium marketplace url when premium button clicked', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
fireEvent.click(screen.getByRole('button', { name: /billing\.plans\.premium\.btnText/ }))
expect(assignedHref).toBe(getWithPremiumUrl)
})
test('should redirect to contact sales form when enterprise button clicked', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
fireEvent.click(screen.getByRole('button', { name: 'billing.plans.enterprise.btnText' }))
expect(assignedHref).toBe(contactSalesUrl)
})
})
})
@@ -0,0 +1,25 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import List from './index'
import { SelfHostedPlan } from '@/app/components/billing/type'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.returnObjects)
return ['Feature A', 'Feature B']
return key
},
}),
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
}))
describe('SelfHostedPlanItem/List', () => {
test('should render plan info', () => {
render(<List plan={SelfHostedPlan.community} />)
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
expect(screen.getByText('Feature A')).toBeInTheDocument()
expect(screen.getByText('Feature B')).toBeInTheDocument()
})
})
@@ -0,0 +1,12 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import Item from './item'
describe('SelfHostedPlanItem/List/Item', () => {
test('should display provided feature label', () => {
const { container } = render(<Item label="Dedicated support" />)
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})
@@ -29,7 +29,6 @@ const NotionPagePreview = ({
return
try {
const res = await fetchNotionPagePreview({
workspaceID: currentPage.workspace_id,
pageID: currentPage.page_id,
pageType: currentPage.type,
credentialID: notionCredentialId,
@@ -75,11 +75,17 @@ const OnlineDocuments = ({
const getOnlineDocuments = useCallback(async () => {
const { currentCredentialId } = dataSourceStore.getState()
// Convert datasource_parameters to inputs format for the API
const inputs = Object.entries(nodeData.datasource_parameters || {}).reduce((acc, [key, value]) => {
acc[key] = typeof value === 'object' && value !== null && 'value' in value ? value.value : value
return acc
}, {} as Record<string, any>)
ssePost(
datasourceNodeRunURL,
{
body: {
inputs: {},
inputs,
credential_id: currentCredentialId,
datasource_type: DatasourceType.onlineDocument,
},
@@ -97,7 +103,7 @@ const OnlineDocuments = ({
},
},
)
}, [dataSourceStore, datasourceNodeRunURL])
}, [dataSourceStore, datasourceNodeRunURL, nodeData.datasource_parameters])
useEffect(() => {
if (!currentCredentialId) return
@@ -62,7 +62,7 @@ type CurrChildChunkType = {
showModal: boolean
}
type SegmentListContextValue = {
export type SegmentListContextValue = {
isCollapsed: boolean
fullScreen: boolean
toggleFullScreen: (fullscreen?: boolean) => void
@@ -129,6 +129,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
return (
<div
data-testid="segment-card"
className={cn(
'chunk-card group/card w-full rounded-xl px-3',
isFullDocMode ? '' : 'pb-2 pt-2.5 hover:bg-dataset-chunk-detail-card-hover-bg',
@@ -172,6 +173,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
popupClassName='text-text-secondary system-xs-medium'
>
<div
data-testid="segment-edit-button"
className='flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
@@ -184,7 +186,9 @@ const SegmentCard: FC<ISegmentCardProps> = ({
popupContent='Delete'
popupClassName='text-text-secondary system-xs-medium'
>
<div className='group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover'
<div
data-testid="segment-delete-button"
className='group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover'
onClick={(e) => {
e.stopPropagation()
setShowModal(true)
@@ -10,7 +10,7 @@ import {
const ParentChunkCardSkelton = () => {
const { t } = useTranslation()
return (
<div className='flex flex-col pb-2'>
<div data-testid='parent-chunk-card-skeleton' className='flex flex-col pb-2'>
<SkeletonContainer className='gap-y-0 p-1 pb-0'>
<SkeletonContainer className='gap-y-0.5 px-2 pt-1.5'>
<SkeletonRow className='py-0.5'>
@@ -1,7 +1,7 @@
import type { ChunkingMode, ParentMode } from '@/models/datasets'
import { createContext, useContextSelector } from 'use-context-selector'
type DocumentContextValue = {
export type DocumentContextValue = {
datasetId?: string
documentId?: string
docForm?: ChunkingMode
@@ -0,0 +1,786 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import PipelineSettings from './index'
import { DatasourceType } from '@/models/pipeline'
import type { PipelineExecutionLogResponse } from '@/models/pipeline'
// Mock i18n
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock Next.js router
const mockPush = jest.fn()
const mockBack = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: mockBack,
}),
}))
// Mock dataset detail context
const mockPipelineId = 'pipeline-123'
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string; doc_form: string } }) => unknown) =>
selector({ dataset: { pipeline_id: mockPipelineId, doc_form: 'text_model' } }),
}))
// Mock API hooks for PipelineSettings
const mockUsePipelineExecutionLog = jest.fn()
const mockMutateAsync = jest.fn()
const mockUseRunPublishedPipeline = jest.fn()
jest.mock('@/service/use-pipeline', () => ({
usePipelineExecutionLog: (params: { dataset_id: string; document_id: string }) => mockUsePipelineExecutionLog(params),
useRunPublishedPipeline: () => mockUseRunPublishedPipeline(),
// For ProcessDocuments component
usePublishedPipelineProcessingParams: () => ({
data: { variables: [] },
isFetching: false,
}),
}))
// Mock document invalidation hooks
const mockInvalidDocumentList = jest.fn()
const mockInvalidDocumentDetail = jest.fn()
jest.mock('@/service/knowledge/use-document', () => ({
useInvalidDocumentList: () => mockInvalidDocumentList,
useInvalidDocumentDetail: () => mockInvalidDocumentDetail,
}))
// Mock Form component in ProcessDocuments - internal dependencies are too complex
jest.mock('../../../create-from-pipeline/process-documents/form', () => {
return function MockForm({
ref,
initialData,
configurations,
onSubmit,
onPreview,
isRunning,
}: {
ref: React.RefObject<{ submit: () => void }>
initialData: Record<string, unknown>
configurations: Array<{ variable: string; label: string; type: string }>
schema: unknown
onSubmit: (data: Record<string, unknown>) => void
onPreview: () => void
isRunning: boolean
}) {
if (ref && typeof ref === 'object' && 'current' in ref) {
(ref as React.MutableRefObject<{ submit: () => void }>).current = {
submit: () => onSubmit(initialData),
}
}
return (
<form
data-testid="process-form"
onSubmit={(e) => {
e.preventDefault()
onSubmit(initialData)
}}
>
{configurations.map((config, index) => (
<div key={index} data-testid={`field-${config.variable}`}>
<label>{config.label}</label>
</div>
))}
<button type="button" data-testid="preview-btn" onClick={onPreview} disabled={isRunning}>
Preview
</button>
</form>
)
}
})
// Mock ChunkPreview - has complex internal state and many dependencies
jest.mock('../../../create-from-pipeline/preview/chunk-preview', () => {
return function MockChunkPreview({
dataSourceType,
localFiles,
onlineDocuments,
websitePages,
onlineDriveFiles,
isIdle,
isPending,
estimateData,
}: {
dataSourceType: string
localFiles: unknown[]
onlineDocuments: unknown[]
websitePages: unknown[]
onlineDriveFiles: unknown[]
isIdle: boolean
isPending: boolean
estimateData: unknown
}) {
return (
<div data-testid="chunk-preview">
<span data-testid="datasource-type">{dataSourceType}</span>
<span data-testid="local-files-count">{localFiles.length}</span>
<span data-testid="online-documents-count">{onlineDocuments.length}</span>
<span data-testid="website-pages-count">{websitePages.length}</span>
<span data-testid="online-drive-files-count">{onlineDriveFiles.length}</span>
<span data-testid="is-idle">{String(isIdle)}</span>
<span data-testid="is-pending">{String(isPending)}</span>
<span data-testid="has-estimate-data">{String(!!estimateData)}</span>
</div>
)
}
})
// Test utilities
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// Factory functions for test data
const createMockExecutionLogResponse = (
overrides: Partial<PipelineExecutionLogResponse> = {},
): PipelineExecutionLogResponse => ({
datasource_type: DatasourceType.localFile,
input_data: { chunk_size: '100' },
datasource_node_id: 'datasource-node-1',
datasource_info: {
related_id: 'file-1',
name: 'test-file.pdf',
extension: 'pdf',
},
...overrides,
})
const createDefaultProps = () => ({
datasetId: 'dataset-123',
documentId: 'document-456',
})
describe('PipelineSettings', () => {
beforeEach(() => {
jest.clearAllMocks()
mockPush.mockClear()
mockBack.mockClear()
mockMutateAsync.mockClear()
mockInvalidDocumentList.mockClear()
mockInvalidDocumentDetail.mockClear()
// Default: successful data fetch
mockUsePipelineExecutionLog.mockReturnValue({
data: createMockExecutionLogResponse(),
isFetching: false,
isError: false,
})
// Default: useRunPublishedPipeline mock
mockUseRunPublishedPipeline.mockReturnValue({
mutateAsync: mockMutateAsync,
isIdle: true,
isPending: false,
})
})
// ==================== Rendering Tests ====================
// Test basic rendering with real components
describe('Rendering', () => {
it('should render without crashing when data is loaded', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - Real LeftHeader should render with correct content
expect(screen.getByText('datasetPipeline.documentSettings.title')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.addDocuments.steps.processDocuments')).toBeInTheDocument()
// Real ProcessDocuments should render
expect(screen.getByTestId('process-form')).toBeInTheDocument()
// ChunkPreview should render
expect(screen.getByTestId('chunk-preview')).toBeInTheDocument()
})
it('should render Loading component when fetching data', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: true,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - Loading component should be rendered, not main content
expect(screen.queryByText('datasetPipeline.documentSettings.title')).not.toBeInTheDocument()
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
})
it('should render AppUnavailable when there is an error', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: false,
isError: true,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - AppUnavailable should be rendered
expect(screen.queryByText('datasetPipeline.documentSettings.title')).not.toBeInTheDocument()
})
it('should render container with correct CSS classes', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = renderWithProviders(<PipelineSettings {...props} />)
// Assert
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('relative', 'flex', 'min-w-[1024px]')
})
})
// ==================== LeftHeader Integration ====================
// Test real LeftHeader component behavior
describe('LeftHeader Integration', () => {
it('should render LeftHeader with title prop', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - LeftHeader displays the title
expect(screen.getByText('datasetPipeline.documentSettings.title')).toBeInTheDocument()
})
it('should render back button in LeftHeader', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - Back button should exist with proper aria-label
const backButton = screen.getByRole('button', { name: 'common.operation.back' })
expect(backButton).toBeInTheDocument()
})
it('should call router.back when back button is clicked', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
const backButton = screen.getByRole('button', { name: 'common.operation.back' })
fireEvent.click(backButton)
// Assert
expect(mockBack).toHaveBeenCalledTimes(1)
})
})
// ==================== Props Testing ====================
describe('Props', () => {
it('should pass datasetId and documentId to usePipelineExecutionLog', () => {
// Arrange
const props = { datasetId: 'custom-dataset', documentId: 'custom-document' }
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(mockUsePipelineExecutionLog).toHaveBeenCalledWith({
dataset_id: 'custom-dataset',
document_id: 'custom-document',
})
})
})
// ==================== Memoization - Data Transformation ====================
describe('Memoization - Data Transformation', () => {
it('should transform localFile datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.localFile,
datasource_info: {
related_id: 'file-123',
name: 'document.pdf',
extension: 'pdf',
},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('local-files-count')).toHaveTextContent('1')
expect(screen.getByTestId('datasource-type')).toHaveTextContent(DatasourceType.localFile)
})
it('should transform websiteCrawl datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.websiteCrawl,
datasource_info: {
content: 'Page content',
description: 'Page description',
source_url: 'https://example.com/page',
title: 'Page Title',
},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('website-pages-count')).toHaveTextContent('1')
expect(screen.getByTestId('local-files-count')).toHaveTextContent('0')
})
it('should transform onlineDocument datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.onlineDocument,
datasource_info: {
workspace_id: 'workspace-1',
page: { page_id: 'page-1', page_name: 'Notion Page' },
},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('online-documents-count')).toHaveTextContent('1')
})
it('should transform onlineDrive datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.onlineDrive,
datasource_info: { id: 'drive-1', type: 'doc', name: 'Google Doc', size: 1024 },
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('online-drive-files-count')).toHaveTextContent('1')
})
})
// ==================== User Interactions - Process ====================
describe('User Interactions - Process', () => {
it('should trigger form submit when process button is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Find the "Save and Process" button (from real ProcessDocuments > Actions)
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
fireEvent.click(processButton)
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should call handleProcess with is_preview=false', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
is_preview: false,
pipeline_id: mockPipelineId,
original_document_id: 'document-456',
}),
expect.any(Object),
)
})
})
it('should navigate to documents list after successful process', async () => {
// Arrange
mockMutateAsync.mockImplementation((_request, options) => {
options?.onSuccess?.()
return Promise.resolve({})
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents')
})
})
it('should invalidate document cache after successful process', async () => {
// Arrange
mockMutateAsync.mockImplementation((_request, options) => {
options?.onSuccess?.()
return Promise.resolve({})
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockInvalidDocumentList).toHaveBeenCalled()
expect(mockInvalidDocumentDetail).toHaveBeenCalled()
})
})
})
// ==================== User Interactions - Preview ====================
describe('User Interactions - Preview', () => {
it('should trigger preview when preview button is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should call handlePreviewChunks with is_preview=true', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
is_preview: true,
pipeline_id: mockPipelineId,
}),
expect.any(Object),
)
})
})
it('should update estimateData on successful preview', async () => {
// Arrange
const mockOutputs = { chunks: [], total_tokens: 50 }
mockMutateAsync.mockImplementation((_req, opts) => {
opts?.onSuccess?.({ data: { outputs: mockOutputs } })
return Promise.resolve({ data: { outputs: mockOutputs } })
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true')
})
})
})
// ==================== API Integration ====================
describe('API Integration', () => {
it('should pass correct parameters for preview', async () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.localFile,
datasource_node_id: 'node-xyz',
datasource_info: { related_id: 'file-1', name: 'test.pdf', extension: 'pdf' },
input_data: {},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert - inputs come from initialData which is transformed by useInitialData
// Since usePublishedPipelineProcessingParams returns empty variables, inputs is {}
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
{
pipeline_id: mockPipelineId,
inputs: {},
start_node_id: 'node-xyz',
datasource_type: DatasourceType.localFile,
datasource_info_list: [{ related_id: 'file-1', name: 'test.pdf', extension: 'pdf' }],
is_preview: true,
},
expect.any(Object),
)
})
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it.each([
[DatasourceType.localFile, 'local-files-count', '1'],
[DatasourceType.websiteCrawl, 'website-pages-count', '1'],
[DatasourceType.onlineDocument, 'online-documents-count', '1'],
[DatasourceType.onlineDrive, 'online-drive-files-count', '1'],
])('should handle %s datasource type correctly', (datasourceType, testId, expectedCount) => {
// Arrange
const datasourceInfoMap: Record<DatasourceType, Record<string, unknown>> = {
[DatasourceType.localFile]: { related_id: 'f1', name: 'file.pdf', extension: 'pdf' },
[DatasourceType.websiteCrawl]: { content: 'c', description: 'd', source_url: 'u', title: 't' },
[DatasourceType.onlineDocument]: { workspace_id: 'w1', page: { page_id: 'p1' } },
[DatasourceType.onlineDrive]: { id: 'd1', type: 'doc', name: 'n', size: 100 },
}
const mockData = createMockExecutionLogResponse({
datasource_type: datasourceType,
datasource_info: datasourceInfoMap[datasourceType],
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId(testId)).toHaveTextContent(expectedCount)
})
it('should show loading state during initial fetch', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: true,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
})
it('should show error state when API fails', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: false,
isError: true,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
})
})
// ==================== State Management ====================
describe('State Management', () => {
it('should initialize with undefined estimateData', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('false')
})
it('should update estimateData after successful preview', async () => {
// Arrange
const mockEstimateData = { chunks: [], total_tokens: 50 }
mockMutateAsync.mockImplementation((_req, opts) => {
opts?.onSuccess?.({ data: { outputs: mockEstimateData } })
return Promise.resolve({ data: { outputs: mockEstimateData } })
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true')
})
})
it('should set isPreview ref to false when process is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ is_preview: false }),
expect.any(Object),
)
})
})
it('should set isPreview ref to true when preview is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ is_preview: true }),
expect.any(Object),
)
})
})
it('should pass isPending=true to ChunkPreview when preview is pending', async () => {
// Arrange - Start with isPending=false so buttons are enabled
let isPendingState = false
mockUseRunPublishedPipeline.mockImplementation(() => ({
mutateAsync: mockMutateAsync,
isIdle: !isPendingState,
isPending: isPendingState,
}))
// A promise that never resolves to keep the pending state
const pendingPromise = new Promise<void>(() => undefined)
// When mutateAsync is called, set isPending to true and trigger rerender
mockMutateAsync.mockImplementation(() => {
isPendingState = true
return pendingPromise
})
const props = createDefaultProps()
const { rerender } = renderWithProviders(<PipelineSettings {...props} />)
// Act - Click preview button (sets isPreview.current = true and calls mutateAsync)
fireEvent.click(screen.getByTestId('preview-btn'))
// Update mock and rerender to reflect isPending=true state
mockUseRunPublishedPipeline.mockReturnValue({
mutateAsync: mockMutateAsync,
isIdle: false,
isPending: true,
})
rerender(
<QueryClientProvider client={createQueryClient()}>
<PipelineSettings {...props} />
</QueryClientProvider>,
)
// Assert - isPending && isPreview.current should both be true now
expect(screen.getByTestId('is-pending')).toHaveTextContent('true')
})
it('should pass isPending=false to ChunkPreview when process is pending (not preview)', async () => {
// Arrange - isPending is true but isPreview.current is false
mockUseRunPublishedPipeline.mockReturnValue({
mutateAsync: mockMutateAsync,
isIdle: false,
isPending: true,
})
mockMutateAsync.mockReturnValue(new Promise<void>(() => undefined))
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Click process (not preview) to set isPreview.current = false
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert - isPending && isPreview.current should be false (true && false = false)
await waitFor(() => {
expect(screen.getByTestId('is-pending')).toHaveTextContent('false')
})
})
})
})
@@ -31,6 +31,7 @@ const LeftHeader = ({
variant='secondary-accent'
className='absolute -left-11 top-3.5 size-9 rounded-full p-0'
onClick={navigateBack}
aria-label={t('common.operation.back')}
>
<RiArrowLeftLine className='size-5 ' />
</Button>
@@ -0,0 +1,573 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ProcessDocuments from './index'
import { PipelineInputVarType } from '@/models/pipeline'
import type { RAGPipelineVariable } from '@/models/pipeline'
// Mock i18n
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock dataset detail context - required for useInputVariables hook
const mockPipelineId = 'pipeline-123'
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string } }) => string) =>
selector({ dataset: { pipeline_id: mockPipelineId } }),
}))
// Mock API call for pipeline processing params
const mockParamsConfig = jest.fn()
jest.mock('@/service/use-pipeline', () => ({
usePublishedPipelineProcessingParams: () => ({
data: mockParamsConfig(),
isFetching: false,
}),
}))
// Mock Form component - internal dependencies (useAppForm, BaseField) are too complex
// Keep the mock minimal and focused on testing the integration
jest.mock('../../../../create-from-pipeline/process-documents/form', () => {
return function MockForm({
ref,
initialData,
configurations,
onSubmit,
onPreview,
isRunning,
}: {
ref: React.RefObject<{ submit: () => void }>
initialData: Record<string, unknown>
configurations: Array<{ variable: string; label: string; type: string }>
schema: unknown
onSubmit: (data: Record<string, unknown>) => void
onPreview: () => void
isRunning: boolean
}) {
// Expose submit method via ref for parent component control
if (ref && typeof ref === 'object' && 'current' in ref) {
(ref as React.MutableRefObject<{ submit: () => void }>).current = {
submit: () => onSubmit(initialData),
}
}
return (
<form
data-testid="process-form"
onSubmit={(e) => {
e.preventDefault()
onSubmit(initialData)
}}
>
{/* Render actual field labels from configurations */}
{configurations.map((config, index) => (
<div key={index} data-testid={`field-${config.variable}`}>
<label>{config.label}</label>
<input
name={config.variable}
defaultValue={String(initialData[config.variable] ?? '')}
data-testid={`input-${config.variable}`}
/>
</div>
))}
<button type="button" data-testid="preview-btn" onClick={onPreview} disabled={isRunning}>
Preview
</button>
</form>
)
}
})
// Test utilities
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// Factory function for creating mock variables - matches RAGPipelineVariable type
const createMockVariable = (overrides: Partial<RAGPipelineVariable> = {}): RAGPipelineVariable => ({
belong_to_node_id: 'node-123',
type: PipelineInputVarType.textInput,
variable: 'test_var',
label: 'Test Variable',
required: false,
...overrides,
})
// Default props factory
const createDefaultProps = (overrides: Partial<{
datasourceNodeId: string
lastRunInputData: Record<string, unknown>
isRunning: boolean
ref: React.RefObject<{ submit: () => void } | null>
onProcess: () => void
onPreview: () => void
onSubmit: (data: Record<string, unknown>) => void
}> = {}) => ({
datasourceNodeId: 'node-123',
lastRunInputData: {},
isRunning: false,
ref: { current: null } as React.RefObject<{ submit: () => void } | null>,
onProcess: jest.fn(),
onPreview: jest.fn(),
onSubmit: jest.fn(),
...overrides,
})
describe('ProcessDocuments', () => {
beforeEach(() => {
jest.clearAllMocks()
// Default: return empty variables
mockParamsConfig.mockReturnValue({ variables: [] })
})
// ==================== Rendering Tests ====================
// Test basic rendering and component structure
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - verify both Form and Actions are rendered
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument()
})
it('should render with correct container structure', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'flex-col', 'gap-y-4', 'pt-4')
})
it('should render form fields based on variables configuration', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number }),
createMockVariable({ variable: 'separator', label: 'Separator', type: PipelineInputVarType.textInput }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - real hooks transform variables to configurations
expect(screen.getByTestId('field-chunk_size')).toBeInTheDocument()
expect(screen.getByTestId('field-separator')).toBeInTheDocument()
expect(screen.getByText('Chunk Size')).toBeInTheDocument()
expect(screen.getByText('Separator')).toBeInTheDocument()
})
})
// ==================== Props Testing ====================
// Test how component behaves with different prop values
describe('Props', () => {
describe('lastRunInputData', () => {
it('should use lastRunInputData as initial form values', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const lastRunInputData = { chunk_size: 500 }
const props = createDefaultProps({ lastRunInputData })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - lastRunInputData should override default_value
const input = screen.getByTestId('input-chunk_size') as HTMLInputElement
expect(input.defaultValue).toBe('500')
})
it('should use default_value when lastRunInputData is empty', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps({ lastRunInputData: {} })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-chunk_size') as HTMLInputElement
expect(input.value).toBe('100')
})
})
describe('isRunning', () => {
it('should enable Actions button when isRunning is false', () => {
// Arrange
const props = createDefaultProps({ isRunning: false })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
expect(processButton).not.toBeDisabled()
})
it('should disable Actions button when isRunning is true', () => {
// Arrange
const props = createDefaultProps({ isRunning: true })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
expect(processButton).toBeDisabled()
})
it('should disable preview button when isRunning is true', () => {
// Arrange
const props = createDefaultProps({ isRunning: true })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('preview-btn')).toBeDisabled()
})
})
describe('ref', () => {
it('should expose submit method via ref', () => {
// Arrange
const ref = { current: null } as React.RefObject<{ submit: () => void } | null>
const onSubmit = jest.fn()
const props = createDefaultProps({ ref, onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(ref.current).not.toBeNull()
expect(typeof ref.current?.submit).toBe('function')
// Act - call submit via ref
ref.current?.submit()
// Assert - onSubmit should be called
expect(onSubmit).toHaveBeenCalled()
})
})
})
// ==================== User Interactions ====================
// Test event handlers and user interactions
describe('User Interactions', () => {
describe('onProcess', () => {
it('should call onProcess when Save and Process button is clicked', () => {
// Arrange
const onProcess = jest.fn()
const props = createDefaultProps({ onProcess })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
expect(onProcess).toHaveBeenCalledTimes(1)
})
it('should not call onProcess when button is disabled due to isRunning', () => {
// Arrange
const onProcess = jest.fn()
const props = createDefaultProps({ onProcess, isRunning: true })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
expect(onProcess).not.toHaveBeenCalled()
})
})
describe('onPreview', () => {
it('should call onPreview when preview button is clicked', () => {
// Arrange
const onPreview = jest.fn()
const props = createDefaultProps({ onPreview })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
expect(onPreview).toHaveBeenCalledTimes(1)
})
})
describe('onSubmit', () => {
it('should call onSubmit with form data when form is submitted', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const onSubmit = jest.fn()
const props = createDefaultProps({ onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.submit(screen.getByTestId('process-form'))
// Assert - should submit with initial data transformed by real hooks
// Note: default_value is string type, so the value remains as string
expect(onSubmit).toHaveBeenCalledWith({ chunk_size: '100' })
})
})
})
// ==================== Data Transformation Tests ====================
// Test real hooks transform data correctly
describe('Data Transformation', () => {
it('should transform text-input variable to string initial value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput, default_value: 'default' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-name') as HTMLInputElement
expect(input.defaultValue).toBe('default')
})
it('should transform number variable to number initial value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'count', label: 'Count', type: PipelineInputVarType.number, default_value: '42' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-count') as HTMLInputElement
expect(input.defaultValue).toBe('42')
})
it('should use empty string for text-input without default value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-name') as HTMLInputElement
expect(input.defaultValue).toBe('')
})
it('should prioritize lastRunInputData over default_value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'size', label: 'Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps({ lastRunInputData: { size: 999 } })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-size') as HTMLInputElement
expect(input.defaultValue).toBe('999')
})
})
// ==================== Edge Cases ====================
// Test boundary conditions and error handling
describe('Edge Cases', () => {
describe('Empty/Null data handling', () => {
it('should handle undefined paramsConfig.variables', () => {
// Arrange
mockParamsConfig.mockReturnValue({ variables: undefined })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - should render without fields
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument()
})
it('should handle null paramsConfig', () => {
// Arrange
mockParamsConfig.mockReturnValue(null)
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('process-form')).toBeInTheDocument()
})
it('should handle empty variables array', () => {
// Arrange
mockParamsConfig.mockReturnValue({ variables: [] })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument()
})
})
describe('Multiple variables', () => {
it('should handle multiple variables of different types', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'text_field', label: 'Text', type: PipelineInputVarType.textInput, default_value: 'hello' }),
createMockVariable({ variable: 'number_field', label: 'Number', type: PipelineInputVarType.number, default_value: '123' }),
createMockVariable({ variable: 'select_field', label: 'Select', type: PipelineInputVarType.select, default_value: 'option1' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - all fields should be rendered
expect(screen.getByTestId('field-text_field')).toBeInTheDocument()
expect(screen.getByTestId('field-number_field')).toBeInTheDocument()
expect(screen.getByTestId('field-select_field')).toBeInTheDocument()
})
it('should submit all variables data correctly', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'field1', label: 'Field 1', type: PipelineInputVarType.textInput, default_value: 'value1' }),
createMockVariable({ variable: 'field2', label: 'Field 2', type: PipelineInputVarType.number, default_value: '42' }),
]
mockParamsConfig.mockReturnValue({ variables })
const onSubmit = jest.fn()
const props = createDefaultProps({ onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.submit(screen.getByTestId('process-form'))
// Assert - default_value is string type, so values remain as strings
expect(onSubmit).toHaveBeenCalledWith({
field1: 'value1',
field2: '42',
})
})
})
describe('Variable with options (select type)', () => {
it('should handle select variable with options', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({
variable: 'mode',
label: 'Mode',
type: PipelineInputVarType.select,
options: ['auto', 'manual', 'custom'],
default_value: 'auto',
}),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('field-mode')).toBeInTheDocument()
const input = screen.getByTestId('input-mode') as HTMLInputElement
expect(input.defaultValue).toBe('auto')
})
})
})
// ==================== Integration Tests ====================
// Test Form and Actions components work together with real hooks
describe('Integration', () => {
it('should coordinate form submission flow correctly', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'setting', label: 'Setting', type: PipelineInputVarType.textInput, default_value: 'initial' }),
]
mockParamsConfig.mockReturnValue({ variables })
const onProcess = jest.fn()
const onSubmit = jest.fn()
const props = createDefaultProps({ onProcess, onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - form is rendered with correct initial data
const input = screen.getByTestId('input-setting') as HTMLInputElement
expect(input.defaultValue).toBe('initial')
// Act - click process button
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert - onProcess is called
expect(onProcess).toHaveBeenCalled()
})
it('should render complete UI with all interactive elements', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'test', label: 'Test Field', type: PipelineInputVarType.textInput }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - all UI elements are present
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.getByText('Test Field')).toBeInTheDocument()
expect(screen.getByTestId('preview-btn')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument()
})
})
})
@@ -0,0 +1,968 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import StatusItem from './index'
import type { DocumentDisplayStatus } from '@/models/datasets'
// Mock i18n - required for translation
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock ToastContext - required to verify notifications
const mockNotify = jest.fn()
jest.mock('use-context-selector', () => ({
...jest.requireActual('use-context-selector'),
useContext: () => ({ notify: mockNotify }),
}))
// Mock document service hooks - required to avoid real API calls
const mockEnableDocument = jest.fn()
const mockDisableDocument = jest.fn()
const mockDeleteDocument = jest.fn()
jest.mock('@/service/knowledge/use-document', () => ({
useDocumentEnable: () => ({ mutateAsync: mockEnableDocument }),
useDocumentDisable: () => ({ mutateAsync: mockDisableDocument }),
useDocumentDelete: () => ({ mutateAsync: mockDeleteDocument }),
}))
// Mock useDebounceFn to execute immediately for testing
jest.mock('ahooks', () => ({
...jest.requireActual('ahooks'),
useDebounceFn: (fn: (...args: unknown[]) => void) => ({ run: fn }),
}))
// Test utilities
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// Factory functions for test data
const createDetailProps = (overrides: Partial<{
enabled: boolean
archived: boolean
id: string
}> = {}) => ({
enabled: false,
archived: false,
id: 'doc-123',
...overrides,
})
describe('StatusItem', () => {
beforeEach(() => {
jest.clearAllMocks()
mockEnableDocument.mockResolvedValue({ result: 'success' })
mockDisableDocument.mockResolvedValue({ result: 'success' })
mockDeleteDocument.mockResolvedValue({ result: 'success' })
})
// ==================== Rendering Tests ====================
// Test basic rendering with different status values
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert - check indicator element exists (real Indicator component)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toBeInTheDocument()
})
it.each([
['queuing', 'bg-components-badge-status-light-warning-bg'],
['indexing', 'bg-components-badge-status-light-normal-bg'],
['paused', 'bg-components-badge-status-light-warning-bg'],
['error', 'bg-components-badge-status-light-error-bg'],
['available', 'bg-components-badge-status-light-success-bg'],
['enabled', 'bg-components-badge-status-light-success-bg'],
['disabled', 'bg-components-badge-status-light-disabled-bg'],
['archived', 'bg-components-badge-status-light-disabled-bg'],
] as const)('should render status "%s" with correct indicator background', (status, expectedBg) => {
// Arrange & Act
renderWithProviders(<StatusItem status={status} />)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass(expectedBg)
})
it('should render status text from translation', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert
expect(screen.getByText('datasetDocuments.list.status.available')).toBeInTheDocument()
})
it('should handle case-insensitive status', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status={'AVAILABLE' as DocumentDisplayStatus} />,
)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
})
})
// ==================== Props Testing ====================
// Test all prop variations and combinations
describe('Props', () => {
// reverse prop tests
describe('reverse prop', () => {
it('should apply default layout when reverse is false', () => {
// Arrange & Act
const { container } = renderWithProviders(<StatusItem status="available" reverse={false} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).not.toHaveClass('flex-row-reverse')
})
it('should apply reversed layout when reverse is true', () => {
// Arrange & Act
const { container } = renderWithProviders(<StatusItem status="available" reverse />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex-row-reverse')
})
it('should apply ml-2 to indicator when reversed', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" reverse />)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('ml-2')
})
it('should apply mr-2 to indicator when not reversed', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" reverse={false} />)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('mr-2')
})
})
// scene prop tests
describe('scene prop', () => {
it('should not render switch in list scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="list"
detail={createDetailProps()}
/>,
)
// Assert - Switch renders as a button element
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('should render switch in detail scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should default to list scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
detail={createDetailProps()}
/>,
)
// Assert
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
})
// textCls prop tests
describe('textCls prop', () => {
it('should apply custom text class', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status="available" textCls="custom-text-class" />,
)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.available')
expect(statusText).toHaveClass('custom-text-class')
})
it('should default to empty string', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.available')
expect(statusText).toHaveClass('text-sm')
})
})
// errorMessage prop tests
describe('errorMessage prop', () => {
it('should render tooltip trigger when errorMessage is provided', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status="error" errorMessage="Something went wrong" />,
)
// Assert - tooltip trigger element should exist
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
expect(tooltipTrigger).toBeInTheDocument()
})
it('should show error message on hover', async () => {
// Arrange
renderWithProviders(
<StatusItem status="error" errorMessage="Something went wrong" />,
)
// Act - hover the tooltip trigger
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
fireEvent.mouseEnter(tooltipTrigger)
// Assert - wait for tooltip content to appear
expect(await screen.findByText('Something went wrong')).toBeInTheDocument()
})
it('should not render tooltip trigger when errorMessage is not provided', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="error" />)
// Assert - tooltip trigger should not exist
const tooltipTrigger = screen.queryByTestId('error-tooltip-trigger')
expect(tooltipTrigger).not.toBeInTheDocument()
})
it('should not render tooltip trigger when errorMessage is empty', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="error" errorMessage="" />)
// Assert - tooltip trigger should not exist
const tooltipTrigger = screen.queryByTestId('error-tooltip-trigger')
expect(tooltipTrigger).not.toBeInTheDocument()
})
})
// detail prop tests
describe('detail prop', () => {
it('should use default values when detail is undefined', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status="available" scene="detail" />,
)
// Assert - switch should be unchecked (defaultValue = false when archived = false and enabled = false)
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
it('should use enabled value from detail', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ enabled: true })}
/>,
)
// Assert
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'true')
})
it('should set switch to false when archived regardless of enabled', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ enabled: true, archived: true })}
/>,
)
// Assert - archived overrides enabled, defaultValue becomes false
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
})
})
// ==================== Memoization Tests ====================
// Test useMemo logic for embedding status (disables switch)
describe('Memoization', () => {
it.each([
['queuing', true],
['indexing', true],
['paused', true],
['available', false],
['enabled', false],
['disabled', false],
['archived', false],
['error', false],
] as const)('should correctly identify embedding status for "%s" - disabled: %s', (status, isEmbedding) => {
// Arrange & Act
renderWithProviders(
<StatusItem
status={status}
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert - check if switch is visually disabled (via CSS classes)
// The Switch component uses CSS classes for disabled state, not the native disabled attribute
const switchEl = screen.getByRole('switch')
if (isEmbedding)
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
else
expect(switchEl).not.toHaveClass('!cursor-not-allowed')
})
it('should disable switch when archived', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ archived: true })}
/>,
)
// Assert - visually disabled via CSS classes
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
it('should disable switch when both embedding and archived', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="indexing"
scene="detail"
detail={createDetailProps({ archived: true })}
/>,
)
// Assert - visually disabled via CSS classes
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
})
// ==================== Switch Toggle Tests ====================
// Test Switch toggle interactions
describe('Switch Toggle', () => {
it('should call enable operation when switch is toggled on', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockEnableDocument).toHaveBeenCalledWith({
datasetId: 'dataset-123',
documentId: 'doc-123',
})
})
})
it('should call disable operation when switch is toggled off', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockDisableDocument).toHaveBeenCalledWith({
datasetId: 'dataset-123',
documentId: 'doc-123',
})
})
})
it('should not call any operation when archived', () => {
// Arrange
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ archived: true })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
expect(mockEnableDocument).not.toHaveBeenCalled()
expect(mockDisableDocument).not.toHaveBeenCalled()
})
it('should render switch as checked when enabled is true', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
/>,
)
// Assert - verify switch shows checked state
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'true')
})
it('should render switch as unchecked when enabled is false', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
/>,
)
// Assert - verify switch shows unchecked state
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
it('should skip enable operation when props.enabled is true (guard branch)', () => {
// Covers guard condition: if (operationName === 'enable' && enabled) return
// Note: The guard checks props.enabled, NOT the Switch's internal UI state.
// This prevents redundant API calls when the UI toggles back to a state
// that already matches the server-side data (props haven't been updated yet).
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
const switchEl = screen.getByRole('switch')
// First click: Switch UI toggles OFF, calls disable (props.enabled=true, so allowed)
fireEvent.click(switchEl)
// Second click: Switch UI toggles ON, tries to call enable
// BUT props.enabled is still true (not updated), so guard skips the API call
fireEvent.click(switchEl)
// Assert - disable was called once, enable was skipped because props.enabled=true
expect(mockDisableDocument).toHaveBeenCalledTimes(1)
expect(mockEnableDocument).not.toHaveBeenCalled()
})
it('should skip disable operation when props.enabled is false (guard branch)', () => {
// Covers guard condition: if (operationName === 'disable' && !enabled) return
// Note: The guard checks props.enabled, NOT the Switch's internal UI state.
// This prevents redundant API calls when the UI toggles back to a state
// that already matches the server-side data (props haven't been updated yet).
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
const switchEl = screen.getByRole('switch')
// First click: Switch UI toggles ON, calls enable (props.enabled=false, so allowed)
fireEvent.click(switchEl)
// Second click: Switch UI toggles OFF, tries to call disable
// BUT props.enabled is still false (not updated), so guard skips the API call
fireEvent.click(switchEl)
// Assert - enable was called once, disable was skipped because props.enabled=false
expect(mockEnableDocument).toHaveBeenCalledTimes(1)
expect(mockDisableDocument).not.toHaveBeenCalled()
})
})
// ==================== onUpdate Callback Tests ====================
// Test onUpdate callback behavior
describe('onUpdate Callback', () => {
it('should call onUpdate with operation name on successful enable', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockOnUpdate).toHaveBeenCalledWith('enable')
})
})
it('should call onUpdate with operation name on successful disable', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockOnUpdate).toHaveBeenCalledWith('disable')
})
})
it('should not call onUpdate when operation fails', async () => {
// Arrange
mockEnableDocument.mockRejectedValue(new Error('API Error'))
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.actionMsg.modifiedUnsuccessfully',
})
})
expect(mockOnUpdate).not.toHaveBeenCalled()
})
it('should not throw when onUpdate is not provided', () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
// Assert - should not throw
expect(() => fireEvent.click(switchEl)).not.toThrow()
})
})
// ==================== API Calls ====================
// Test API operations and toast notifications
describe('API Operations', () => {
it('should show success toast on successful operation', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'common.actionMsg.modifiedSuccessfully',
})
})
})
it('should show error toast on failed operation', async () => {
// Arrange
mockDisableDocument.mockRejectedValue(new Error('Network error'))
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.actionMsg.modifiedUnsuccessfully',
})
})
})
it('should pass correct parameters to enable API', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false, id: 'test-doc-id' })}
datasetId="test-dataset-id"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockEnableDocument).toHaveBeenCalledWith({
datasetId: 'test-dataset-id',
documentId: 'test-doc-id',
})
})
})
it('should pass correct parameters to disable API', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true, id: 'test-doc-456' })}
datasetId="test-dataset-456"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockDisableDocument).toHaveBeenCalledWith({
datasetId: 'test-dataset-456',
documentId: 'test-doc-456',
})
})
})
})
// ==================== Edge Cases ====================
// Test boundary conditions and unusual inputs
describe('Edge Cases', () => {
it('should handle empty datasetId', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert - should render without errors
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should handle undefined detail gracefully', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={undefined}
/>,
)
// Assert
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
it('should handle empty string id in detail', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false, id: '' })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockEnableDocument).toHaveBeenCalledWith({
datasetId: 'dataset-123',
documentId: '',
})
})
})
it('should handle very long error messages', async () => {
// Arrange
const longErrorMessage = 'A'.repeat(500)
renderWithProviders(
<StatusItem status="error" errorMessage={longErrorMessage} />,
)
// Act - hover to show tooltip
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
fireEvent.mouseEnter(tooltipTrigger)
// Assert
await waitFor(() => {
expect(screen.getByText(longErrorMessage)).toBeInTheDocument()
})
})
it('should handle special characters in error message', async () => {
// Arrange
const specialChars = '<script>alert("xss")</script> & < > " \''
renderWithProviders(
<StatusItem status="error" errorMessage={specialChars} />,
)
// Act - hover to show tooltip
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
fireEvent.mouseEnter(tooltipTrigger)
// Assert
await waitFor(() => {
expect(screen.getByText(specialChars)).toBeInTheDocument()
})
})
it('should handle all status types in sequence', () => {
// Arrange
const statuses: DocumentDisplayStatus[] = [
'queuing', 'indexing', 'paused', 'error',
'available', 'enabled', 'disabled', 'archived',
]
// Act & Assert
statuses.forEach((status) => {
const { unmount } = renderWithProviders(<StatusItem status={status} />)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toBeInTheDocument()
unmount()
})
})
})
// ==================== Component Memoization ====================
// Test React.memo behavior
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(StatusItem).toHaveProperty('$$typeof', Symbol.for('react.memo'))
})
it('should render correctly with same props', () => {
// Arrange
const props = {
status: 'available' as const,
scene: 'detail' as const,
detail: createDetailProps(),
}
// Act
const { rerender } = renderWithProviders(<StatusItem {...props} />)
rerender(
<QueryClientProvider client={createQueryClient()}>
<StatusItem {...props} />
</QueryClientProvider>,
)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toBeInTheDocument()
})
it('should update when status prop changes', () => {
// Arrange
const { rerender } = renderWithProviders(<StatusItem status="available" />)
// Assert initial - green/success background
let indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
// Act
rerender(
<QueryClientProvider client={createQueryClient()}>
<StatusItem status="error" />
</QueryClientProvider>,
)
// Assert updated - red/error background
indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-error-bg')
})
})
// ==================== Styling Tests ====================
// Test CSS classes and styling
describe('Styling', () => {
it('should apply correct status text color for green status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.available')
expect(statusText).toHaveClass('text-util-colors-green-green-600')
})
it('should apply correct status text color for red status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="error" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.error')
expect(statusText).toHaveClass('text-util-colors-red-red-600')
})
it('should apply correct status text color for orange status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="queuing" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.queuing')
expect(statusText).toHaveClass('text-util-colors-warning-warning-600')
})
it('should apply correct status text color for blue status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="indexing" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.indexing')
expect(statusText).toHaveClass('text-util-colors-blue-light-blue-light-600')
})
it('should apply correct status text color for gray status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="disabled" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.disabled')
expect(statusText).toHaveClass('text-text-tertiary')
})
it('should render switch with md size in detail scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert - check switch has the md size class (h-4 w-7)
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveClass('h-4', 'w-7')
})
})
})
@@ -105,6 +105,7 @@ const StatusItem = ({
<div className='max-w-[260px] break-all'>{errorMessage}</div>
}
triggerClassName='ml-1 w-4 h-4'
triggerTestId='error-tooltip-trigger'
/>
)
}
@@ -47,6 +47,7 @@ export default function Indicator({
}: IndicatorProps) {
return (
<div
data-testid="status-indicator"
className={classNames(
'h-2 w-2 rounded-[3px] border border-solid',
BACKGROUND_MAP[color],
@@ -61,7 +61,8 @@ export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManife
}
export const parseGitHubUrl = (url: string): GitHubUrlInfo => {
const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/)
const githubUrlRegex = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/
const match = githubUrlRegex.exec(url)
return match ? { isValid: true, owner: match[1], repo: match[2] } : { isValid: false }
}
@@ -0,0 +1,116 @@
import {
memo,
useState,
} from 'react'
import {
RiAddLine,
RiKey2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import AddOAuthButton from './authorize/add-oauth-button'
import AddApiKeyButton from './authorize/add-api-key-button'
import type { PluginPayload } from './types'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
export type CredentialConfigHeaderProps = {
pluginPayload: PluginPayload
canOAuth?: boolean
canApiKey?: boolean
hasOAuthClientConfigured?: boolean
disabled?: boolean
onCredentialAdded?: () => void
onAddMenuOpenChange?: (open: boolean) => void
}
const CredentialConfigHeader = ({
pluginPayload,
canOAuth,
canApiKey,
hasOAuthClientConfigured,
disabled,
onCredentialAdded,
onAddMenuOpenChange,
}: CredentialConfigHeaderProps) => {
const { t } = useTranslation()
const [showAddMenu, setShowAddMenu] = useState(false)
const handleAddMenuOpenChange = (open: boolean) => {
setShowAddMenu(open)
onAddMenuOpenChange?.(open)
}
const addButtonDisabled = disabled || (!canOAuth && !canApiKey && !hasOAuthClientConfigured)
return (
<div className='flex items-start justify-between gap-2'>
<div className='flex items-start gap-2'>
<RiKey2Line className='mt-0.5 h-4 w-4 text-text-tertiary' />
<div className='space-y-0.5'>
<div className='system-md-semibold text-text-primary'>
{t('plugin.auth.configuredCredentials.title')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.auth.configuredCredentials.desc')}
</div>
</div>
</div>
<PortalToFollowElem
open={showAddMenu}
onOpenChange={handleAddMenuOpenChange}
placement='bottom-end'
offset={6}
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full bg-primary-600 text-white hover:bg-primary-700',
addButtonDisabled && 'pointer-events-none opacity-50',
)}
onClick={() => handleAddMenuOpenChange(!showAddMenu)}
>
<RiAddLine className='h-5 w-5' />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[120]'>
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='flex flex-col gap-1 p-1'>
{canOAuth && (
<AddOAuthButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
buttonText={t('plugin.auth.addOAuth')}
disabled={disabled}
onUpdate={() => {
setShowAddMenu(false)
onCredentialAdded?.()
}}
/>
)}
{canApiKey && (
<AddApiKeyButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
buttonText={t('plugin.auth.addApi')}
disabled={disabled}
onUpdate={() => {
setShowAddMenu(false)
onCredentialAdded?.()
}}
/>
)}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
export default memo(CredentialConfigHeader)
@@ -0,0 +1,176 @@
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import type { ReactNode } from 'react'
import {
RiArrowDownSLine,
RiEqualizer2Line,
RiKey2Line,
RiUserStarLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import AddOAuthButton from './authorize/add-oauth-button'
import AddApiKeyButton from './authorize/add-api-key-button'
import type { PluginPayload } from './types'
import cn from '@/utils/classnames'
import Switch from '@/app/components/base/switch'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
export type EndUserCredentialSectionProps = {
pluginPayload: PluginPayload
canOAuth?: boolean
canApiKey?: boolean
disabled?: boolean
useEndUserCredentialEnabled?: boolean
endUserCredentialType?: string
onEndUserCredentialChange?: (enabled: boolean) => void
onEndUserCredentialTypeChange?: (type: string) => void
onCredentialAdded?: () => void
className?: string
}
const EndUserCredentialSection = ({
pluginPayload,
canOAuth,
canApiKey,
disabled,
useEndUserCredentialEnabled,
endUserCredentialType,
onEndUserCredentialChange,
onEndUserCredentialTypeChange,
onCredentialAdded,
className,
}: EndUserCredentialSectionProps) => {
const { t } = useTranslation()
const [showEndUserTypeMenu, setShowEndUserTypeMenu] = useState(false)
const availableEndUserTypes = useMemo(() => {
const list: { value: string; label: string; icon: ReactNode }[] = []
if (canOAuth) {
list.push({
value: 'oauth2',
label: t('plugin.auth.endUserCredentials.optionOAuth'),
icon: <RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />,
})
}
if (canApiKey) {
list.push({
value: 'api-key',
label: t('plugin.auth.endUserCredentials.optionApiKey'),
icon: <RiKey2Line className='h-4 w-4 text-text-tertiary' />,
})
}
return list
}, [canOAuth, canApiKey, t])
const endUserCredentialLabel = useMemo(() => {
const found = availableEndUserTypes.find(item => item.value === endUserCredentialType)
return found?.label || availableEndUserTypes[0]?.label || '-'
}, [availableEndUserTypes, endUserCredentialType])
useEffect(() => {
if (!useEndUserCredentialEnabled)
return
if (!availableEndUserTypes.length)
return
const isValid = availableEndUserTypes.some(item => item.value === endUserCredentialType)
if (!isValid)
onEndUserCredentialTypeChange?.(availableEndUserTypes[0].value)
}, [useEndUserCredentialEnabled, endUserCredentialType, availableEndUserTypes, onEndUserCredentialTypeChange])
const handleSelectEndUserType = useCallback((value: string) => {
onEndUserCredentialTypeChange?.(value)
setShowEndUserTypeMenu(false)
}, [onEndUserCredentialTypeChange])
return (
<div className={cn('flex items-start gap-3', className)}>
<RiUserStarLine className='mt-0.5 h-4 w-4 shrink-0 text-text-tertiary' />
<div className='flex-1 space-y-3'>
<div className='flex items-center justify-between gap-3'>
<div className='space-y-1'>
<div className='system-sm-semibold text-text-primary'>
{t('plugin.auth.endUserCredentials.title')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.auth.endUserCredentials.desc')}
</div>
</div>
<Switch
size='md'
defaultValue={!!useEndUserCredentialEnabled}
onChange={onEndUserCredentialChange}
disabled={disabled}
/>
</div>
{
useEndUserCredentialEnabled && availableEndUserTypes.length > 0 && (
<div className='flex items-center justify-between gap-3'>
<div className='system-sm-semibold text-text-primary'>
{t('plugin.auth.endUserCredentials.typeLabel')}
</div>
<PortalToFollowElem
open={showEndUserTypeMenu}
onOpenChange={setShowEndUserTypeMenu}
placement='bottom-end'
offset={6}
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className='border-components-input-border flex h-9 min-w-[190px] items-center justify-between rounded-lg border bg-components-input-bg-normal px-3 text-left text-text-primary shadow-xs hover:bg-components-input-bg-hover'
onClick={() => setShowEndUserTypeMenu(v => !v)}
>
<span className='system-sm-semibold'>{endUserCredentialLabel}</span>
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[120]'>
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='flex flex-col gap-1 p-1'>
{canOAuth && (
<AddOAuthButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
buttonText={t('plugin.auth.addOAuth')}
disabled={disabled}
onUpdate={() => {
handleSelectEndUserType('oauth2')
onCredentialAdded?.()
}}
/>
)}
{canApiKey && (
<AddApiKeyButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
buttonText={t('plugin.auth.addApi')}
disabled={disabled}
onUpdate={() => {
handleSelectEndUserType('api-key')
onCredentialAdded?.()
}}
/>
)}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
</div>
</div>
)
}
export default memo(EndUserCredentialSection)
@@ -13,6 +13,7 @@ export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) =>
const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2)
const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY)
const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
const hasOAuthClientConfigured = data?.is_oauth_custom_client_enabled
return {
isAuthorized,
@@ -22,5 +23,6 @@ export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) =>
disabled: !isCurrentWorkspaceManager,
notAllowCustomCredential: data?.allow_custom_token === false,
invalidPluginCredentialInfo,
hasOAuthClientConfigured: !!hasOAuthClientConfigured,
}
}
@@ -2,6 +2,10 @@ export { default as PluginAuth } from './plugin-auth'
export { default as Authorized } from './authorized'
export { default as AuthorizedInNode } from './authorized-in-node'
export { default as PluginAuthInAgent } from './plugin-auth-in-agent'
export { default as CredentialConfigHeader } from './credential-config-header'
export type { CredentialConfigHeaderProps } from './credential-config-header'
export { default as EndUserCredentialSection } from './end-user-credential-section'
export type { EndUserCredentialSectionProps } from './end-user-credential-section'
export { usePluginAuth } from './hooks/use-plugin-auth'
export { default as PluginAuthInDataSourceNode } from './plugin-auth-in-datasource-node'
export { default as AuthorizedInDataSourceNode } from './authorized-in-data-source-node'
@@ -3,10 +3,14 @@ import {
useCallback,
useState,
} from 'react'
import { RiArrowDownSLine } from '@remixicon/react'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Authorize from './authorize'
import Authorized from './authorized'
import CredentialConfigHeader from './credential-config-header'
import EndUserCredentialSection from './end-user-credential-section'
import type {
Credential,
PluginPayload,
@@ -20,11 +24,19 @@ type PluginAuthInAgentProps = {
pluginPayload: PluginPayload
credentialId?: string
onAuthorizationItemClick?: (id: string) => void
useEndUserCredentialEnabled?: boolean
endUserCredentialType?: string
onEndUserCredentialChange?: (enabled: boolean) => void
onEndUserCredentialTypeChange?: (type: string) => void
}
const PluginAuthInAgent = ({
pluginPayload,
credentialId,
onAuthorizationItemClick,
useEndUserCredentialEnabled,
endUserCredentialType,
onEndUserCredentialChange,
onEndUserCredentialTypeChange,
}: PluginAuthInAgentProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
@@ -36,8 +48,11 @@ const PluginAuthInAgent = ({
disabled,
invalidPluginCredentialInfo,
notAllowCustomCredential,
hasOAuthClientConfigured,
} = usePluginAuth(pluginPayload, true)
const configuredDisabled = !!useEndUserCredentialEnabled
const extraAuthorizationItems: Credential[] = [
{
id: '__workspace_default__',
@@ -94,42 +109,87 @@ const PluginAuthInAgent = ({
)
}, [credentialId, credentials, t])
const shouldShowAuthorizeCard = !credentials.length && (canOAuth || canApiKey || hasOAuthClientConfigured)
return (
<>
{
!isAuthorized && (
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
/>
)
}
{
isAuthorized && (
<Authorized
pluginPayload={pluginPayload}
credentials={credentials}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
disableSetDefault
onItemClick={handleAuthorizationItemClick}
extraAuthorizationItems={extraAuthorizationItems}
showItemSelectedIcon
renderTrigger={renderTrigger}
isOpen={isOpen}
onOpenChange={setIsOpen}
selectedCredentialId={credentialId || '__workspace_default__'}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
/>
)
}
</>
<div className='border-components-panel-border bg-components-panel-bg'>
<div className={cn(configuredDisabled && 'pointer-events-none opacity-50')}>
<CredentialConfigHeader
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
hasOAuthClientConfigured={hasOAuthClientConfigured}
disabled={disabled || configuredDisabled}
onCredentialAdded={invalidPluginCredentialInfo}
/>
</div>
<div className={cn(configuredDisabled && 'pointer-events-none opacity-50')}>
{
!isAuthorized && shouldShowAuthorizeCard && (
<div className='rounded-xl bg-background-section px-4 py-4'>
<div className='flex w-full justify-center'>
<div className='w-full max-w-[520px]'>
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled || configuredDisabled}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
theme='secondary'
showDivider={!!(canOAuth && canApiKey)}
/>
</div>
</div>
</div>
)
}
{
!isAuthorized && !shouldShowAuthorizeCard && (
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled || configuredDisabled}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
/>
)
}
{
isAuthorized && (
<Authorized
pluginPayload={pluginPayload}
credentials={credentials}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled || configuredDisabled}
disableSetDefault
onItemClick={handleAuthorizationItemClick}
extraAuthorizationItems={extraAuthorizationItems}
showItemSelectedIcon
renderTrigger={renderTrigger}
isOpen={isOpen}
onOpenChange={setIsOpen}
selectedCredentialId={credentialId || '__workspace_default__'}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
/>
)
}
</div>
<EndUserCredentialSection
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
useEndUserCredentialEnabled={useEndUserCredentialEnabled}
endUserCredentialType={endUserCredentialType}
onEndUserCredentialChange={onEndUserCredentialChange}
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
onCredentialAdded={invalidPluginCredentialInfo}
/>
</div>
)
}
@@ -1,6 +1,26 @@
import { memo } from 'react'
import {
memo,
useEffect,
useMemo,
useState,
} from 'react'
import {
RiAddLine,
RiArrowDownSLine,
RiKey2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Authorize from './authorize'
import Authorized from './authorized'
import AddApiKeyButton from './authorize/add-api-key-button'
import AddOAuthButton from './authorize/add-oauth-button'
import EndUserCredentialSection from './end-user-credential-section'
import Item from './authorized/item'
import type { PluginPayload } from './types'
import { usePluginAuth } from './hooks/use-plugin-auth'
import cn from '@/utils/classnames'
@@ -9,12 +29,23 @@ type PluginAuthProps = {
pluginPayload: PluginPayload
children?: React.ReactNode
className?: string
showConnectGuide?: boolean
endUserCredentialEnabled?: boolean
endUserCredentialType?: string
onEndUserCredentialTypeChange?: (type: string) => void
onEndUserCredentialChange?: (enabled: boolean) => void
}
const PluginAuth = ({
pluginPayload,
children,
className,
showConnectGuide,
endUserCredentialEnabled,
endUserCredentialType,
onEndUserCredentialTypeChange,
onEndUserCredentialChange,
}: PluginAuthProps) => {
const { t } = useTranslation()
const {
isAuthorized,
canOAuth,
@@ -23,12 +54,205 @@ const PluginAuth = ({
disabled,
invalidPluginCredentialInfo,
notAllowCustomCredential,
hasOAuthClientConfigured,
} = usePluginAuth(pluginPayload, !!pluginPayload.provider)
const shouldShowGuide = !!showConnectGuide
const [showCredentialPanel, setShowCredentialPanel] = useState(false)
const [showAddMenu, setShowAddMenu] = useState(false)
const configuredDisabled = !!endUserCredentialEnabled
const shouldShowAuthorizeCard = useMemo(() => {
const hasCredential = credentials.length > 0
const canAdd = canOAuth || canApiKey || hasOAuthClientConfigured
return !hasCredential && canAdd
}, [credentials.length, canOAuth, canApiKey, hasOAuthClientConfigured])
const containerClassName = useMemo(() => {
if (showConnectGuide)
return className
return !isAuthorized ? className : undefined
}, [isAuthorized, className, showConnectGuide])
useEffect(() => {
if (isAuthorized)
setShowCredentialPanel(false)
}, [isAuthorized])
const credentialList = useMemo(() => {
return (
<div className={cn(!credentials.length ? 'mt-0' : 'mt-3')}>
{
credentials.length > 0
? (
<div className='space-y-1'>
{credentials.map(credential => (
<Item
key={credential.id}
credential={credential}
disabled
disableRename
disableEdit
disableDelete
disableSetDefault
/>
))}
</div>
)
: null
}
</div>
)
}, [credentials, t])
const endUserSwitch = (
<EndUserCredentialSection
className='px-4 py-3'
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
useEndUserCredentialEnabled={endUserCredentialEnabled}
endUserCredentialType={endUserCredentialType}
onEndUserCredentialChange={onEndUserCredentialChange}
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
onCredentialAdded={invalidPluginCredentialInfo}
/>
)
return (
<div className={cn(!isAuthorized && className)}>
<div className={cn(containerClassName)}>
{
!isAuthorized && (
shouldShowGuide && (
<PortalToFollowElem
open={showCredentialPanel}
onOpenChange={setShowCredentialPanel}
placement='bottom-start'
offset={8}
triggerPopupSameWidth
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className='flex w-full items-center justify-center gap-2 rounded-xl bg-primary-600 px-4 py-3 text-left text-white shadow-xs hover:bg-primary-700'
onClick={() => setShowCredentialPanel(v => !v)}
>
<div className='system-sm-semibold text-white'>
{t('plugin.auth.connectCredentials')}
</div>
<RiArrowDownSLine
className={cn(
'h-4 w-4 text-white transition-transform',
showCredentialPanel && 'rotate-180',
)}
/>
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[100]'>
<div className='w-[420px] max-w-[calc(100vw-48px)] rounded-2xl border border-divider-subtle bg-components-panel-bg shadow-lg'>
<div className='border-b border-divider-subtle px-3 py-3'>
<div className='flex items-start justify-between gap-2'>
<div className='flex items-start gap-2'>
<RiKey2Line className='mt-0.5 h-4 w-4 text-text-tertiary' />
<div className='space-y-0.5'>
<div className='system-md-semibold text-text-primary'>
{t('plugin.auth.configuredCredentials.title')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.auth.configuredCredentials.desc')}
</div>
</div>
</div>
<PortalToFollowElem
open={showAddMenu}
onOpenChange={setShowAddMenu}
placement='bottom-end'
offset={6}
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full bg-primary-600 text-white hover:bg-primary-700',
configuredDisabled && 'pointer-events-none opacity-50',
)}
onClick={() => {
setShowCredentialPanel(true)
setShowAddMenu(v => !v)
}}
>
<RiAddLine className='h-5 w-5' />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[120]'>
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='flex flex-col gap-1 p-1'>
{
canOAuth && (
<AddOAuthButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
buttonText={t('plugin.auth.addOAuth')}
disabled={disabled}
onUpdate={() => {
setShowAddMenu(false)
invalidPluginCredentialInfo()
}}
/>
)
}
{
canApiKey && (
<AddApiKeyButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
buttonText={t('plugin.auth.addApi')}
disabled={disabled}
onUpdate={() => {
setShowAddMenu(false)
invalidPluginCredentialInfo()
}}
/>
)
}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
<div className={cn(configuredDisabled && 'pointer-events-none opacity-50')}>
{credentialList}
</div>
{
shouldShowAuthorizeCard && (
<div className={cn(
'mt-4 flex items-start gap-1.5 rounded-xl bg-background-section px-4 py-8',
configuredDisabled && 'pointer-events-none opacity-50',
)}>
<div className='flex w-full justify-center'>
<div className='w-full max-w-[520px]'>
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled || configuredDisabled}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
theme='secondary'
showDivider={!!(canOAuth && canApiKey)}
/>
</div>
</div>
</div>
)
}
</div>
{endUserSwitch}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
{
!shouldShowGuide && !isAuthorized && (
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
@@ -210,6 +210,19 @@ const ToolSelector: FC<Props> = ({
credential_id: id,
} as any)
}
const handleEndUserCredentialChange = (enabled: boolean) => {
onSelect({
...value,
use_end_user_credentials: enabled,
auth_type: enabled ? 'end_user' : 'workspace',
} as any)
}
const handleEndUserCredentialTypeChange = (type: string) => {
onSelect({
...value,
end_user_credential_type: type,
} as any)
}
return (
<>
@@ -323,6 +336,10 @@ const ToolSelector: FC<Props> = ({
}}
credentialId={value?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
useEndUserCredentialEnabled={value?.use_end_user_credentials}
endUserCredentialType={value?.end_user_credential_type}
onEndUserCredentialChange={handleEndUserCredentialChange}
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
/>
</div>
</>
@@ -0,0 +1,49 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import CSVDownload from './index'
const mockType = { Link: 'mock-link' }
let capturedProps: Record<string, unknown> | undefined
jest.mock('react-papaparse', () => ({
useCSVDownloader: () => {
const CSVDownloader = ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
capturedProps = props
return <div data-testid="csv-downloader" className={props.className as string}>{children}</div>
}
return {
CSVDownloader,
Type: mockType,
}
},
}))
describe('CSVDownload', () => {
const vars = [{ name: 'prompt' }, { name: 'context' }]
beforeEach(() => {
capturedProps = undefined
jest.clearAllMocks()
})
test('should render table headers and sample row for each variable', () => {
render(<CSVDownload vars={vars} />)
expect(screen.getByText('share.generation.csvStructureTitle')).toBeInTheDocument()
expect(screen.getAllByRole('row')[0].children).toHaveLength(2)
expect(screen.getByText('prompt share.generation.field')).toBeInTheDocument()
expect(screen.getByText('context share.generation.field')).toBeInTheDocument()
})
test('should configure CSV downloader with template data', () => {
render(<CSVDownload vars={vars} />)
expect(capturedProps?.filename).toBe('template')
expect(capturedProps?.type).toBe(mockType.Link)
expect(capturedProps?.bom).toBe(true)
expect(capturedProps?.data).toEqual([
{ prompt: '', context: '' },
])
expect(screen.getByText('share.generation.downloadTemplate')).toBeInTheDocument()
})
})
@@ -0,0 +1,70 @@
import React from 'react'
import { act, render, screen, waitFor } from '@testing-library/react'
import CSVReader from './index'
let mockAcceptedFile: { name: string } | null = null
let capturedHandlers: Record<string, (payload: any) => void> = {}
jest.mock('react-papaparse', () => ({
useCSVReader: () => ({
CSVReader: ({ children, ...handlers }: any) => {
capturedHandlers = handlers
return (
<div data-testid="csv-reader-wrapper">
{children({
getRootProps: () => ({ 'data-testid': 'drop-zone' }),
acceptedFile: mockAcceptedFile,
})}
</div>
)
},
}),
}))
describe('CSVReader', () => {
beforeEach(() => {
mockAcceptedFile = null
capturedHandlers = {}
jest.clearAllMocks()
})
test('should display upload instructions when no file selected', async () => {
const onParsed = jest.fn()
render(<CSVReader onParsed={onParsed} />)
expect(screen.getByText('share.generation.csvUploadTitle')).toBeInTheDocument()
expect(screen.getByText('share.generation.browse')).toBeInTheDocument()
await act(async () => {
capturedHandlers.onUploadAccepted?.({ data: [['row1']] })
})
expect(onParsed).toHaveBeenCalledWith([['row1']])
})
test('should show accepted file name without extension', () => {
mockAcceptedFile = { name: 'batch.csv' }
render(<CSVReader onParsed={jest.fn()} />)
expect(screen.getByText('batch')).toBeInTheDocument()
expect(screen.getByText('.csv')).toBeInTheDocument()
})
test('should toggle hover styling on drag events', async () => {
render(<CSVReader onParsed={jest.fn()} />)
const dragEvent = { preventDefault: jest.fn() } as unknown as DragEvent
await act(async () => {
capturedHandlers.onDragOver?.(dragEvent)
})
await waitFor(() => {
expect(screen.getByTestId('drop-zone')).toHaveClass('border-components-dropzone-border-accent')
})
await act(async () => {
capturedHandlers.onDragLeave?.(dragEvent)
})
await waitFor(() => {
expect(screen.getByTestId('drop-zone')).not.toHaveClass('border-components-dropzone-border-accent')
})
})
})
@@ -0,0 +1,88 @@
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import RunBatch from './index'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
jest.mock('@/hooks/use-breakpoints', () => {
const actual = jest.requireActual('@/hooks/use-breakpoints')
return {
__esModule: true,
default: jest.fn(),
MediaType: actual.MediaType,
}
})
let latestOnParsed: ((data: string[][]) => void) | undefined
let receivedCSVDownloadProps: Record<string, unknown> | undefined
jest.mock('./csv-reader', () => (props: { onParsed: (data: string[][]) => void }) => {
latestOnParsed = props.onParsed
return <div data-testid="csv-reader" />
})
jest.mock('./csv-download', () => (props: { vars: { name: string }[] }) => {
receivedCSVDownloadProps = props
return <div data-testid="csv-download" />
})
const mockUseBreakpoints = useBreakpoints as jest.Mock
describe('RunBatch', () => {
const vars = [{ name: 'prompt' }]
beforeEach(() => {
mockUseBreakpoints.mockReturnValue(MediaType.pc)
latestOnParsed = undefined
receivedCSVDownloadProps = undefined
jest.clearAllMocks()
})
test('should enable run button after CSV parsed and send data', async () => {
const onSend = jest.fn()
render(
<RunBatch
vars={vars}
onSend={onSend}
isAllFinished
/>,
)
expect(receivedCSVDownloadProps?.vars).toEqual(vars)
await act(async () => {
latestOnParsed?.([['row1']])
})
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
await waitFor(() => {
expect(runButton).not.toBeDisabled()
})
fireEvent.click(runButton)
expect(onSend).toHaveBeenCalledWith([['row1']])
})
test('should keep button disabled and show spinner when results still running on mobile', async () => {
mockUseBreakpoints.mockReturnValue(MediaType.mobile)
const onSend = jest.fn()
const { container } = render(
<RunBatch
vars={vars}
onSend={onSend}
isAllFinished={false}
/>,
)
await act(async () => {
latestOnParsed?.([['row']])
})
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
await waitFor(() => {
expect(runButton).toBeDisabled()
})
expect(runButton).toHaveClass('grow')
const icon = container.querySelector('svg')
expect(icon).toHaveClass('animate-spin')
expect(onSend).not.toHaveBeenCalled()
})
})

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