Compare commits

...

154 Commits

Author SHA1 Message Date
Piyush Gupta
52fe7219d0 fix: unit tests 2025-06-09 17:11:10 +05:30
Piyush Gupta
3c263ddd45 refactor: improve action class comparison logic in copySurveyToOtherEnvironment function 2025-06-09 17:05:13 +05:30
Dhruwang
2851a6904a fix 2025-06-09 15:51:07 +05:30
Dhruwang
2b6a29e57f Merge branch 'main' of https://github.com/formbricks/formbricks into survey-copy-issue 2025-06-09 15:31:44 +05:30
Piyush Jain
eb4b2dde05 chore(elasticache): add serverless redis (#5943) 2025-06-09 07:01:51 +00:00
Piyush Gupta
1b48dee378 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-copy-issue 2025-06-06 17:49:47 +05:30
victorvhs017
f2dae67813 chore: updated docs (#5940) 2025-06-06 11:54:24 +00:00
Dhruwang
0b392c205f removed console log 2025-06-06 13:54:37 +05:30
Dhruwang
0a10f983b6 fix UX 2025-06-06 13:53:29 +05:30
DivyanshuLohani
3ffc9bd290 fix: iframe url not being automatically populated (#5892)
Co-authored-by: Divyanshu Lohani <DivyanshuLohani@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-06-06 09:12:59 +02:00
Dhruwang
8dd9b98c94 fix: test 2025-06-06 11:02:16 +05:30
Dhruwang
3d0ed345b7 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-copy-issue 2025-06-06 09:45:34 +05:30
victorvhs017
a9946737df feat: audit logs (#5866)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-06-05 19:31:39 +00:00
Archit Sehgal
ece3d508a2 fix: back button in survey published modal closes modal instead of navigating (#5831)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-06-05 20:45:47 +02:00
Piyush Gupta
0d1d227e6a feat: add security headers for Referrer-Policy and Permissions-Policy (#5877) 2025-06-05 04:21:10 +00:00
Matti Nannt
c0b8edfdf2 chore: Comprehensive Cache Optimization & Performance Enhancement (#5926)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-04 20:33:17 +02:00
Dhruwang Jariwala
45fec0e184 fix: language tweaks (#5933) 2025-06-04 11:27:19 +00:00
Dhruwang Jariwala
2c2ba919c6 fix: backspacing headline causing infinite loop (#5891)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-06-04 10:26:15 +00:00
Piyush Jain
6d8adc6168 chore: switch curl images to quay.io (#5916) 2025-05-31 11:19:22 +00:00
Anshuman Pandey
ec208960e8 fix: surveys package resize observer issue (#5907)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-29 19:00:28 +00:00
Piyush Gupta
b9505158b4 fix: ciphers issue for fb staging (#5908) 2025-05-29 14:39:20 +00:00
abhishek
ad0c3421f0 fix: alignment issue in file upload (#5828) 2025-05-29 16:40:18 +02:00
Matti Nannt
916c00344b chore: clean up public directory and update cache headers (#5904)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-29 10:46:41 +00:00
Jakob Schott
459cdee17e chore: tweak language select dropdown width (#5878) 2025-05-29 03:54:51 +00:00
Harsh Bhat
bb26a64dbb docs: follow up update (#5601)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-29 03:24:58 +00:00
Harsh Bhat
29a3fa532a docs: RTL support in multi-lang docs (#5898)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-29 03:02:52 +00:00
Harsh Bhat
738b8f9012 docs: android sdk (#5889) 2025-05-29 02:47:26 +00:00
Matti Nannt
c95272288e fix: caching issue in newest next version (#5902) 2025-05-28 21:44:39 +02:00
Piyush Gupta
919febd166 fix: resend verification email translation (#5881) 2025-05-28 09:51:55 +00:00
Dhruwang Jariwala
10ccc20b53 fix: recall not working for NPS question (#5895) 2025-05-28 09:44:55 +00:00
Dhruwang Jariwala
d9ca64da54 fix: favicon warning (#5874)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-28 08:09:51 +00:00
Anshuman Pandey
ce00ec97d1 fix: js-core trackAction bugs (#5843)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-27 17:14:21 +00:00
Matti Nannt
2b9cd37c6c chore: enable rate limiting by default in helm chart (#5879) 2025-05-27 14:36:39 +02:00
Piyush Gupta
f8f14eb6f3 fix: weak cipher suite usage (#5873) 2025-05-27 12:09:16 +00:00
Matti Nannt
645fc863aa fix: performance issues on survey summary (#5885)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-27 12:07:31 +00:00
Anshuman Pandey
c53f030b24 fix: multiple close function calls because of timeouts (#5886) 2025-05-27 07:20:35 +00:00
devin-ai-integration[bot]
45d74f9ba0 fix: Update JS SDK log messages for clarity (#5819)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matti.sh>
2025-05-26 09:57:37 +00:00
Piyush Gupta
87870919ca fix: issues in the email change feature (#5868) 2025-05-24 12:04:58 +00:00
Piyush Gupta
ce2fdde474 fix: rtl issue in open text placeholder (#5855) 2025-05-23 11:47:38 +00:00
Harsh Bhat
6e2f30c6ed chore: add no index for survey pages (#5859) 2025-05-23 05:44:22 +00:00
Jakob Schott
5c8040008a fix: 602 modal height on small screens (#5863) 2025-05-23 05:34:43 +00:00
Harsh Bhat
639e25d679 chore: canonical seo issue (#5852) 2025-05-21 13:38:41 +00:00
Piyush Gupta
f7e5ef96d2 feat: added email change feature (#5837)
Co-authored-by: Paribesh01 <nepalparibesh01@gmail.com>
Co-authored-by: Paribesh Nepal <100255987+Paribesh01@users.noreply.github.com>
2025-05-21 11:23:12 +00:00
Dhruwang Jariwala
745f5487e9 fix: tweaks in open text question (#5841)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-21 06:20:40 +00:00
devin-ai-integration[bot]
0e7f3adf53 feat: Make session maxAge configurable with environment variable (#5830)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matti.sh>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-21 05:49:18 +00:00
Dhruwang Jariwala
342d2b1fc4 fix: response getting stuck (#5849) 2025-05-21 05:33:13 +00:00
Piyush Gupta
15279685f7 fix: delete pre-filled value (#5839) 2025-05-21 04:23:05 +00:00
Matti Nannt
12aa959f50 fix: slow responses query slowing down database (#5846) 2025-05-21 04:13:31 +00:00
Johannes
9478946c7a fix: fix icon in new docs page (#5836) 2025-05-19 04:53:57 -07:00
Johannes
8560bbf28b docs: documentation of multi-tenancy of Formbricks Cloud (#5835) 2025-05-19 04:47:26 -07:00
victorvhs017
df7afe1b64 fix: non-interactive elements without roles (#5804)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-19 10:10:13 +00:00
Piyush Gupta
df52b60d61 fix: env-var-generation in mac os for self-hosting (#5814) 2025-05-17 07:50:15 +00:00
Jakob Schott
65b051f0eb feat: download selection of responses (#5488)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-17 00:59:14 +00:00
Dhruwang Jariwala
7678084061 fix: unknown property warnings (#5800) 2025-05-16 13:45:48 +00:00
victorvhs017
022d33d06f chore: track server action with sentry and general fixes (#5799) 2025-05-16 12:02:06 +00:00
Anshuman Pandey
4d157bf8dc fix: user attributes updates api email fix (#5827) 2025-05-16 11:48:34 +00:00
Dhruwang Jariwala
9fcbe4e8c5 chore: swap next and back button input (#5748)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-16 08:51:12 +00:00
Piyush Gupta
5aeb92eb4f chore: removes https enforcement from management api (#5810) 2025-05-15 19:40:04 +00:00
Matti Nannt
00dfa629b5 fix: build process warnings (#5734)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-15 15:46:05 +00:00
Piyush Gupta
3ca471b6a2 feat: implement user management role configuration and access control (#5808) 2025-05-15 15:09:33 +00:00
Dhruwang Jariwala
a525589186 fix: token permisson issues (#4986) 2025-05-15 08:29:40 +00:00
Piyush Gupta
59ed10398d fix: suid bugs (#5780)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-14 12:09:41 +00:00
Piyush Gupta
25a86e31df fix: promise misuse error (#5783) 2025-05-14 12:06:41 +00:00
Matti Nannt
7d6743a81a chore(deps): upgrade npm dependencies in /packages (#5807) 2025-05-14 14:09:26 +02:00
Matti Nannt
6616f62da5 chore: remove unused prisma accelerate extension (#5682) 2025-05-14 12:34:08 +02:00
dependabot[bot]
a3cbc05e12 chore(deps): bump marocchino/sticky-pull-request-comment from 2.9.1 to 2.9.2 (#5760)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:29:43 +02:00
dependabot[bot]
97095a627a chore(deps): bump aws-actions/configure-aws-credentials from 4.0.2 to 4.2.0 (#5757)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:59 +02:00
dependabot[bot]
910d257c56 chore(deps): bump SonarSource/sonarqube-scan-action from 5.1.0 to 5.2.0 (#5758)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:39 +02:00
dependabot[bot]
0c0a008b28 chore(deps): bump actions/dependency-review-action from 4.6.0 to 4.7.0 (#5759)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:14 +02:00
dependabot[bot]
9879458353 chore(deps): bump borchero/terraform-plan-comment from 2.4.0 to 2.4.1 (#5761)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:26:46 +02:00
Matti Nannt
d44f1f3b4b chore: remove dependabot action and return to simple repository setup (#5806) 2025-05-14 12:23:38 +02:00
Dhruwang Jariwala
c5d387a7e5 fix: multi choice issues (#5802) 2025-05-14 09:59:22 +00:00
Anshuman Pandey
a6aacd5c55 fix: transaction timeout and max contacts (#5415)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-14 11:01:39 +02:00
Piyush Gupta
57e7485564 fix: reliability issues (#5781) 2025-05-13 16:40:16 +00:00
Matti Nannt
42a38a6f47 chore: fix environment surveys filter not working properly (#5793) 2025-05-13 13:34:27 +02:00
Dhruwang Jariwala
34bb9c2127 fix: multi select question (#5792)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-13 13:12:22 +02:00
Dhruwang Jariwala
6442b5e4aa fix: Vietnamese char interpretation (#5747) 2025-05-13 11:54:30 +02:00
Piyush Gupta
dde5a55446 fix: CTA and consent question breaking the survey editor (#5745) 2025-05-13 04:18:34 +00:00
Piyush Gupta
13e615a798 fix: duplicate switch cases (#5752)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-12 15:00:55 +00:00
victorvhs017
9c81961b0b chore: remove redundant code (#5751) 2025-05-12 12:23:05 +00:00
Matti Nannt
c1a35e2d75 chore: introduce new reliable cache for enterprise license check (#5740)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-12 10:41:53 +02:00
Piyush Gupta
13415c75c2 docs: adds auth-behaviour docs (#5743) 2025-05-12 05:28:12 +00:00
Johannes
300557a0e6 docs: update ee feature table (#5744) 2025-05-11 22:10:40 -07:00
Anshuman Pandey
fcbb97010c fix: follow ups ending card (#5732) 2025-05-10 10:30:49 +02:00
Matti Nannt
6be46b16b2 fix: limit number of surveys in environment state (#5715) 2025-05-09 16:10:01 +00:00
Matti Nannt
35b2356a31 fix: nextjs cache handler for next 15 (#5717)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-09 17:51:57 +02:00
Johannes
53ef756723 test: last round (#5731) 2025-05-09 10:09:09 +02:00
Johannes
0f0b743a10 test: backfill variety of test files (#5729)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-09 07:26:41 +00:00
Matti Nannt
3f7dafb65c fix: failing authOptions test because of missing mock (#5727) 2025-05-09 02:28:55 +02:00
Matti Nannt
9df791b5ff chore: add tests for apps/web/lib files (#5725) (#5726) 2025-05-09 01:35:50 +02:00
Dhruwang Jariwala
dea40d9757 test: unit test for survey.ts (#5724) 2025-05-09 00:54:19 +02:00
Matti Nannt
dd12a589d6 chore: add tests for apps/web/lib files (#5725) 2025-05-08 20:54:50 +00:00
Harsh Bhat
af6e5ba31e chore: add tests for packages/surveys/lib (#5722)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-08 20:31:52 +00:00
victorvhs017
2b57b2080b chore: fixed included files on surveys package tests (#5723) 2025-05-08 19:49:02 +00:00
Johannes
154c85a0f7 test: add test to multiple files (#5719)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-08 19:24:50 +00:00
Piyush Gupta
3f465d4594 chore: adds response utils tests (#5721) 2025-05-08 18:44:09 +00:00
Anshuman Pandey
94e883f4c3 chore: adds tests for apps/web/modules/survey/editor/lib (#5720) 2025-05-08 18:42:40 +00:00
victorvhs017
38622101f1 chore: add tests to apps/web/modules/survey/components and apps/web/modules/survey/link (#5707) 2025-05-08 17:24:46 +00:00
Dhruwang Jariwala
0eb64c0084 test: ui module test part 2 (#5716) 2025-05-08 15:37:59 +00:00
Johannes
409f5b1791 test: onboarding unit tests (#5712)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-08 14:57:20 +00:00
Harsh Bhat
14398a9c4f chore: add tests for package/surveys/src/components/general (#5699) 2025-05-08 13:23:36 +00:00
Piyush Gupta
d1cdf6e216 chore: adds unit tests for ee/contact/lib and modules/organization (#5713) 2025-05-08 13:06:39 +00:00
Dhruwang Jariwala
65da25a626 fix: rate limiting on login page (#5710) 2025-05-08 12:29:58 +00:00
Anshuman Pandey
ce8b019e93 chore: modules/survey/lib and modules/ee/contacts/api/v1 (#5711) 2025-05-08 10:52:55 +00:00
Dhruwang Jariwala
67d7fe016d test: Test for UI module (Part 1) (#5703) 2025-05-08 08:16:24 +00:00
Dhruwang Jariwala
47583b5a32 fix: unauthorized email address change (#5709) 2025-05-08 06:34:04 +00:00
Jakob Schott
03c9a6aaae chore: 576 test coverage: apps/web/modules/survey/list/lib (#5706) 2025-05-07 21:32:52 +00:00
Jakob Schott
4dcf9b093b chore: 576 test coverage components wrappers (#5702) 2025-05-07 21:31:43 +00:00
Jakob Schott
5ba5ebf63d chore: 576 test coverage apps web modules survey list components (#5704) 2025-05-07 19:24:15 +00:00
victorvhs017
115bea2792 chore: add tests to package/surveys/src/components/questions (#5694) 2025-05-07 18:42:25 +00:00
Piyush Gupta
b0495a8a42 chore: adds unit tests in module/projects (#5701) 2025-05-07 16:34:06 +00:00
Johannes
faabd371f5 fix: infinite loop and freeze (#5622)
Co-authored-by: Jakob Schott <jakob@formbricks.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-07 14:19:26 +00:00
Johannes
f0be6de0b3 chore: remove unused code (#5697) 2025-05-07 14:16:27 +00:00
Matti Nannt
b338c6d28d chore: remove unused cache files (#5700) 2025-05-07 15:26:13 +02:00
Anshuman Pandey
07e9a7c007 chore: tests for lib/utils and lib/survey (#5676) 2025-05-07 12:27:48 +00:00
victorvhs017
928bb3f8bc chore: updated sonar qube and vite config (#5695) 2025-05-07 11:13:07 +00:00
Piyush Jain
b9d62f6af2 fix: pin version 1 of helmfile actions (#5691) 2025-05-07 09:45:57 +02:00
Matti Nannt
f7ac38953b fix: infinite redirect issue (#5693)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-07 09:40:45 +02:00
Anshuman Pandey
6441c0aa31 fix: moves storage api management endpoint to use payload instead of … (#5348)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-07 04:55:58 +00:00
victorvhs017
16479eb6cf chore: add tests to environments path - part 2 (#5667) 2025-05-07 02:32:03 +02:00
Matti Nannt
69472c21c2 chore: simplify vite config for better coverage report (#5687) 2025-05-07 01:42:21 +02:00
Matti Nannt
c270688e8f chore: update remaining npm dependencies (#5685) 2025-05-07 01:15:01 +02:00
victorvhs017
00c86c7082 chore: add tests to environments path - part 3 (#5680) 2025-05-07 00:37:36 +02:00
Matti Nannt
e95e9f9fda fix: security issue because of outdated pnpm version (#5683) 2025-05-07 00:17:54 +02:00
Matti Nannt
1588c2f47b chore: remove config and script files from test coverage (#5684) 2025-05-06 22:21:45 +02:00
Vijay
53850c96db fix: sonar security hotspots (https, --ignore-scripts, api_key, math.random) (#5538)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-06 20:41:35 +02:00
Vijay
ae2cb15055 fix: sonar security hotspot (permission issue - non-root user in Dockerfile) (#5411)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-06 19:14:51 +02:00
Matti Nannt
8bf1e096c0 chore: move dependencies to devDependencies if possible (#5679) 2025-05-06 18:57:51 +02:00
Anshuman Pandey
0052dc88f0 fix: increases language button size (#5677) 2025-05-06 16:07:26 +00:00
Matti Nannt
d67d62df45 chore: update zod dependency, remove unused labeler action (#5678) 2025-05-06 18:18:27 +02:00
Piyush Gupta
5d45de6bc4 feat: adds unit tests in modules/ee/teams (#5620) 2025-05-06 12:31:43 +00:00
Piyush Gupta
cf5bc51e94 fix: strict recaptcha checks (#5674) 2025-05-06 12:13:28 +00:00
Dhruwang Jariwala
9a7d24ea4e chore: updated open telemtry package versions (#5672) 2025-05-06 11:59:54 +00:00
victorvhs017
649f28ff8d chore: add tests to survey editor components - part 4 (#5592)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-06 08:04:44 +00:00
dependabot[bot]
bc5a81d146 chore(deps): bump actions/github-script from 5.2.0 to 7.0.1 (#5652)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 09:28:04 +02:00
dependabot[bot]
7dce35bde4 chore(deps): bump actions/labeler from 4.3.0 to 5.0.0 (#5657)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 09:27:46 +02:00
victorvhs017
f30ebc32ec chore: add tests to environments path (#5617) 2025-05-06 04:19:50 +00:00
Piyush Gupta
027bc20975 fix: project list fetching logic in the copy to modal in survey list (#5609) 2025-05-06 04:05:05 +00:00
Matti Nannt
3b1cddb9ce chore: upgrade npm dependencies in apps/web (#5669) 2025-05-05 23:44:14 +02:00
Matti Nannt
bd22aaaa86 chore: move js sdk and demo app to its own repository (#5668) 2025-05-05 21:03:54 +02:00
Piyush Gupta
e0e42d2eed fix: adds support for default_team_id env var (#5046)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-05 18:40:19 +00:00
Matti Nannt
616210f1bf chore: update outdated dependencies (#5666) 2025-05-05 19:34:56 +02:00
Matti Nannt
ff2e7f6cc7 chore: make microsoft login available without tenant (#5665) 2025-05-05 19:18:07 +02:00
Jakob Schott
d1ce037f7d feat: disable productionbrowsersourcemaps (#5663) 2025-05-05 16:55:05 +00:00
Matti Nannt
91f87f4b7b chore: upgrade prisma to 6.7.0 (#5664) 2025-05-05 18:38:59 +02:00
Dhruwang Jariwala
61657b9f9a chore: add char limit to other option (#5382)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-05 14:56:54 +00:00
Dhruwang Jariwala
476d032642 fix: regex DoS issues (#5520)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-05 12:11:51 +00:00
Johannes
7538e570c5 chore: enforce cookie options for more security (#5618) 2025-05-05 12:09:35 +00:00
Jakob Schott
66fcf4b79b fix: changed unnecessary map to forEach (#5490) 2025-05-05 14:12:34 +02:00
Dhruwang Jariwala
21371b1815 fix: weak cryptography security hotspot (#5600) 2025-05-05 11:41:56 +00:00
Dhruwang
95a0607a06 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-copy-issue 2025-03-04 11:17:35 +05:30
Dhruwang
c0005623df fix: survey copy 2025-03-03 17:11:37 +05:30
Dhruwang
caa83517d4 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-copy-issue 2025-03-03 16:06:46 +05:30
jonas-hoebenreich
df10c8d401 fix duplicate name survey copy issue 2024-10-17 22:52:59 +02:00
1445 changed files with 120981 additions and 26519 deletions

View File

@@ -0,0 +1,61 @@
---
description:
globs:
alwaysApply: false
---
# Build & Deployment Best Practices
## Build Process
### Running Builds
- Use `pnpm build` from project root for full build
- Monitor for React hooks warnings and fix them immediately
- Ensure all TypeScript errors are resolved before deployment
### Common Build Issues & Fixes
#### React Hooks Warnings
- Capture ref values in variables within useEffect cleanup
- Avoid accessing `.current` directly in cleanup functions
- Pattern for fixing ref cleanup warnings:
```typescript
useEffect(() => {
const currentRef = myRef.current;
return () => {
if (currentRef) {
currentRef.cleanup();
}
};
}, []);
```
#### Test Failures During Build
- Ensure all test mocks include required constants like `SESSION_MAX_AGE`
- Mock Next.js navigation hooks properly: `useParams`, `useRouter`, `useSearchParams`
- Remove unused imports and constants from test files
- Use literal values instead of imported constants when the constant isn't actually needed
### Test Execution
- Run `pnpm test` to execute all tests
- Use `pnpm test -- --run filename.test.tsx` for specific test files
- Fix test failures before merging code
- Ensure 100% test coverage for new components
### Performance Monitoring
- Monitor build times and optimize if necessary
- Watch for memory usage during builds
- Use proper caching strategies for faster rebuilds
### Deployment Checklist
1. All tests passing
2. Build completes without warnings
3. TypeScript compilation successful
4. No linter errors
5. Database migrations applied (if any)
6. Environment variables configured
### EKS Deployment Considerations
- Ensure latest code is deployed to all pods
- Monitor AWS RDS Performance Insights for database issues
- Verify environment-specific configurations
- Check pod health and resource usage

View File

@@ -0,0 +1,414 @@
---
description: Caching rules for performance improvements
globs:
alwaysApply: false
---
# Cache Optimization Patterns for Formbricks
## Cache Strategy Overview
Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
- **Redis** for persistent cross-request caching
- **React `cache()`** for request-level deduplication
- **NO Next.js `unstable_cache()`** - avoid for reliability
## Key Files
### Core Cache Infrastructure
- [apps/web/modules/cache/lib/service.ts](mdc:apps/web/modules/cache/lib/service.ts) - Redis cache service
- [apps/web/modules/cache/lib/withCache.ts](mdc:apps/web/modules/cache/lib/withCache.ts) - Cache wrapper utilities
- [apps/web/modules/cache/lib/cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts) - Enterprise cache key patterns and utilities
### Environment State Caching (Critical Endpoint)
- [apps/web/app/api/v1/client/[environmentId]/environment/route.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/route.ts) - Main endpoint serving hundreds of thousands of SDK clients
- [apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts) - Optimized data layer with caching
## Enterprise-Grade Cache Key Patterns
**Always use** the `createCacheKey` utilities from [cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts):
```typescript
// ✅ Correct patterns
createCacheKey.environment.state(environmentId) // "fb:env:abc123:state"
createCacheKey.organization.billing(organizationId) // "fb:org:xyz789:billing"
createCacheKey.license.status(organizationId) // "fb:license:org123:status"
createCacheKey.user.permissions(userId, orgId) // "fb:user:456:org:123:permissions"
// ❌ Never use flat keys - collision-prone
"environment_abc123"
"user_data_456"
```
## When to Use Each Cache Type
### Use React `cache()` for Request Deduplication
```typescript
// ✅ Prevents multiple calls within same request
export const getEnterpriseLicense = reactCache(async () => {
// Complex license validation logic
});
```
### Use `withCache()` for Simple Database Queries
```typescript
// ✅ Simple caching with automatic fallback (TTL in milliseconds)
export const getActionClasses = (environmentId: string) => {
return withCache(() => fetchActionClassesFromDB(environmentId), {
key: createCacheKey.environment.actionClasses(environmentId),
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
})();
};
```
### Use Explicit Redis Cache for Complex Business Logic
```typescript
// ✅ Full control for high-stakes endpoints
export const getEnvironmentState = async (environmentId: string) => {
const cached = await environmentStateCache.getEnvironmentState(environmentId);
if (cached) return cached;
const fresh = await buildComplexState(environmentId);
await environmentStateCache.setEnvironmentState(environmentId, fresh);
return fresh;
};
```
## Caching Decision Framework
### When TO Add Caching
```typescript
// ✅ Expensive operations that benefit from caching
- Database queries (>10ms typical)
- External API calls (>50ms typical)
- Complex computations (>5ms)
- File system operations
- Heavy data transformations
// Example: Database query with complex joins (TTL in milliseconds)
export const getEnvironmentWithDetails = withCache(
async (environmentId: string) => {
return prisma.environment.findUnique({
where: { id: environmentId },
include: { /* complex joins */ }
});
},
{ key: createCacheKey.environment.details(environmentId), ttl: 60 * 30 * 1000 } // 30 minutes
)();
```
### When NOT to Add Caching
```typescript
// ❌ Don't cache these operations - minimal overhead
- Simple property access (<0.1ms)
- Basic transformations (<1ms)
- Functions that just call already-cached functions
- Pure computation without I/O
// ❌ Bad example: Redundant caching
const getCachedLicenseFeatures = withCache(
async () => {
const license = await getEnterpriseLicense(); // Already cached!
return license.active ? license.features : null; // Just property access
},
{ key: "license-features", ttl: 1800 * 1000 } // 30 minutes in milliseconds
);
// ✅ Good example: Simple and efficient
const getLicenseFeatures = async () => {
const license = await getEnterpriseLicense(); // Already cached
return license.active ? license.features : null; // 0.1ms overhead
};
```
### Computational Overhead Analysis
Before adding caching, analyze the overhead:
```typescript
// ✅ High overhead - CACHE IT
- Database queries: ~10-100ms
- External APIs: ~50-500ms
- File I/O: ~5-50ms
- Complex algorithms: >5ms
// ❌ Low overhead - DON'T CACHE
- Property access: ~0.001ms
- Simple lookups: ~0.1ms
- Basic validation: ~1ms
- Type checks: ~0.01ms
// Example decision tree:
const expensiveOperation = async () => {
return prisma.query(); // 50ms - CACHE IT
};
const cheapOperation = (data: any) => {
return data.property; // 0.001ms - DON'T CACHE
};
```
### Avoid Cache Wrapper Anti-Pattern
```typescript
// ❌ Don't create wrapper functions just for caching
const getCachedUserPermissions = withCache(
async (userId: string) => getUserPermissions(userId),
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
);
// ✅ Add caching directly to the original function
export const getUserPermissions = withCache(
async (userId: string) => {
return prisma.user.findUnique({
where: { id: userId },
include: { permissions: true }
});
},
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
);
```
## TTL Coordination Strategy
### Multi-Layer Cache Coordination
For endpoints serving client SDKs, coordinate TTLs across layers:
```typescript
// Client SDK cache (expiresAt) - longest TTL for fewer requests
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
// Server Redis cache - shorter TTL ensures fresh data for clients
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
// HTTP cache headers (seconds)
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
```
### Standard TTL Guidelines (in milliseconds for cache-manager + Keyv)
```typescript
// Configuration data - rarely changes
const CONFIG_TTL = 60 * 60 * 24 * 1000; // 24 hours
// User data - moderate frequency
const USER_TTL = 60 * 60 * 2 * 1000; // 2 hours
// Survey data - changes moderately
const SURVEY_TTL = 60 * 15 * 1000; // 15 minutes
// Billing data - expensive to compute
const BILLING_TTL = 60 * 30 * 1000; // 30 minutes
// Action classes - infrequent changes
const ACTION_CLASS_TTL = 60 * 30 * 1000; // 30 minutes
```
## High-Frequency Endpoint Optimization
### Performance Patterns for High-Volume Endpoints
```typescript
// ✅ Optimized high-frequency endpoint pattern
export const GET = async (request: NextRequest, props: { params: Promise<{ id: string }> }) => {
const params = await props.params;
try {
// Simple validation (avoid Zod for high-frequency)
if (!params.id || typeof params.id !== 'string') {
return responses.badRequestResponse("ID is required", undefined, true);
}
// Single optimized query with caching
const data = await getOptimizedData(params.id);
return responses.successResponse(
{
data,
expiresAt: new Date(Date.now() + CLIENT_TTL * 1000), // SDK cache duration
},
true,
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
);
} catch (err) {
// Simplified error handling for performance
if (err instanceof ResourceNotFoundError) {
return responses.notFoundResponse(err.resourceType, err.resourceId);
}
logger.error({ error: err, url: request.url }, "Error in high-frequency endpoint");
return responses.internalServerErrorResponse(err.message, true);
}
};
```
### Avoid These Performance Anti-Patterns
```typescript
// ❌ Avoid for high-frequency endpoints
const inputValidation = ZodSchema.safeParse(input); // Too slow
const startTime = Date.now(); logger.debug(...); // Logging overhead
const { data, revalidateEnvironment } = await get(); // Complex return types
```
### CORS Optimization
```typescript
// ✅ Balanced CORS caching (not too aggressive)
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
true,
"public, s-maxage=3600, max-age=3600" // 1 hour balanced approach
);
};
```
## Redis Cache Migration from Next.js
### Avoid Legacy Next.js Patterns
```typescript
// ❌ Old Next.js unstable_cache pattern (avoid)
const getCachedData = unstable_cache(
async (id) => fetchData(id),
['cache-key'],
{ tags: ['environment'], revalidate: 900 }
);
// ❌ Don't use revalidateEnvironment flags with Redis
return { data, revalidateEnvironment: true }; // This gets cached incorrectly!
// ✅ New Redis pattern with withCache (TTL in milliseconds)
export const getCachedData = (id: string) =>
withCache(
() => fetchData(id),
{
key: createCacheKey.environment.data(id),
ttl: 60 * 15 * 1000, // 15 minutes in milliseconds
}
)();
```
### Remove Revalidation Logic
When migrating from Next.js `unstable_cache`:
- Remove `revalidateEnvironment` or similar flags
- Remove tag-based invalidation logic
- Use TTL-based expiration instead
- Handle one-time updates (like `appSetupCompleted`) directly in cache
## Data Layer Optimization
### Single Query Pattern
```typescript
// ✅ Optimize with single database query
export const getOptimizedEnvironmentData = async (environmentId: string) => {
return prisma.environment.findUniqueOrThrow({
where: { id: environmentId },
include: {
project: {
select: { id: true, recontactDays: true, /* ... */ }
},
organization: {
select: { id: true, billing: true }
},
surveys: {
where: { status: "inProgress" },
select: { id: true, name: true, /* ... */ }
},
actionClasses: {
select: { id: true, name: true, /* ... */ }
}
}
});
};
// ❌ Avoid multiple separate queries
const environment = await getEnvironment(id);
const organization = await getOrganization(environment.organizationId);
const surveys = await getSurveys(id);
const actionClasses = await getActionClasses(id);
```
## Invalidation Best Practices
**Always use explicit key-based invalidation:**
```typescript
// ✅ Clear and debuggable
await invalidateCache(createCacheKey.environment.state(environmentId));
await invalidateCache([
createCacheKey.environment.surveys(environmentId),
createCacheKey.environment.actionClasses(environmentId)
]);
// ❌ Avoid complex tag systems
await invalidateByTags(["environment", "survey"]); // Don't do this
```
## Critical Performance Targets
### High-Frequency Endpoint Goals
- **Cache hit ratio**: >85%
- **Response time P95**: <200ms
- **Database load reduction**: >60%
- **HTTP cache duration**: 1hr browser, 30min Cloudflare
- **SDK refresh interval**: 1 hour with 30min server cache
### Performance Monitoring
- Use **existing elastic cache analytics** for metrics
- Log cache errors and warnings (not debug info)
- Track database query reduction
- Monitor response times for cached endpoints
- **Avoid performance logging** in high-frequency endpoints
## Error Handling Pattern
Always provide fallback to fresh data on cache errors:
```typescript
try {
const cached = await cache.get(key);
if (cached) return cached;
const fresh = await fetchFresh();
await cache.set(key, fresh, ttl); // ttl in milliseconds
return fresh;
} catch (error) {
// ✅ Always fallback to fresh data
logger.warn("Cache error, fetching fresh", { key, error });
return fetchFresh();
}
```
## Common Pitfalls to Avoid
1. **Never use Next.js `unstable_cache()`** - unreliable in production
2. **Don't use revalidation flags with Redis** - they get cached incorrectly
3. **Avoid Zod validation** for simple parameters in high-frequency endpoints
4. **Don't add performance logging** to high-frequency endpoints
5. **Coordinate TTLs** between client and server caches
6. **Don't over-engineer** with complex tag systems
7. **Avoid caching rapidly changing data** (real-time metrics)
8. **Always validate cache keys** to prevent collisions
9. **Don't add redundant caching layers** - analyze computational overhead first
10. **Avoid cache wrapper functions** - add caching directly to expensive operations
11. **Don't cache property access or simple transformations** - overhead is negligible
12. **Analyze the full call chain** before adding caching to avoid double-caching
13. **Remember TTL is in milliseconds** for cache-manager + Keyv stack (not seconds)
## Monitoring Strategy
- Use **existing elastic cache analytics** for metrics
- Log cache errors and warnings
- Track database query reduction
- Monitor response times for cached endpoints
- **Don't add custom metrics** that duplicate existing monitoring
## Important Notes
### TTL Units
- **cache-manager + Keyv**: TTL in **milliseconds**
- **Direct Redis commands**: TTL in **seconds** (EXPIRE, SETEX) or **milliseconds** (PEXPIRE, PSETEX)
- **HTTP cache headers**: TTL in **seconds** (max-age, s-maxage)
- **Client SDK**: TTL in **seconds** (expiresAt calculation)

View File

@@ -0,0 +1,41 @@
---
description:
globs:
alwaysApply: false
---
# Database Performance & Prisma Best Practices
## Critical Performance Rules
### Response Count Queries
- **NEVER** use `skip`/`offset` with `prisma.response.count()` - this causes expensive subqueries with OFFSET
- Always use only `where` clauses for count operations: `prisma.response.count({ where: { ... } })`
- For pagination, separate count queries from data queries
- Reference: [apps/web/lib/response/service.ts](mdc:apps/web/lib/response/service.ts) line 654-686
### Prisma Query Optimization
- Use proper indexes defined in [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
- Leverage existing indexes: `@@index([surveyId, createdAt])`, `@@index([createdAt])`
- Use cursor-based pagination for large datasets instead of offset-based
- Cache frequently accessed data using React Cache and custom cache tags
### Date Range Filtering
- When filtering by `createdAt`, always use indexed queries
- Combine with `surveyId` for optimal performance: `{ surveyId, createdAt: { gte: start, lt: end } }`
- Avoid complex WHERE clauses that can't utilize indexes
### Count vs Data Separation
- Always separate count queries from data fetching queries
- Use `Promise.all()` to run count and data queries in parallel
- Example pattern from [apps/web/modules/api/v2/management/responses/lib/response.ts](mdc:apps/web/modules/api/v2/management/responses/lib/response.ts):
```typescript
const [responses, totalCount] = await Promise.all([
prisma.response.findMany(query),
prisma.response.count({ where: whereClause }),
]);
```
### Monitoring & Debugging
- Monitor AWS RDS Performance Insights for problematic queries
- Look for queries with OFFSET in count operations - these indicate performance issues
- Use proper error handling with `DatabaseError` for Prisma exceptions

View File

@@ -0,0 +1,152 @@
---
description:
globs:
alwaysApply: false
---
# EKS & ALB Optimization Guide for Error Reduction
## Infrastructure Overview
This project uses AWS EKS with Application Load Balancer (ALB) for the Formbricks application. The infrastructure has been optimized to minimize ELB 502/504 errors through careful configuration of connection handling, health checks, and pod lifecycle management.
## Key Infrastructure Files
### Terraform Configuration
- **Main Infrastructure**: [infra/terraform/main.tf](mdc:infra/terraform/main.tf) - EKS cluster, VPC, Karpenter, and core AWS resources
- **Monitoring**: [infra/terraform/cloudwatch.tf](mdc:infra/terraform/cloudwatch.tf) - CloudWatch alarms for 502/504 error tracking and alerting
- **Database**: [infra/terraform/rds.tf](mdc:infra/terraform/rds.tf) - Aurora PostgreSQL configuration
### Helm Configuration
- **Production**: [infra/formbricks-cloud-helm/values.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values.yaml.gotmpl) - Optimized ALB and pod configurations
- **Staging**: [infra/formbricks-cloud-helm/values-staging.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values-staging.yaml.gotmpl) - Staging environment with spot instances
- **Deployment**: [infra/formbricks-cloud-helm/helmfile.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/helmfile.yaml.gotmpl) - Multi-environment Helm releases
## ALB Optimization Patterns
### Connection Handling Optimizations
```yaml
# Key ALB annotations for reducing 502/504 errors
alb.ingress.kubernetes.io/load-balancer-attributes: |
idle_timeout.timeout_seconds=120,
connection_logs.s3.enabled=false,
access_logs.s3.enabled=false
alb.ingress.kubernetes.io/target-group-attributes: |
deregistration_delay.timeout_seconds=30,
stickiness.enabled=false,
load_balancing.algorithm.type=least_outstanding_requests,
target_group_health.dns_failover.minimum_healthy_targets.count=1
```
### Health Check Configuration
- **Interval**: 15 seconds for faster detection of unhealthy targets
- **Timeout**: 5 seconds to prevent false positives
- **Thresholds**: 2 healthy, 3 unhealthy for balanced responsiveness
- **Path**: `/health` endpoint optimized for < 100ms response time
## Pod Lifecycle Management
### Graceful Shutdown Pattern
```yaml
# PreStop hook to allow connection draining
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
# Termination grace period for complete cleanup
terminationGracePeriodSeconds: 45
```
### Health Probe Strategy
- **Startup Probe**: 5s initial delay, 5s interval, max 60s startup time
- **Readiness Probe**: 10s delay, 10s interval for traffic readiness
- **Liveness Probe**: 30s delay, 30s interval for container health
### Rolling Update Configuration
```yaml
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 25% # Maintain capacity during updates
maxSurge: 50% # Allow faster rollouts
```
## Karpenter Node Management
### Node Lifecycle Optimization
- **Startup Taints**: Prevent traffic during node initialization
- **Graceful Shutdown**: 30s grace period for pod eviction
- **Consolidation Delay**: 60s to reduce unnecessary churn
- **Eviction Policies**: Configured for smooth pod migrations
### Instance Selection
- **Families**: c8g, c7g, m8g, m7g, r8g, r7g (ARM64 Graviton)
- **Sizes**: 2, 4, 8 vCPUs for cost optimization
- **Bottlerocket AMI**: Enhanced security and performance
## Monitoring & Alerting
### Critical ALB Metrics
1. **ELB 502 Errors**: Threshold 20 over 5 minutes
2. **ELB 504 Errors**: Threshold 15 over 5 minutes
3. **Target Connection Errors**: Threshold 50 over 5 minutes
4. **4XX Errors**: Threshold 100 over 10 minutes (client issues)
### Expected Improvements
- **60-80% reduction** in ELB 502 errors
- **Faster recovery** during pod restarts
- **Better connection reuse** efficiency
- **Improved autoscaling** responsiveness
## Deployment Patterns
### Infrastructure Updates
1. **Terraform First**: Apply infrastructure changes via [infra/deploy-improvements.sh](mdc:infra/deploy-improvements.sh)
2. **Helm Second**: Deploy application configurations
3. **Verification**: Check pod status, endpoints, and ALB health
4. **Monitoring**: Watch CloudWatch metrics for 24-48 hours
### Environment-Specific Configurations
- **Production**: On-demand instances, stricter resource limits
- **Staging**: Spot instances, rate limiting disabled, relaxed resources
## Troubleshooting Patterns
### 502 Error Investigation
1. Check pod readiness and health probe status
2. Verify ALB target group health
3. Review deregistration timing during deployments
4. Monitor connection pool utilization
### 504 Error Analysis
1. Check application response times
2. Verify timeout configurations (ALB: 120s, App: aligned)
3. Review database query performance
4. Monitor resource utilization during traffic spikes
### Connection Error Patterns
1. Verify Karpenter node lifecycle timing
2. Check pod termination grace periods
3. Review ALB connection draining settings
4. Monitor cluster autoscaling events
## Best Practices
### When Making Changes
- **Test in staging first** with same configurations
- **Monitor metrics** for 24-48 hours after changes
- **Use gradual rollouts** with proper health checks
- **Maintain ALB timeout alignment** across all layers
### Performance Optimization
- **Health endpoint** should respond < 100ms consistently
- **Connection pooling** aligned with ALB idle timeouts
- **Resource requests/limits** tuned for consistent performance
- **Graceful shutdown** implemented in application code
### Monitoring Strategy
- **Real-time alerts** for error rate spikes
- **Trend analysis** for connection patterns
- **Capacity planning** based on LCU usage
- **4XX pattern analysis** for client behavior insights

View File

@@ -0,0 +1,334 @@
---
description:
globs:
alwaysApply: false
---
# Formbricks Architecture & Patterns
## Monorepo Structure
### Apps Directory
- `apps/web/` - Main Next.js web application
- `packages/` - Shared packages and utilities
### Key Directories in Web App
```
apps/web/
├── app/ # Next.js 13+ app directory
│ ├── (app)/ # Main application routes
│ ├── (auth)/ # Authentication routes
│ ├── api/ # API routes
│ └── share/ # Public sharing routes
├── components/ # Shared components
├── lib/ # Utility functions and services
└── modules/ # Feature-specific modules
```
## Routing Patterns
### App Router Structure
The application uses Next.js 13+ app router with route groups:
```
(app)/environments/[environmentId]/
├── surveys/[surveyId]/
│ ├── (analysis)/ # Analysis views
│ │ ├── responses/ # Response management
│ │ ├── summary/ # Survey summary
│ │ └── hooks/ # Analysis-specific hooks
│ ├── edit/ # Survey editing
│ └── settings/ # Survey settings
```
### Dynamic Routes
- `[environmentId]` - Environment-specific routes
- `[surveyId]` - Survey-specific routes
- `[sharingKey]` - Public sharing routes
## Service Layer Pattern
### Service Organization
Services are organized by domain in `apps/web/lib/`:
```typescript
// Example: Response service
// apps/web/lib/response/service.ts
export const getResponseCountAction = async ({
surveyId,
filterCriteria,
}: {
surveyId: string;
filterCriteria: any;
}) => {
// Service implementation
};
```
### Action Pattern
Server actions follow a consistent pattern:
```typescript
// Action wrapper for service calls
export const getResponseCountAction = async (params) => {
try {
const result = await responseService.getCount(params);
return { data: result };
} catch (error) {
return { error: error.message };
}
};
```
## Context Patterns
### Provider Structure
Context providers follow a consistent pattern:
```typescript
// Provider component
export const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => {
const [selectedFilter, setSelectedFilter] = useState(defaultFilter);
const value = {
selectedFilter,
setSelectedFilter,
// ... other state and methods
};
return (
<ResponseFilterContext.Provider value={value}>
{children}
</ResponseFilterContext.Provider>
);
};
// Hook for consuming context
export const useResponseFilter = () => {
const context = useContext(ResponseFilterContext);
if (!context) {
throw new Error('useResponseFilter must be used within ResponseFilterProvider');
}
return context;
};
```
### Context Composition
Multiple contexts are often composed together:
```typescript
// Layout component with multiple providers
export default function AnalysisLayout({ children }: { children: React.ReactNode }) {
return (
<ResponseFilterProvider>
<ResponseCountProvider>
{children}
</ResponseCountProvider>
</ResponseFilterProvider>
);
}
```
## Component Patterns
### Page Components
Page components are located in the app directory and follow this pattern:
```typescript
// apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
export default function ResponsesPage() {
return (
<div>
<ResponsesTable />
<ResponsesPagination />
</div>
);
}
```
### Component Organization
- **Pages** - Route components in app directory
- **Components** - Reusable UI components
- **Modules** - Feature-specific components and logic
### Shared Components
Common components are in `apps/web/components/`:
- UI components (buttons, inputs, modals)
- Layout components (headers, sidebars)
- Data display components (tables, charts)
## Hook Patterns
### Custom Hook Structure
Custom hooks follow consistent patterns:
```typescript
export const useResponseCount = ({
survey,
initialCount
}: {
survey: TSurvey;
initialCount?: number;
}) => {
const [responseCount, setResponseCount] = useState(initialCount ?? 0);
const [isLoading, setIsLoading] = useState(false);
// Hook logic...
return {
responseCount,
isLoading,
refetch,
};
};
```
### Hook Dependencies
- Use context hooks for shared state
- Implement proper cleanup with AbortController
- Optimize dependency arrays to prevent unnecessary re-renders
## Data Fetching Patterns
### Server Actions
The app uses Next.js server actions for data fetching:
```typescript
// Server action
export async function getResponsesAction(params: GetResponsesParams) {
const responses = await getResponses(params);
return { data: responses };
}
// Client usage
const { data } = await getResponsesAction(params);
```
### Error Handling
Consistent error handling across the application:
```typescript
try {
const result = await apiCall();
return { data: result };
} catch (error) {
console.error("Operation failed:", error);
return { error: error.message };
}
```
## Type Safety
### Type Organization
Types are organized in packages:
- `@formbricks/types` - Shared type definitions
- Local types in component/hook files
### Common Types
```typescript
import { TSurvey } from "@formbricks/types/surveys/types";
import { TResponse } from "@formbricks/types/responses";
import { TEnvironment } from "@formbricks/types/environment";
```
## State Management
### Local State
- Use `useState` for component-specific state
- Use `useReducer` for complex state logic
- Use refs for mutable values that don't trigger re-renders
### Global State
- React Context for feature-specific shared state
- URL state for filters and pagination
- Server state through server actions
## Performance Considerations
### Code Splitting
- Dynamic imports for heavy components
- Route-based code splitting with app router
- Lazy loading for non-critical features
### Caching Strategy
- Server-side caching for database queries
- Client-side caching with React Query (where applicable)
- Static generation for public pages
## Testing Strategy
### Test Organization
```
component/
├── Component.tsx
├── Component.test.tsx
└── hooks/
├── useHook.ts
└── useHook.test.tsx
```
### Test Patterns
- Unit tests for utilities and services
- Integration tests for components with context
- Hook tests with proper mocking
## Build & Deployment
### Build Process
- TypeScript compilation
- Next.js build optimization
- Asset optimization and bundling
### Environment Configuration
- Environment-specific configurations
- Feature flags for gradual rollouts
- Database connection management
## Security Patterns
### Authentication
- Session-based authentication
- Environment-based access control
- API route protection
### Data Validation
- Input validation on both client and server
- Type-safe API contracts
- Sanitization of user inputs
## Monitoring & Observability
### Error Tracking
- Client-side error boundaries
- Server-side error logging
- Performance monitoring
### Analytics
- User interaction tracking
- Performance metrics
- Database query monitoring
## Best Practices Summary
### Code Organization
- ✅ Follow the established directory structure
- ✅ Use consistent naming conventions
- ✅ Separate concerns (UI, logic, data)
- ✅ Keep components focused and small
### Performance
- ✅ Implement proper loading states
- ✅ Use AbortController for async operations
- ✅ Optimize database queries
- ✅ Implement proper caching strategies
### Type Safety
- ✅ Use TypeScript throughout
- ✅ Define proper interfaces for props
- ✅ Use type guards for runtime validation
- ✅ Leverage shared type packages
### Testing
- ✅ Write tests for critical functionality
- ✅ Mock external dependencies properly
- ✅ Test error scenarios and edge cases
- ✅ Maintain good test coverage

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -0,0 +1,52 @@
---
description:
globs:
alwaysApply: false
---
# React Context & Provider Patterns
## Context Provider Best Practices
### Provider Implementation
- Use TypeScript interfaces for provider props with optional `initialCount` for testing
- Implement proper cleanup in `useEffect` to avoid React hooks warnings
- Reference: [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx)
### Cleanup Pattern for Refs
```typescript
useEffect(() => {
const currentPendingRequests = pendingRequests.current;
const currentAbortController = abortController.current;
return () => {
if (currentAbortController) {
currentAbortController.abort();
}
currentPendingRequests.clear();
};
}, []);
```
### Testing Context Providers
- Always wrap components using context in the provider during tests
- Use `initialCount` prop for predictable test scenarios
- Mock context dependencies like `useParams`, `useResponseFilter`
- Example from [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx):
```typescript
render(
<ResponseCountProvider survey={dummySurvey} initialCount={5}>
<ComponentUnderTest />
</ResponseCountProvider>
);
```
### Required Mocks for Context Testing
- Mock `next/navigation` with `useParams` returning environment and survey IDs
- Mock response filter context and actions
- Mock API actions that the provider depends on
### Context Hook Usage
- Create custom hooks like `useResponseCountContext()` for consuming context
- Provide meaningful error messages when context is used outside provider
- Use context for shared state that multiple components need to access

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -0,0 +1,327 @@
---
description:
globs:
alwaysApply: false
---
# Testing Patterns & Best Practices
## Running Tests
### Test Commands
From the **root directory** (formbricks/):
- `npm test` - Run all tests across all packages (recommended for CI/full testing)
- `npm run test:coverage` - Run all tests with coverage reports
- `npm run test:e2e` - Run end-to-end tests with Playwright
From the **apps/web directory** (apps/web/):
- `npm run test` - Run only web app tests (fastest for development)
- `npm run test:coverage` - Run web app tests with coverage
- `npm run test -- <file-pattern>` - Run specific test files
### Examples
```bash
# Run all tests from root (takes ~3 minutes, runs 790 test files with 5334+ tests)
npm test
# Run specific test file from apps/web (fastest for development)
npm run test -- modules/cache/lib/service.test.ts
# Run tests matching pattern from apps/web
npm run test -- modules/ee/license-check/lib/license.test.ts
# Run with coverage from root
npm run test:coverage
# Run specific test with watch mode from apps/web (for development)
npm run test -- --watch modules/cache/lib/service.test.ts
# Run tests for a specific directory from apps/web
npm run test -- modules/cache/
```
### Performance Tips
- **For development**: Use `apps/web` directory commands to run only web app tests
- **For CI/validation**: Use root directory commands to run all packages
- **For specific features**: Use file patterns to target specific test files
- **For debugging**: Use `--watch` mode for continuous testing during development
### Test File Organization
- Place test files in the **same directory** as the source file
- Use `.test.ts` for utility/service tests (Node environment)
- Use `.test.tsx` for React component tests (jsdom environment)
## Test File Naming & Environment
### File Extensions
- Use `.test.tsx` for React component/hook tests (runs in jsdom environment)
- Use `.test.ts` for utility/service tests (runs in Node environment)
- The vitest config uses `environmentMatchGlobs` to automatically set jsdom for `.tsx` files
### Test Structure
```typescript
// Import the mocked functions first
import { useHook } from "@/path/to/hook";
import { serviceFunction } from "@/path/to/service";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock dependencies
vi.mock("@/path/to/hook", () => ({
useHook: vi.fn(),
}));
describe("ComponentName", () => {
beforeEach(() => {
vi.clearAllMocks();
// Setup default mocks
});
test("descriptive test name", async () => {
// Test implementation
});
});
```
## React Hook Testing
### Context Mocking
When testing hooks that use React Context:
```typescript
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [],
onlyComplete: false,
},
setSelectedFilter: vi.fn(),
selectedOptions: {
questionOptions: [],
questionFilterOptions: [],
},
setSelectedOptions: vi.fn(),
dateRange: { from: new Date(), to: new Date() },
setDateRange: vi.fn(),
resetState: vi.fn(),
});
```
### Testing Async Hooks
- Always use `waitFor` for async operations
- Test both loading and completed states
- Verify API calls with correct parameters
```typescript
test("fetches data on mount", async () => {
const { result } = renderHook(() => useHook());
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBe(expectedData);
expect(vi.mocked(apiCall)).toHaveBeenCalledWith(expectedParams);
});
```
### Testing Hook Dependencies
To test useEffect dependencies, ensure mocks return different values:
```typescript
// First render
mockGetFormattedFilters.mockReturnValue(mockFilters);
// Change dependency and trigger re-render
const newMockFilters = { ...mockFilters, finished: true };
mockGetFormattedFilters.mockReturnValue(newMockFilters);
rerender();
```
## Performance Testing
### Race Condition Testing
Test AbortController implementation:
```typescript
test("cancels previous request when new request is made", async () => {
let resolveFirst: (value: any) => void;
let resolveSecond: (value: any) => void;
const firstPromise = new Promise((resolve) => {
resolveFirst = resolve;
});
const secondPromise = new Promise((resolve) => {
resolveSecond = resolve;
});
vi.mocked(apiCall)
.mockReturnValueOnce(firstPromise as any)
.mockReturnValueOnce(secondPromise as any);
const { result } = renderHook(() => useHook());
// Trigger second request
result.current.refetch();
// Resolve in order - first should be cancelled
resolveFirst!({ data: 100 });
resolveSecond!({ data: 200 });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Should have result from second request
expect(result.current.data).toBe(200);
});
```
### Cleanup Testing
```typescript
test("cleans up on unmount", () => {
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
const { unmount } = renderHook(() => useHook());
unmount();
expect(abortSpy).toHaveBeenCalled();
abortSpy.mockRestore();
});
```
## Error Handling Testing
### API Error Testing
```typescript
test("handles API errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(apiCall).mockRejectedValue(new Error("API Error"));
const { result } = renderHook(() => useHook());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(consoleSpy).toHaveBeenCalledWith("Error message:", expect.any(Error));
expect(result.current.data).toBe(fallbackValue);
consoleSpy.mockRestore();
});
```
### Cancelled Request Testing
```typescript
test("does not update state for cancelled requests", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
let rejectFirst: (error: any) => void;
const firstPromise = new Promise((_, reject) => {
rejectFirst = reject;
});
vi.mocked(apiCall)
.mockReturnValueOnce(firstPromise as any)
.mockResolvedValueOnce({ data: 42 });
const { result } = renderHook(() => useHook());
result.current.refetch();
const abortError = new Error("Request cancelled");
rejectFirst!(abortError);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Should not log error for cancelled request
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
```
## Type Safety in Tests
### Mock Type Assertions
Use type assertions for edge cases:
```typescript
vi.mocked(apiCall).mockResolvedValue({
data: null as any, // For testing null handling
});
vi.mocked(apiCall).mockResolvedValue({
data: undefined as any, // For testing undefined handling
});
```
### Proper Mock Typing
Ensure mocks match the actual interface:
```typescript
const mockSurvey: TSurvey = {
id: "survey-123",
name: "Test Survey",
// ... other required properties
} as unknown as TSurvey; // Use when partial mocking is needed
```
## Common Test Patterns
### Testing State Changes
```typescript
test("updates state correctly", async () => {
const { result } = renderHook(() => useHook());
// Initial state
expect(result.current.value).toBe(initialValue);
// Trigger change
result.current.updateValue(newValue);
// Verify change
expect(result.current.value).toBe(newValue);
});
```
### Testing Multiple Scenarios
```typescript
test("handles different modes", async () => {
// Test regular mode
vi.mocked(useParams).mockReturnValue({ surveyId: "123" });
const { rerender } = renderHook(() => useHook());
await waitFor(() => {
expect(vi.mocked(regularApi)).toHaveBeenCalled();
});
// Test sharing mode
vi.mocked(useParams).mockReturnValue({
surveyId: "123",
sharingKey: "share-123"
});
rerender();
await waitFor(() => {
expect(vi.mocked(sharingApi)).toHaveBeenCalled();
});
});
```
## Test Organization
### Comprehensive Test Coverage
For hooks, ensure you test:
- ✅ Initialization (with/without initial values)
- ✅ Data fetching (success/error cases)
- ✅ State updates and refetching
- ✅ Dependency changes triggering effects
- ✅ Manual actions (refetch, reset)
- ✅ Race condition prevention
- ✅ Cleanup on unmount
- ✅ Mode switching (if applicable)
- ✅ Edge cases (null/undefined data)
### Test Naming
Use descriptive test names that explain the scenario:
- ✅ "initializes with initial count"
- ✅ "fetches response count on mount for regular survey"
- ✅ "cancels previous request when new request is made"
- ❌ "test hook"
- ❌ "it works"

View File

@@ -0,0 +1,7 @@
---
description: Whenever the user asks to write or update a test file for .tsx or .ts files.
globs:
alwaysApply: false
---
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md).
After writing the tests, run them and check if there's any issue with the tests and if all of them are passing. Fix the issues and rerun the tests until all pass.

View File

@@ -93,10 +93,6 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1
# Signup. Disable the ability for new users to create an account.
# Note: This variable is only available to the SaaS setup of Formbricks Cloud. Signup is disable by default for self-hosting.
# SIGNUP_DISABLED=1
# Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1
@@ -158,10 +154,6 @@ NOTION_OAUTH_CLIENT_SECRET=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks
FORMBRICKS_API_HOST=
FORMBRICKS_ENVIRONMENT_ID=
# Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID=
GOOGLE_SHEETS_CLIENT_SECRET=
@@ -180,8 +172,8 @@ ENTERPRISE_LICENSE_KEY=
# Automatically assign new users to a specific organization and role within that organization
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# (Role Management is an Enterprise feature)
# DEFAULT_ORGANIZATION_ID=
# DEFAULT_ORGANIZATION_ROLE=owner
# AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO=
# Send new users to Brevo
# BREVO_API_KEY=
@@ -199,7 +191,6 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379
REDIS_DEFAULT_TTL=86400 # 1 day
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
@@ -207,9 +198,6 @@ REDIS_DEFAULT_TTL=86400 # 1 day
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
# CUSTOM_CACHE_DISABLED=1
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
@@ -223,5 +211,13 @@ UNKEY_ROOT_KEY=
# It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN=
# Disable the user management from UI
# DISABLE_USER_MANAGEMENT
# Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
# AUDIT_LOG_ENABLED=0
# If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0

View File

@@ -49,7 +49,7 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies

View File

@@ -11,10 +11,11 @@ When generating test files inside the "/app/web" path, follow these rules:
- Follow the same test pattern used for other files in the package where the file is located
- All imports should be at the top of the file, not inside individual tests
- For mocking inside "test" blocks use "vi.mocked"
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react"
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
- When mocking data check if the properties added are part of the type of the object being mocked. Don't add properties that are not part of the type.
- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase.
- When mocking data check if the properties added are part of the type of the object being mocked. Only specify known properties, don't use properties that are not part of the type.
If it's a test for a ".tsx" file, follow these extra instructions:
@@ -27,4 +28,5 @@ afterEach(() => {
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
- For click events, import userEvent from "@testing-library/user-event"
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
- You don't need to mock @tolgee/react
- You don't need to mock @tolgee/react
- Use "import "@testing-library/jest-dom/vitest";"

View File

@@ -1,84 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
directory: "/" # Root package.json
schedule:
interval: "weekly"
versioning-strategy: increase
# Apps directory packages
- package-ecosystem: "npm"
directory: "/apps/demo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/demo-react-native"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/storybook"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/web"
schedule:
interval: "weekly"
# Packages directory
- package-ecosystem: "npm"
directory: "/packages/database"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/lib"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/types"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-eslint"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-prettier"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-typescript"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/js-core"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/surveys"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/logger"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -24,7 +24,7 @@ jobs:
egress-policy: audit
- name: Apply labels from linked issue to PR
uses: actions/github-script@211cb3fefb35a799baa5156f9321bb774fe56294 # v5.2.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -10,6 +10,11 @@ jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0

View File

@@ -24,4 +24,4 @@ jobs:
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
VERSION:
description: 'The version of the Docker image to release'
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.'
required: true
type: string
REPOSITORY:
@@ -54,7 +54,7 @@ jobs:
tags: tag:github
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1"
@@ -67,7 +67,7 @@ jobs:
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Prod
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
if: inputs.ENVIRONMENT == 'prod'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -75,6 +75,7 @@ jobs:
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: 'v1.0.0'
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
@@ -84,13 +85,14 @@ jobs:
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Stage
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
if: inputs.ENVIRONMENT == 'stage'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: 'v1.0.0'
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets

View File

@@ -11,6 +11,8 @@ on:
required: false
PLAYWRIGHT_SERVICE_URL:
required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary
workflow_dispatch:
@@ -23,7 +25,6 @@ permissions:
id-token: write
contents: read
actions: read
checks: write
jobs:
build:
@@ -44,19 +45,31 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=5
valkey:
image: valkey/valkey:8.1.1
ports:
- 6379:6379
options: >-
--entrypoint "valkey-server"
--health-cmd="valkey-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
egress-policy: allow
allowed-endpoints: |
ee.formbricks.com:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
- name: Setup Node.js 22.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 20.x
node-version: 22.x
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
@@ -75,7 +88,7 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
echo "" >> .env
echo "E2E_TESTING=1" >> .env
shell: bash
@@ -89,8 +102,18 @@ jobs:
# pnpm prisma migrate deploy
pnpm db:migrate:dev
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
if [ -z "$LICENSE_KEY" ]; then
echo "::error::ENTERPRISE_LICENSE_KEY in .env is empty. Please check your secret configuration."
exit 1
fi
echo "License key length: ${#LICENSE_KEY}"
- name: Run App
run: |
echo "Starting app with enterprise license..."
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
sleep 10 # Optional: gives some buffer for the app to start
for attempt in {1..10}; do

View File

@@ -30,5 +30,5 @@ jobs:
- docker-build
- helm-chart-release
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod"

View File

@@ -1,27 +0,0 @@
name: "Pull Request Labeler"
on:
- pull_request_target
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
labeler:
name: Pull Request Labeler
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594 # v4.3.0
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481
sync-labels: ""

View File

@@ -26,7 +26,7 @@ jobs:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64

View File

@@ -20,18 +20,15 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
outputs:
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}

View File

@@ -4,7 +4,7 @@ on:
workflow_call:
inputs:
VERSION:
description: 'The version of the Helm chart to release'
description: "The version of the Helm chart to release"
required: true
type: string

View File

@@ -40,7 +40,7 @@ jobs:
revert
ossgg
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
# When the previous steps fails, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message.
if: always() && (steps.lint_pr_title.outputs.error_message != null)
@@ -59,7 +59,7 @@ jobs:
# Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
with:
header: pr-title-lint-error
message: |

View File

@@ -29,7 +29,7 @@ jobs:
node-version: 22.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
@@ -48,7 +48,7 @@ jobs:
run: |
pnpm test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@@ -1,8 +1,8 @@
name: 'Terraform'
name: "Terraform"
on:
workflow_dispatch:
# TODO: enable it back when migration is completed.
# TODO: enable it back when migration is completed.
push:
branches:
- main
@@ -14,14 +14,13 @@ on:
paths:
- "infra/terraform/**"
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
@@ -41,7 +40,7 @@ jobs:
tags: tag:github
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1"
@@ -71,7 +70,7 @@ jobs:
working-directory: infra/terraform
- name: Post PR comment
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
with:
token: ${{ github.token }}
@@ -83,4 +82,3 @@ jobs:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply .planfile
working-directory: "infra/terraform"

View File

@@ -1,5 +0,0 @@
NEXT_PUBLIC_FORMBRICKS_API_HOST=http://localhost:3000
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=YOUR_ENVIRONMENT_ID
# Copy the environment ID for the URL of your Formbricks App and
# paste it above to connect your Formbricks App with the Demo App.

View File

@@ -1,7 +0,0 @@
module.exports = {
extends: ["@formbricks/eslint-config/next.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
};

36
apps/demo/.gitignore vendored
View File

@@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,13 +0,0 @@
import { Sidebar } from "./sidebar";
export function LayoutApp({ children }: { children: React.ReactNode }): React.JSX.Element {
return (
<div className="min-h-full">
{/* Static sidebar for desktop */}
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
<Sidebar />
</div>
<div className="flex flex-1 flex-col lg:pl-64">{children}</div>
</div>
);
}

View File

@@ -1,65 +0,0 @@
import {
ClockIcon,
CogIcon,
CreditCardIcon,
FileBarChartIcon,
HelpCircleIcon,
HomeIcon,
ScaleIcon,
ShieldCheckIcon,
UsersIcon,
} from "lucide-react";
import { classNames } from "../lib/utils";
const navigation = [
{ name: "Home", href: "#", icon: HomeIcon, current: true },
{ name: "History", href: "#", icon: ClockIcon, current: false },
{ name: "Balances", href: "#", icon: ScaleIcon, current: false },
{ name: "Cards", href: "#", icon: CreditCardIcon, current: false },
{ name: "Recipients", href: "#", icon: UsersIcon, current: false },
{ name: "Reports", href: "#", icon: FileBarChartIcon, current: false },
];
const secondaryNavigation = [
{ name: "Settings", href: "#", icon: CogIcon },
{ name: "Help", href: "#", icon: HelpCircleIcon },
{ name: "Privacy", href: "#", icon: ShieldCheckIcon },
];
export function Sidebar(): React.JSX.Element {
return (
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pt-5 pb-4">
<nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar">
<div className="space-y-1 px-2">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
className={classNames(
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
{item.name}
</a>
))}
</div>
<div className="mt-6 pt-6">
<div className="space-y-1 px-2">
{secondaryNavigation.map((item) => (
<a
key={item.name}
href={item.href}
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
{item.name}
</a>
))}
</div>
</div>
</nav>
</div>
);
}

View File

@@ -1,23 +0,0 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@custom-variant dark (&:is(.dark *));
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}

View File

@@ -1,3 +0,0 @@
export function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}

View File

@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

View File

@@ -1,17 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "tailwindui.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
};
export default nextConfig;

View File

@@ -1,28 +0,0 @@
{
"name": "@formbricks/demo",
"version": "0.0.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
"dev": "next dev -p 3002 --turbopack",
"go": "next dev -p 3002 --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@formbricks/js": "workspace:*",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/postcss": "4.1.3",
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwindcss": "4.1.3"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*"
}
}

View File

@@ -1,20 +0,0 @@
import type { AppProps } from "next/app";
import Head from "next/head";
import "../globals.css";
export default function App({ Component, pageProps }: AppProps): React.JSX.Element {
return (
<>
<Head>
<title>Demo App</title>
</Head>
{(!process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID ||
!process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) && (
<div className="w-full bg-red-500 p-3 text-center text-sm text-white">
Please set Formbricks environment variables in apps/demo/.env
</div>
)}
<Component {...pageProps} />
</>
);
}

View File

@@ -1,13 +0,0 @@
import { Head, Html, Main, NextScript } from "next/document";
export default function Document(): React.JSX.Element {
return (
<Html lang="en" className="h-full bg-slate-50">
<Head />
<body className="h-full">
<Main />
<NextScript />
</body>
</Html>
);
}

View File

@@ -1,359 +0,0 @@
import Image from "next/image";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import formbricks from "@formbricks/js";
import fbsetup from "../public/fb-setup.png";
declare const window: Window;
export default function AppPage(): React.JSX.Element {
const [darkMode, setDarkMode] = useState(false);
const router = useRouter();
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
const userAttributes = {
"Attribute 1": "one",
"Attribute 2": "two",
"Attribute 3": "three",
};
useEffect(() => {
if (darkMode) {
document.body.classList.add("dark");
} else {
document.body.classList.remove("dark");
}
}, [darkMode]);
useEffect(() => {
const initFormbricks = () => {
// enable Formbricks debug mode by adding formbricksDebug=true GET parameter
const addFormbricksDebugParam = (): void => {
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.has("formbricksDebug")) {
urlParams.set("formbricksDebug", "true");
const newUrl = `${window.location.pathname}?${urlParams.toString()}`;
window.history.replaceState({}, "", newUrl);
}
};
addFormbricksDebugParam();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
void formbricks.setup({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
appUrl: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
});
}
// Connect next.js router to Formbricks
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const handleRouteChange = formbricks.registerRouteChange;
router.events.on("routeChangeComplete", () => {
void handleRouteChange();
});
return () => {
router.events.off("routeChangeComplete", () => {
void handleRouteChange();
});
};
}
};
initFormbricks();
}, [router.events]);
return (
<div className="min-h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex flex-col items-center gap-2 sm:flex-row">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Formbricks In-product Survey Demo App
</h1>
<p className="text-slate-700 dark:text-slate-300">
This app helps you test your app surveys. You can create and test user actions, create and
update user attributes, etc.
</p>
</div>
</div>
<button
type="button"
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
onClick={() => {
setDarkMode(!darkMode);
}}>
{darkMode ? "Toggle Light Mode" : "Toggle Dark Mode"}
</button>
</div>
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">1. Setup .env</h3>
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded-xs" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mr-2 sm:mb-0">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
</strong>
<span className="relative ml-2 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500" />
</span>
</div>
</div>
</div>
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">2. Widget Logs</h3>
<p className="text-slate-700 dark:text-slate-300">
Look at the logs to understand how the widget works.{" "}
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
</p>
</div>
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold dark:text-white">
Set a user ID / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-slate-300">
On formbricks.setUserId() the user state will <strong>be fetched from Formbricks</strong> and
the local state gets <strong>updated with the user state</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
type="button"
onClick={() => {
void formbricks.setUserId(userId);
}}>
Set user ID
</button>
<p className="text-xs text-slate-700 dark:text-slate-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
try again.
</p>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
No-Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
No Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttribute("Plan", "Free");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Plan to &apos;Free&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
attribute
</a>{" "}
&apos;Plan&apos; to &apos;Free&apos;. If the attribute does not exist, it creates it.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttribute("Plan", "Paid");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Plan to &apos;Paid&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
attribute
</a>{" "}
&apos;Plan&apos; to &apos;Paid&apos;. If the attribute does not exist, it creates it.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setEmail("test@web.com");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Email
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
user email
</a>{" "}
&apos;test@web.com&apos;
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttributes(userAttributes);
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Multiple Attributes
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
user attributes
</a>{" "}
to &apos;one&apos;, &apos;two&apos;, &apos;three&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setLanguage("de");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Language to &apos;de&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/general-features/multi-language-surveys"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
language
</a>{" "}
to &apos;de&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.track("code");
}}>
Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.logout();
}}>
Logout
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button logs out the user and syncs the local state with Formbricks. (Only works if a
userId is set)
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="31" fill="none"><g opacity=".9"><path fill="url(#a)" d="M13 .4v29.3H7V6.3h-.2L0 10.5V5L7.2.4H13Z"/><path fill="url(#b)" d="M28.8 30.1c-2.2 0-4-.3-5.7-1-1.7-.8-3-1.8-4-3.1a7.7 7.7 0 0 1-1.4-4.6h6.2c0 .8.3 1.4.7 2 .4.5 1 .9 1.7 1.2.7.3 1.6.4 2.5.4 1 0 1.7-.2 2.5-.5.7-.3 1.3-.8 1.7-1.4.4-.6.6-1.2.6-2s-.2-1.5-.7-2.1c-.4-.6-1-1-1.8-1.4-.8-.4-1.8-.5-2.9-.5h-2.7v-4.6h2.7a6 6 0 0 0 2.5-.5 4 4 0 0 0 1.7-1.3c.4-.6.6-1.3.6-2a3.5 3.5 0 0 0-2-3.3 5.6 5.6 0 0 0-4.5 0 4 4 0 0 0-1.7 1.2c-.4.6-.6 1.2-.6 2h-6c0-1.7.6-3.2 1.5-4.5 1-1.3 2.2-2.3 3.8-3C25 .4 26.8 0 28.8 0s3.8.4 5.3 1.1c1.5.7 2.7 1.7 3.6 3a7.2 7.2 0 0 1 1.2 4.2c0 1.6-.5 3-1.5 4a7 7 0 0 1-4 2.2v.2c2.2.3 3.8 1 5 2.2a6.4 6.4 0 0 1 1.6 4.6c0 1.7-.5 3.1-1.4 4.4a9.7 9.7 0 0 1-4 3.1c-1.7.8-3.7 1.1-5.8 1.1Z"/></g><defs><linearGradient id="a" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient><linearGradient id="b" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Before

Width:  |  Height:  |  Size: 629 B

View File

@@ -1,5 +0,0 @@
{
"exclude": ["node_modules"],
"extends": "@formbricks/config-typescript/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}

View File

@@ -11,13 +11,10 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.19",
"react": "19.1.0",
"react-dom": "19.1.0"
"eslint-plugin-react-refresh": "0.4.20"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
@@ -27,14 +24,13 @@
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.2",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
"storybook": "8.6.12",
"tsup": "8.4.0",
"vite": "6.2.5"
"vite": "6.3.5"
}
}

View File

@@ -18,8 +18,9 @@ FROM node:22-alpine3.21 AS base
FROM base AS installer
# Enable corepack and prepare pnpm
RUN npm install -g corepack@latest
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
@@ -59,7 +60,7 @@ COPY . .
RUN touch apps/web/.env
# Install the dependencies
RUN pnpm install
RUN pnpm install --ignore-scripts
# Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers
@@ -75,7 +76,7 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
#
FROM base AS runner
RUN npm install -g corepack@latest
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN apk add --no-cache curl \
@@ -141,12 +142,13 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
RUN npm install -g prisma
EXPOSE 3000
ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV="production"
# USER nextjs
USER nextjs
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/

View File

@@ -0,0 +1,79 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ConnectWithFormbricks } from "./ConnectWithFormbricks";
// Mocks before import
const pushMock = vi.fn();
const refreshMock = vi.fn();
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock, refresh: refreshMock })) }));
vi.mock("./OnboardingSetupInstructions", () => ({
OnboardingSetupInstructions: () => <div data-testid="instructions" />,
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("ConnectWithFormbricks", () => {
const environment = { id: "env1" } as any;
const webAppUrl = "http://app";
const channel = {} as any;
test("renders waiting state when widgetSetupCompleted is false", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={false}
channel={channel}
/>
);
expect(screen.getByTestId("instructions")).toBeInTheDocument();
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
});
test("renders success state when widgetSetupCompleted is true", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={true}
channel={channel}
/>
);
expect(screen.getByText("environments.connect.congrats")).toBeInTheDocument();
expect(screen.getByText("environments.connect.connection_successful_message")).toBeInTheDocument();
});
test("clicking finish button navigates to surveys", async () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={true}
channel={channel}
/>
);
const button = screen.getByRole("button", { name: "environments.connect.finish_onboarding" });
await userEvent.click(button);
expect(pushMock).toHaveBeenCalledWith(`/environments/${environment.id}/surveys`);
});
test("refresh is called on visibilitychange to visible", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={false}
channel={channel}
/>
);
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
expect(refreshMock).toHaveBeenCalled();
});
});

View File

@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -0,0 +1,147 @@
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import OnboardingLayout from "./layout";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
PRIVACY_URL: "http://localhost:3000/privacy",
TERMS_URL: "http://localhost:3000/terms",
IMPRINT_URL: "http://localhost:3000/imprint",
IMPRINT_ADDRESS: "Mock Address",
PASSWORD_RESET_DISABLED: false,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: false,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
SAML_XML_DIR: "./mock-saml-connection",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
INVITE_DISABLED: false,
SLACK_CLIENT_SECRET: "mock-slack-secret",
SLACK_CLIENT_ID: "mock-slack-id",
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
AIRTABLE_CLIENT_ID: "mock-airtable-id",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "587",
SMTP_SECURE_ENABLED: false,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SMTP_AUTHENTICATED: true,
SMTP_REJECT_UNAUTHORIZED_TLS: true,
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
NEXTAUTH_SECRET: "mock-nextauth-secret",
ITEMS_PER_PAGE: 30,
SURVEYS_PER_PAGE: 12,
RESPONSES_PER_PAGE: 25,
TEXT_RESPONSES_PER_PAGE: 5,
INSIGHTS_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
MAX_OTHER_OPTION_LENGTH: 250,
ENTERPRISE_LICENSE_KEY: "ABC",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
AZURE_ID: "mock-azure-id",
AZUREAD_CLIENT_ID: "mock-azure-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
OIDC_ID: "mock-oidc-id",
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
SAML_ID: "mock-saml-id",
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
OIDC_DISPLAY_NAME: "Mock OIDC",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
describe("OnboardingLayout", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("redirects to login if session is missing", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
await OnboardingLayout({
params: { environmentId: "env1" },
children: <div>Test Content</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("throws AuthorizationError if user lacks access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
OnboardingLayout({
params: { environmentId: "env1" },
children: <div>Test Content</div>,
})
).rejects.toThrow("User is not authorized to access this environment");
});
test("renders children if user has access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
const result = await OnboardingLayout({
params: { environmentId: "env1" },
children: <div data-testid="child">Test Content</div>,
});
render(result);
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
});
});

View File

@@ -16,7 +16,7 @@ const OnboardingLayout = async (props) => {
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!isAuthorized) {
throw AuthorizationError;
throw new AuthorizationError("User is not authorized to access this environment");
}
return <div className="flex-1 bg-slate-50">{children}</div>;

View File

@@ -0,0 +1,76 @@
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { XMTemplateList } from "./XMTemplateList";
// Prepare push mock and module mocks before importing component
const pushMock = vi.fn();
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock })) }));
vi.mock("react-hot-toast", () => ({ default: { error: vi.fn() } }));
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates", () => ({
getXMTemplates: (t: any) => [
{ id: 1, name: "tmpl1" },
{ id: 2, name: "tmpl2" },
],
}));
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils", () => ({
replacePresetPlaceholders: (template: any, project: any) => ({ ...template, projectId: project.id }),
}));
vi.mock("@/modules/survey/components/template-list/actions", () => ({ createSurveyAction: vi.fn() }));
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
<div>
{options.map((opt, idx) => (
<button key={idx} data-testid={`option-${idx}`} onClick={opt.onClick}>
{opt.title}
</button>
))}
</div>
),
}));
// Reset mocks between tests
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("XMTemplateList component", () => {
const project = { id: "proj1" } as any;
const user = { id: "user1" } as any;
const environmentId = "env1";
test("creates survey and navigates on success", async () => {
// Mock successful survey creation
vi.mocked(createSurveyAction).mockResolvedValue({ data: { id: "survey1" } } as any);
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
const option0 = screen.getByTestId("option-0");
await userEvent.click(option0);
expect(createSurveyAction).toHaveBeenCalledWith({
environmentId,
surveyBody: expect.objectContaining({ id: 1, projectId: "proj1", type: "link", createdBy: "user1" }),
});
expect(pushMock).toHaveBeenCalledWith(`/environments/${environmentId}/surveys/survey1/edit?mode=cx`);
});
test("shows error toast on failure", async () => {
// Mock failed survey creation
vi.mocked(createSurveyAction).mockResolvedValue({ error: "err" } as any);
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
const option1 = screen.getByTestId("option-1");
await userEvent.click(option1);
expect(createSurveyAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("formatted-error");
});
});

View File

@@ -0,0 +1,80 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TXMTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "./utils";
// Mock data
const mockProject: TProject = {
id: "project1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
organizationId: "org1",
styling: {
allowStyleOverwrite: true,
brandColor: { light: "#FFFFFF" },
},
recontactDays: 30,
inAppSurveyBranding: true,
linkSurveyBranding: true,
config: {
channel: "link" as const,
industry: "eCommerce" as "eCommerce" | "saas" | "other" | null,
},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
languages: [],
logo: null,
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
questions: [
{
id: "q1",
inputType: "text",
type: "email" as any,
headline: { default: "$[projectName] Question" },
required: false,
charLimit: { enabled: true, min: 400, max: 1000 },
},
],
endings: [
{
id: "e1",
type: "endScreen",
headline: { default: "Thank you for completing the survey!" },
},
],
styling: {
brandColor: { light: "#0000FF" },
questionColor: { light: "#00FF00" },
inputColor: { light: "#FF0000" },
},
};
describe("replacePresetPlaceholders", () => {
afterEach(() => {
cleanup();
});
test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Project Survey");
});
test("replaces projectName placeholder in question headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.questions[0].headline.default).toBe("Test Project Question");
});
test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate);
});
});

View File

@@ -0,0 +1,60 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/preact";
import { TFnType } from "@tolgee/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { getXMSurveyDefault, getXMTemplates } from "./xm-templates";
vi.mock("@formbricks/logger", () => ({
logger: { error: vi.fn() },
}));
describe("xm-templates", () => {
afterEach(() => {
cleanup();
});
test("getXMSurveyDefault returns default survey template", () => {
const tMock = vi.fn((key) => key) as TFnType;
const result = getXMSurveyDefault(tMock);
expect(result).toEqual({
name: "",
endings: expect.any(Array),
questions: [],
styling: {
overwriteThemeStyling: true,
},
});
expect(result.endings).toHaveLength(1);
});
test("getXMTemplates returns all templates", () => {
const tMock = vi.fn((key) => key) as TFnType;
const result = getXMTemplates(tMock);
expect(result).toHaveLength(6);
expect(result[0].name).toBe("templates.nps_survey_name");
expect(result[1].name).toBe("templates.star_rating_survey_name");
expect(result[2].name).toBe("templates.csat_survey_name");
expect(result[3].name).toBe("templates.cess_survey_name");
expect(result[4].name).toBe("templates.smileys_survey_name");
expect(result[5].name).toBe("templates.enps_survey_name");
});
test("getXMTemplates handles errors gracefully", async () => {
const tMock = vi.fn(() => {
throw new Error("Test error");
}) as TFnType;
const result = getXMTemplates(tMock);
// Dynamically import the mocked logger
const { logger } = await import("@formbricks/logger");
expect(result).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Unable to load XM templates, returning empty array"
);
});
});

View File

@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -0,0 +1,44 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getTeamsByOrganizationId } from "./onboarding";
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
findMany: vi.fn(),
},
},
}));
describe("getTeamsByOrganizationId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped teams", async () => {
const mockTeams = [
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1");
expect(result).toEqual([
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
test("throws error on unknown error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("fail"));
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow("fail");
});
});

View File

@@ -1,8 +1,6 @@
"use server";
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
import { cache } from "@/lib/cache";
import { teamCache } from "@/lib/cache/team";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
@@ -11,38 +9,31 @@ import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const getTeamsByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationTeam[] | null> =>
cache(
async () => {
validateInputs([organizationId, ZId]);
try {
const teams = await prisma.team.findMany({
where: {
organizationId,
},
select: {
id: true,
name: true,
},
});
async (organizationId: string): Promise<TOrganizationTeam[] | null> => {
validateInputs([organizationId, ZId]);
try {
const teams = await prisma.team.findMany({
where: {
organizationId,
},
select: {
id: true,
name: true,
},
});
const projectTeams = teams.map((team) => ({
id: team.id,
name: team.name,
}));
const projectTeams = teams.map((team) => ({
id: team.id,
name: team.name,
}));
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getTeamsByOrganizationId-${organizationId}`],
{
tags: [teamCache.tag.byOrganizationId(organizationId)],
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
)()
throw error;
}
}
);

View File

@@ -0,0 +1,99 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { LandingSidebar } from "./landing-sidebar";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Module mocks must be declared before importing the component
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
}));
// Mock our useSignOut hook
const mockSignOut = vi.fn();
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: () => ({
signOut: mockSignOut,
}),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) => (
<div data-testid={open ? "modal-open" : "modal-closed"} />
),
}));
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: ({ userId }: { userId: string }) => <div data-testid="avatar">{userId}</div>,
}));
// Ensure mocks are reset between tests
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("LandingSidebar component", () => {
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
const organization = { id: "o1", name: "orgOne" } as any;
const organizations = [
{ id: "o2", name: "betaOrg" },
{ id: "o1", name: "alphaOrg" },
] as any;
test("renders logo, avatar, and initial modal closed", () => {
render(
<LandingSidebar
isMultiOrgEnabled={false}
user={user}
organization={organization}
organizations={organizations}
/>
);
// Formbricks logo
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
// Profile avatar
expect(screen.getByTestId("avatar")).toHaveTextContent("u1");
// CreateOrganizationModal should be closed initially
expect(screen.getByTestId("modal-closed")).toBeInTheDocument();
});
test("clicking logout triggers signOut", async () => {
render(
<LandingSidebar
isMultiOrgEnabled={false}
user={user}
organization={organization}
organizations={organizations}
/>
);
// Open user dropdown by clicking on avatar trigger
const trigger = screen.getByTestId("avatar").parentElement;
if (trigger) await userEvent.click(trigger);
// Click logout menu item
const logoutItem = await screen.findByText("common.logout");
await userEvent.click(logoutItem);
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "o1",
redirect: true,
callbackUrl: "/auth/login",
});
});
});

View File

@@ -1,9 +1,9 @@
"use client";
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import {
@@ -21,7 +21,6 @@ import {
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -45,6 +44,7 @@ export const LandingSidebar = ({
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
const { t } = useTranslate();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const router = useRouter();
@@ -124,8 +124,13 @@ export const LandingSidebar = ({
<DropdownMenuItem
onClick={async () => {
await signOut({ callbackUrl: "/auth/login" });
await formbricksLogout();
await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: true,
callbackUrl: "/auth/login",
});
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -0,0 +1,187 @@
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/preact";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import LandingLayout from "./layout";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
PRIVACY_URL: "http://localhost:3000/privacy",
TERMS_URL: "http://localhost:3000/terms",
IMPRINT_URL: "http://localhost:3000/imprint",
IMPRINT_ADDRESS: "Mock Address",
PASSWORD_RESET_DISABLED: false,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: false,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
SAML_XML_DIR: "./mock-saml-connection",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
INVITE_DISABLED: false,
SLACK_CLIENT_SECRET: "mock-slack-secret",
SLACK_CLIENT_ID: "mock-slack-id",
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
AIRTABLE_CLIENT_ID: "mock-airtable-id",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "587",
SMTP_SECURE_ENABLED: false,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SMTP_AUTHENTICATED: true,
SMTP_REJECT_UNAUTHORIZED_TLS: true,
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
NEXTAUTH_SECRET: "mock-nextauth-secret",
ITEMS_PER_PAGE: 30,
SURVEYS_PER_PAGE: 12,
RESPONSES_PER_PAGE: 25,
TEXT_RESPONSES_PER_PAGE: 5,
INSIGHTS_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
MAX_OTHER_OPTION_LENGTH: 250,
ENTERPRISE_LICENSE_KEY: "ABC",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
AZURE_ID: "mock-azure-id",
AZUREAD_CLIENT_ID: "mock-azure-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
OIDC_ID: "mock-oidc-id",
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
SAML_ID: "mock-saml-id",
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
OIDC_DISPLAY_NAME: "Mock OIDC",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/environment/service");
vi.mock("@/lib/membership/service");
vi.mock("@/lib/project/service");
vi.mock("next-auth");
vi.mock("next/navigation");
afterEach(() => {
cleanup();
});
describe("LandingLayout", () => {
test("redirects to login if no session exists", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
await LandingLayout(props);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/auth/login");
});
test("returns notFound if no membership is found", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
await LandingLayout(props);
expect(vi.mocked(notFound)).toHaveBeenCalled();
});
test("redirects to production environment if available", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
organizationId: "org-123",
userId: "user-123",
accepted: true,
role: "owner",
});
vi.mocked(getUserProjects).mockResolvedValue([
{
id: "proj-123",
organizationId: "org-123",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-02"),
name: "Project 1",
styling: { allowStyleOverwrite: true },
recontactDays: 30,
inAppSurveyBranding: true,
linkSurveyBranding: true,
} as any,
]);
vi.mocked(getEnvironments).mockResolvedValue([
{
id: "env-123",
type: "production",
projectId: "proj-123",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-02"),
appSetupCompleted: true,
},
]);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
await LandingLayout(props);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/");
});
test("renders children if no projects or production environment exist", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
organizationId: "org-123",
userId: "user-123",
accepted: true,
role: "owner",
});
vi.mocked(getUserProjects).mockResolvedValue([]);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
const result = await LandingLayout(props);
expect(result).toEqual(
<>
<div>Child Content</div>
</>
);
});
});

View File

@@ -0,0 +1,200 @@
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { notFound, redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
PRIVACY_URL: "http://localhost:3000/privacy",
TERMS_URL: "http://localhost:3000/terms",
IMPRINT_URL: "http://localhost:3000/imprint",
IMPRINT_ADDRESS: "Mock Address",
PASSWORD_RESET_DISABLED: false,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: false,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
SAML_XML_DIR: "./mock-saml-connection",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
INVITE_DISABLED: false,
SLACK_CLIENT_SECRET: "mock-slack-secret",
SLACK_CLIENT_ID: "mock-slack-id",
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
AIRTABLE_CLIENT_ID: "mock-airtable-id",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "587",
SMTP_SECURE_ENABLED: false,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SMTP_AUTHENTICATED: true,
SMTP_REJECT_UNAUTHORIZED_TLS: true,
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
NEXTAUTH_SECRET: "mock-nextauth-secret",
ITEMS_PER_PAGE: 30,
SURVEYS_PER_PAGE: 12,
RESPONSES_PER_PAGE: 25,
TEXT_RESPONSES_PER_PAGE: 5,
INSIGHTS_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
MAX_OTHER_OPTION_LENGTH: 250,
ENTERPRISE_LICENSE_KEY: "ABC",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
AZURE_ID: "mock-azure-id",
AZUREAD_CLIENT_ID: "mock-azure-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
OIDC_ID: "mock-oidc-id",
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
SAML_ID: "mock-saml-id",
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
OIDC_DISPLAY_NAME: "Mock OIDC",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
LandingSidebar: () => <div data-testid="landing-sidebar" />,
}));
vi.mock("@/modules/organization/lib/utils");
vi.mock("@/lib/user/service");
vi.mock("@/lib/organization/service");
vi.mock("@/tolgee/server");
vi.mock("next/navigation", () => ({
redirect: vi.fn(() => "REDIRECT_STUB"),
notFound: vi.fn(() => "NOT_FOUND_STUB"),
}));
// Mock the React cache function
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: (fn: any) => fn,
};
});
describe("Page component", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.resetModules();
});
test("redirects to login if no user session", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } });
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(result).toBe("REDIRECT_STUB");
});
test("returns notFound if user does not exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({
session: { user: { id: "user1" } },
organization: {},
} as any);
vi.mocked(getUser).mockResolvedValue(null);
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } });
expect(notFound).toHaveBeenCalled();
expect(result).toBe("NOT_FOUND_STUB");
});
test("renders header and sidebar for authenticated user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({
session: { user: { id: "user1" } },
organization: { id: "org1" },
} as any);
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
typeof props === "string" ? props : props.key || ""
);
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const element = await Page({ params: { organizationId: "org1" } });
render(element as React.ReactElement);
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";

View File

@@ -34,6 +34,9 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next-auth", () => ({

View File

@@ -0,0 +1,88 @@
import { getUserProjects } from "@/lib/project/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
const mockTranslate = vi.fn((key) => key);
// Module mocks must be declared before importing the component
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn(() => "REDIRECT_STUB") }));
vi.mock("@/modules/ui/components/header", () => ({
Header: ({ title, subtitle }: { title: string; subtitle: string }) => (
<div>
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
),
}));
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
<div data-testid="options">{options.map((o) => o.title).join(",")}</div>
),
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => <a href={href}>{children}</a>,
}));
describe("Page component", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const params = Promise.resolve({ organizationId: "org1" });
test("redirects to login if no user session", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {} } as any);
const result = await Page({ params });
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(result).toBe("REDIRECT_STUB");
});
test("renders header, options, and close button when projects exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValue([{ id: 1 }] as any);
const element = await Page({ params });
render(element as React.ReactElement);
// Header title and subtitle
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"organizations.projects.new.channel.channel_select_title"
);
expect(
screen.getByText("organizations.projects.new.channel.channel_select_subtitle")
).toBeInTheDocument();
// Options container with correct titles
expect(screen.getByTestId("options")).toHaveTextContent(
"organizations.projects.new.channel.link_and_email_surveys," +
"organizations.projects.new.channel.in_product_surveys"
);
// Close button link rendered when projects >=1
const closeLink = screen.getByRole("link");
expect(closeLink).toHaveAttribute("href", "/");
});
test("does not render close button when no projects", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValue([]);
const element = await Page({ params });
render(element as React.ReactElement);
expect(screen.queryByRole("link")).toBeNull();
});
});

View File

@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -0,0 +1,223 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import OnboardingLayout from "./layout";
// Mock environment variables
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
// Mock dependencies
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganization: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
getOrganizationProjectsCount: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
describe("OnboardingLayout", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("redirects to login if no session", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("returns not found if user is member or billing", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "member",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(notFound).toHaveBeenCalled();
});
test("throws error if organization is not found", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getOrganization).mockResolvedValue(null);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found");
});
test("redirects to home if project limit is reached", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
isAIEnabled: false,
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
};
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(redirect).toHaveBeenCalledWith("/");
});
test("renders children when all conditions are met", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
isAIEnabled: false,
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
};
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
const result = await OnboardingLayout(props);
expect(result).toEqual(<>{props.children}</>);
});
});

View File

@@ -0,0 +1,72 @@
import { getUserProjects } from "@/lib/project/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
const mockTranslate = vi.fn((key) => key);
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
vi.mock("next/link", () => ({
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
}));
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
OnboardingOptionsContainer: ({ options }: any) => (
<div data-testid="options">{options.map((o: any) => o.title).join(",")}</div>
),
}));
vi.mock("@/modules/ui/components/header", () => ({ Header: ({ title }: any) => <h1>{title}</h1> }));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
describe("Mode Page", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const params = Promise.resolve({ organizationId: "org1" });
test("redirects to login if no session user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
await Page({ params });
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("renders header and options without close link when no projects", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
const element = await Page({ params });
render(element as React.ReactElement);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"organizations.projects.new.mode.what_are_you_here_for"
);
expect(screen.getByTestId("options")).toHaveTextContent(
"organizations.projects.new.mode.formbricks_surveys," + "organizations.projects.new.mode.formbricks_cx"
);
expect(screen.queryByRole("link")).toBeNull();
});
test("renders close link when projects exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" } as any]);
const element = await Page({ params });
render(element as React.ReactElement);
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/");
});
});

View File

@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -0,0 +1,124 @@
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ProjectSettings } from "./ProjectSettings";
// Mocks before imports
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
vi.mock("react-hot-toast", () => ({ toast: { error: vi.fn() } }));
vi.mock("@/app/(app)/environments/[environmentId]/actions", () => ({ createProjectAction: vi.fn() }));
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
vi.mock("@/modules/ui/components/color-picker", () => ({
ColorPicker: ({ color, onChange }: any) => (
<button data-testid="color-picker" onClick={() => onChange("#000")}>
{color}
</button>
),
}));
vi.mock("@/modules/ui/components/input", () => ({
Input: ({ value, onChange, placeholder }: any) => (
<input placeholder={placeholder} value={value} onChange={(e) => onChange((e.target as any).value)} />
),
}));
vi.mock("@/modules/ui/components/multi-select", () => ({
MultiSelect: ({ value, options, onChange }: any) => (
<select
data-testid="multi-select"
multiple
value={value}
onChange={(e) => onChange(Array.from((e.target as any).selectedOptions).map((o: any) => o.value))}>
{options.map((o: any) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
),
}));
vi.mock("@/modules/ui/components/survey", () => ({
SurveyInline: () => <div data-testid="survey-inline" />,
}));
vi.mock("@/lib/templates", () => ({ previewSurvey: () => ({}) }));
vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({
CreateTeamModal: ({ open }: any) => <div data-testid={open ? "team-modal-open" : "team-modal-closed"} />,
}));
// Clean up after each test
afterEach(() => {
cleanup();
vi.clearAllMocks();
localStorage.clear();
});
describe("ProjectSettings component", () => {
const baseProps = {
organizationId: "org1",
projectMode: "cx",
industry: "ind",
defaultBrandColor: "#fff",
organizationTeams: [],
canDoRoleManagement: false,
userProjectsCount: 0,
} as any;
const fillAndSubmit = async () => {
const nameInput = screen.getByPlaceholderText("e.g. Formbricks");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "TestProject");
const nextButton = screen.getByRole("button", { name: "common.next" });
await userEvent.click(nextButton);
};
test("successful createProject for link channel navigates to surveys and clears localStorage", async () => {
(createProjectAction as any).mockResolvedValue({
data: { environments: [{ id: "env123", type: "production" }] },
});
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
await fillAndSubmit();
expect(createProjectAction).toHaveBeenCalledWith({
organizationId: "org1",
data: expect.objectContaining({ teamIds: [] }),
});
expect(pushMock).toHaveBeenCalledWith("/environments/env123/surveys");
expect(localStorage.getItem("FORMBRICKS_SURVEYS_FILTERS_KEY_LS")).toBeNull();
});
test("successful createProject for app channel navigates to connect", async () => {
(createProjectAction as any).mockResolvedValue({
data: { environments: [{ id: "env456", type: "production" }] },
});
render(<ProjectSettings {...baseProps} channel="app" projectMode="cx" />);
await fillAndSubmit();
expect(pushMock).toHaveBeenCalledWith("/environments/env456/connect");
});
test("successful createProject for cx mode navigates to xm-templates when channel is neither link nor app", async () => {
(createProjectAction as any).mockResolvedValue({
data: { environments: [{ id: "env789", type: "production" }] },
});
render(<ProjectSettings {...baseProps} channel="unknown" projectMode="cx" />);
await fillAndSubmit();
expect(pushMock).toHaveBeenCalledWith("/environments/env789/xm-templates");
});
test("shows error toast on createProject error response", async () => {
(createProjectAction as any).mockResolvedValue({ error: "err" });
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
await fillAndSubmit();
expect(toast.error).toHaveBeenCalledWith("formatted-error");
});
test("shows error toast on exception", async () => {
(createProjectAction as any).mockImplementation(() => {
throw new Error("fail");
});
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
await fillAndSubmit();
expect(toast.error).toHaveBeenCalledWith("organizations.projects.new.settings.project_creation_failed");
});
});

View File

@@ -225,7 +225,7 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
height={56}
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>

View File

@@ -0,0 +1,106 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
// Mocks before component import
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
vi.mock("next/link", () => ({
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
}));
vi.mock("@/modules/ui/components/header", () => ({
Header: ({ title, subtitle }: any) => (
<div>
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock(
"@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings",
() => ({
ProjectSettings: (props: any) => <div data-testid="project-settings" data-mode={props.projectMode} />,
})
);
// Cleanup after each test
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("ProjectSettingsPage", () => {
const params = Promise.resolve({ organizationId: "org1" });
const searchParams = Promise.resolve({ channel: "link", industry: "other", mode: "cx" } as any);
test("redirects to login when no session user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
await Page({ params, searchParams });
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("throws when teams not found", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
session: { user: { id: "u1" } },
organization: { billing: { plan: "basic" } },
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
});
test("renders header, settings and close link when projects exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
session: { user: { id: "u1" } },
organization: { billing: { plan: "basic" } },
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);
// Header
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"organizations.projects.new.settings.project_settings_title"
);
// ProjectSettings stub receives mode prop
expect(screen.getByTestId("project-settings")).toHaveAttribute("data-mode", "cx");
// Close link for existing projects
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/");
});
test("renders without close link when no projects", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
session: { user: { id: "u1" } },
organization: { billing: { plan: "basic" } },
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);
expect(screen.queryByRole("link")).toBeNull();
});
});

View File

@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -0,0 +1,106 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Home, Settings } from "lucide-react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { OnboardingOptionsContainer } from "./OnboardingOptionsContainer";
describe("OnboardingOptionsContainer", () => {
afterEach(() => {
cleanup();
});
test("renders options with links", () => {
const options = [
{
title: "Test Option",
description: "Test Description",
icon: Home,
href: "/test",
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Test Option")).toBeInTheDocument();
expect(screen.getByText("Test Description")).toBeInTheDocument();
});
test("renders options with onClick handler", () => {
const onClickMock = vi.fn();
const options = [
{
title: "Click Option",
description: "Click Description",
icon: Home,
onClick: onClickMock,
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Click Option")).toBeInTheDocument();
expect(screen.getByText("Click Description")).toBeInTheDocument();
});
test("renders options with iconText", () => {
const options = [
{
title: "Icon Text Option",
description: "Icon Text Description",
icon: Home,
iconText: "Custom Icon Text",
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Custom Icon Text")).toBeInTheDocument();
});
test("renders options with loading state", () => {
const options = [
{
title: "Loading Option",
description: "Loading Description",
icon: Home,
isLoading: true,
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Loading Option")).toBeInTheDocument();
});
test("renders multiple options", () => {
const options = [
{
title: "First Option",
description: "First Description",
icon: Home,
},
{
title: "Second Option",
description: "Second Description",
icon: Settings,
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("First Option")).toBeInTheDocument();
expect(screen.getByText("Second Option")).toBeInTheDocument();
});
test("calls onClick handler when clicking an option", async () => {
const onClickMock = vi.fn();
const options = [
{
title: "Click Option",
description: "Click Description",
icon: Home,
onClick: onClickMock,
},
];
render(<OnboardingOptionsContainer options={options} />);
await userEvent.click(screen.getByText("Click Option"));
expect(onClickMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,81 +0,0 @@
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient";
// Mock next/navigation hooks.
vi.mock("next/navigation", () => ({
usePathname: () => "/test-path",
useSearchParams: () => new URLSearchParams("foo=bar"),
}));
// Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true,
}));
// Mock the Formbricks SDK module.
vi.mock("@formbricks/js", () => ({
__esModule: true,
default: {
setup: vi.fn(),
setUserId: vi.fn(),
setEmail: vi.fn(),
registerRouteChange: vi.fn(),
},
}));
describe("FormbricksClient", () => {
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(
<FormbricksClient
userId="user-123"
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Expect the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({
environmentId: "env-test",
appUrl: "https://api.test.com",
});
expect(mockSetUserId).toHaveBeenCalledWith("user-123");
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
// And the second effect should always register the route change when Formbricks is enabled.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(
<FormbricksClient
userId=""
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled();
expect(mockSetUserId).not.toHaveBeenCalled();
expect(mockSetEmail).not.toHaveBeenCalled();
// The second effect only checks formbricksEnabled, so registerRouteChange should be called.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
});

View File

@@ -1,44 +0,0 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import formbricks from "@formbricks/js";
interface FormbricksClientProps {
userId: string;
email: string;
formbricksEnvironmentId?: string;
formbricksApiHost?: string;
formbricksEnabled?: boolean;
}
export const FormbricksClient = ({
userId,
email,
formbricksEnvironmentId,
formbricksApiHost,
formbricksEnabled,
}: FormbricksClientProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (formbricksEnabled && userId) {
formbricks.setup({
environmentId: formbricksEnvironmentId ?? "",
appUrl: formbricksApiHost ?? "",
});
formbricks.setUserId(userId);
formbricks.setEmail(email);
}
}, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]);
useEffect(() => {
if (formbricksEnabled) {
formbricks.registerRouteChange();
}
}, [pathname, searchParams, formbricksEnabled]);
return null;
};

View File

@@ -0,0 +1,37 @@
import { SingleContactPage } from "@/modules/ee/contacts/[contactId]/page";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
// mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
ENCRYPTION_KEY: "test",
ENTERPRISE_LICENSE_KEY: "test",
GITHUB_ID: "test",
GITHUB_SECRET: "test",
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
WEBAPP_URL: "mock-webapp-url",
IS_PRODUCTION: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: "redis://localhost:6379",
}));
describe("Contact Page Re-export", () => {
test("should re-export SingleContactPage", () => {
expect(Page).toBe(SingleContactPage);
});
});

View File

@@ -0,0 +1,15 @@
import { ContactsPage } from "@/modules/ee/contacts/page";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
// Mock the actual ContactsPage component
vi.mock("@/modules/ee/contacts/page", () => ({
ContactsPage: () => <div data-testid="contacts-page">Mock Contacts Page</div>,
}));
describe("Contacts Page Re-export", () => {
test("should re-export ContactsPage from the EE module", () => {
// Assert that the default export 'Page' is the same as the mocked 'ContactsPage'
expect(Page).toBe(ContactsPage);
});
});

View File

@@ -0,0 +1,18 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import SegmentsPageWrapper from "./page";
vi.mock("@/modules/ee/contacts/segments/page", () => ({
SegmentsPage: vi.fn(() => <div>SegmentsPageMock</div>),
}));
describe("SegmentsPageWrapper", () => {
afterEach(() => {
cleanup();
});
test("renders the SegmentsPage component", () => {
render(<SegmentsPageWrapper params={{ environmentId: "test-env" } as any} />);
expect(screen.getByText("SegmentsPageMock")).toBeInTheDocument();
});
});

View File

@@ -4,7 +4,9 @@ import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getOrganizationProjectsLimit,
getRoleManagementPermission,
@@ -20,62 +22,69 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput,
});
export const createProjectAction = authenticatedActionClient
.schema(ZCreateProjectAction)
.action(async ({ parsedInput, ctx }) => {
const { user } = ctx;
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization project limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization project limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
return project;
});
)
);

View File

@@ -1,11 +1,12 @@
"use server";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { cache } from "@/lib/cache";
import { getSurveysByActionClassId } from "@/lib/survey/service";
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
@@ -15,63 +16,80 @@ const ZDeleteActionClassAction = z.object({
actionClassId: ZId,
});
export const deleteActionClassAction = authenticatedActionClient
.schema(ZDeleteActionClassAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
await deleteActionClass(parsedInput.actionClassId);
});
export const deleteActionClassAction = authenticatedActionClient.schema(ZDeleteActionClassAction).action(
withAuditLogging(
"deleted",
"actionClass",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
ctx.auditLoggingCtx.oldObject = await getActionClass(parsedInput.actionClassId);
return await deleteActionClass(parsedInput.actionClassId);
}
)
);
const ZUpdateActionClassAction = z.object({
actionClassId: ZId,
updatedAction: ZActionClassInput,
});
export const updateActionClassAction = authenticatedActionClient
.schema(ZUpdateActionClassAction)
.action(async ({ ctx, parsedInput }) => {
const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
export const updateActionClassAction = authenticatedActionClient.schema(ZUpdateActionClassAction).action(
withAuditLogging(
"updated",
"actionClass",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
}
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
ctx.auditLoggingCtx.oldObject = actionClass;
const result = await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
ctx.auditLoggingCtx.newObject = result;
return result;
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
return await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
});
)
);
const ZGetActiveInactiveSurveysAction = z.object({
actionClassId: ZId,
@@ -104,31 +122,24 @@ export const getActiveInactiveSurveysAction = authenticatedActionClient
return response;
});
const getLatestStableFbRelease = async (): Promise<string | null> =>
cache(
async () => {
try {
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
const releases = await res.json();
const getLatestStableFbRelease = async (): Promise<string | null> => {
try {
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
const releases = await res.json();
if (Array.isArray(releases)) {
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
?.tag_name as string;
if (latestStableReleaseTag) {
return latestStableReleaseTag;
}
}
return null;
} catch (err) {
return null;
if (Array.isArray(releases)) {
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
?.tag_name as string;
if (latestStableReleaseTag) {
return latestStableReleaseTag;
}
},
["latest-fb-release"],
{
revalidate: 60 * 60 * 24, // 24 hours
}
)();
return null;
} catch (err) {
return null;
}
};
export const getLatestStableFbReleaseAction = actionClient.action(async () => {
return await getLatestStableFbRelease();

View File

@@ -0,0 +1,343 @@
import { createActionClassAction } from "@/modules/survey/editor/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { getActiveInactiveSurveysAction } from "../actions";
import { ActionActivityTab } from "./ActionActivityTab";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
ACTION_TYPE_ICON_LOOKUP: {
noCode: <div>NoCodeIcon</div>,
automatic: <div>AutomaticIcon</div>,
code: <div>CodeIcon</div>,
},
}));
vi.mock("@/lib/time", () => ({
convertDateTimeStringShort: (dateString: string) => `formatted-${dateString}`,
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: (error: any) => `Formatted error: ${error?.message || "Unknown error"}`,
}));
vi.mock("@/lib/utils/strings", () => ({
capitalizeFirstLetter: (str: string) => str.charAt(0).toUpperCase() + str.slice(1),
}));
vi.mock("@/modules/survey/editor/actions", () => ({
createActionClassAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, ...props }: any) => (
<button onClick={onClick} data-variant={variant} {...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/error-component", () => ({
ErrorComponent: () => <div>ErrorComponent</div>,
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children, ...props }: any) => <label {...props}>{children}</label>,
}));
vi.mock("@/modules/ui/components/loading-spinner", () => ({
LoadingSpinner: () => <div>LoadingSpinner</div>,
}));
vi.mock("../actions", () => ({
getActiveInactiveSurveysAction: vi.fn(),
}));
const mockActionClass = {
id: "action1",
createdAt: new Date("2023-01-01T10:00:00Z"),
updatedAt: new Date("2023-01-10T11:00:00Z"),
name: "Test Action",
description: "Test Description",
type: "noCode",
environmentId: "env1_dev",
noCodeConfig: {
/* ... */
} as any,
} as unknown as TActionClass;
const mockEnvironmentDev = {
id: "env1_dev",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
} as unknown as TEnvironment;
const mockEnvironmentProd = {
id: "env1_prod",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
} as unknown as TEnvironment;
const mockOtherEnvActionClasses: TActionClass[] = [
{
id: "action2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Existing Action Prod",
type: "noCode",
environmentId: "env1_prod",
} as unknown as TActionClass,
{
id: "action3",
createdAt: new Date(),
updatedAt: new Date(),
name: "Existing Code Action Prod",
type: "code",
key: "existing-key",
environmentId: "env1_prod",
} as unknown as TActionClass,
];
describe("ActionActivityTab", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({
data: {
activeSurveys: ["Active Survey 1"],
inactiveSurveys: ["Inactive Survey 1", "Inactive Survey 2"],
},
});
});
afterEach(() => {
cleanup();
});
test("renders loading state initially", () => {
// Don't resolve the promise immediately
vi.mocked(getActiveInactiveSurveysAction).mockReturnValue(new Promise(() => {}));
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
expect(screen.getByText("LoadingSpinner")).toBeInTheDocument();
});
test("renders error state if fetching surveys fails", async () => {
vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({
data: undefined,
});
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
// Wait for the component to update after the promise resolves
await screen.findByText("ErrorComponent");
expect(screen.getByText("ErrorComponent")).toBeInTheDocument();
});
test("renders survey lists and action details correctly", async () => {
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
// Wait for loading to finish
await screen.findByText("common.active_surveys");
// Check survey lists
expect(screen.getByText("Active Survey 1")).toBeInTheDocument();
expect(screen.getByText("Inactive Survey 1")).toBeInTheDocument();
expect(screen.getByText("Inactive Survey 2")).toBeInTheDocument();
// Check action details
// Use the actual Date.toString() output that the mock receives
expect(screen.getByText(`formatted-${mockActionClass.createdAt.toString()}`)).toBeInTheDocument(); // Created on
expect(screen.getByText(`formatted-${mockActionClass.updatedAt.toString()}`)).toBeInTheDocument(); // Last updated
expect(screen.getByText("NoCodeIcon")).toBeInTheDocument(); // Type icon
expect(screen.getByText("NoCode")).toBeInTheDocument(); // Type text
expect(screen.getByText("Development")).toBeInTheDocument(); // Environment
expect(screen.getByText("Copy to Production")).toBeInTheDocument(); // Copy button text
});
test("calls copyAction with correct data on button click", async () => {
vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any });
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
await screen.findByText("Copy to Production");
const copyButton = screen.getByText("Copy to Production");
await userEvent.click(copyButton);
expect(createActionClassAction).toHaveBeenCalledTimes(1);
// Include the extra properties that the component sends due to spreading mockActionClass
const expectedActionInput = {
...mockActionClass, // Spread the original object
name: "Test Action", // Keep the original name as it doesn't conflict
environmentId: "env1_prod", // Target environment ID
};
// Remove properties not expected by the action call itself, even if sent by component
delete (expectedActionInput as any).id;
delete (expectedActionInput as any).createdAt;
delete (expectedActionInput as any).updatedAt;
// The assertion now checks against the structure sent by the component
expect(createActionClassAction).toHaveBeenCalledWith({
action: {
...mockActionClass, // Include id, createdAt, updatedAt etc.
name: "Test Action",
environmentId: "env1_prod",
},
});
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully");
});
test("handles name conflict during copy", async () => {
vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any });
const conflictingActionClass = { ...mockActionClass, name: "Existing Action Prod" };
render(
<ActionActivityTab
actionClass={conflictingActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
await screen.findByText("Copy to Production");
const copyButton = screen.getByText("Copy to Production");
await userEvent.click(copyButton);
expect(createActionClassAction).toHaveBeenCalledTimes(1);
// The assertion now checks against the structure sent by the component
expect(createActionClassAction).toHaveBeenCalledWith({
action: {
...conflictingActionClass, // Include id, createdAt, updatedAt etc.
name: "Existing Action Prod (copy)",
environmentId: "env1_prod",
},
});
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully");
});
test("handles key conflict during copy for 'code' type", async () => {
const codeActionClass: TActionClass = {
...mockActionClass,
id: "codeAction1",
type: "code",
key: "existing-key", // Conflicting key
noCodeConfig: {
/* ... */
} as any,
};
render(
<ActionActivityTab
actionClass={codeActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
await screen.findByText("Copy to Production");
const copyButton = screen.getByText("Copy to Production");
await userEvent.click(copyButton);
expect(createActionClassAction).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.actions.action_with_key_already_exists");
});
test("shows error if copy action fails server-side", async () => {
vi.mocked(createActionClassAction).mockResolvedValue({ data: undefined });
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
await screen.findByText("Copy to Production");
const copyButton = screen.getByText("Copy to Production");
await userEvent.click(copyButton);
expect(createActionClassAction).toHaveBeenCalledTimes(1);
expect(toast.error).toHaveBeenCalledWith("environments.actions.action_copy_failed");
});
test("shows error and prevents copy if user is read-only", async () => {
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={true} // Set to read-only
/>
);
await screen.findByText("Copy to Production");
const copyButton = screen.getByText("Copy to Production");
await userEvent.click(copyButton);
expect(createActionClassAction).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("common.you_are_not_authorised_to_perform_this_action");
});
test("renders correct copy button text for production environment", async () => {
render(
<ActionActivityTab
actionClass={{ ...mockActionClass, environmentId: "env1_prod" }}
environmentId="env1_prod"
environment={mockEnvironmentProd} // Current env is Production
otherEnvActionClasses={[]} // Assume dev env has no actions for simplicity
otherEnvironment={mockEnvironmentDev} // Target env is Development
isReadOnly={false}
/>
);
await screen.findByText("Copy to Development");
expect(screen.getByText("Copy to Development")).toBeInTheDocument();
expect(screen.getByText("Production")).toBeInTheDocument(); // Environment text
});
});

View File

@@ -0,0 +1,122 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { ActionClassesTable } from "./ActionClassesTable";
// Mock the ActionDetailModal
vi.mock("./ActionDetailModal", () => ({
ActionDetailModal: ({ open, actionClass, setOpen }: any) =>
open ? (
<div data-testid="action-detail-modal">
Modal for {actionClass.name}
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
) : null,
}));
const mockActionClasses: TActionClass[] = [
{ id: "1", name: "Action 1", type: "noCode", environmentId: "env1" } as TActionClass,
{ id: "2", name: "Action 2", type: "code", environmentId: "env1" } as TActionClass,
];
const mockEnvironment: TEnvironment = {
id: "env1",
name: "Test Environment",
type: "development",
} as unknown as TEnvironment;
const mockOtherEnvironment: TEnvironment = {
id: "env2",
name: "Other Environment",
type: "production",
} as unknown as TEnvironment;
const mockTableHeading = <div data-testid="table-heading">Table Heading</div>;
const mockActionRows = mockActionClasses.map((action) => (
<div key={action.id} data-testid={`action-row-${action.id}`}>
{action.name} Row
</div>
));
describe("ActionClassesTable", () => {
afterEach(() => {
cleanup();
});
test("renders table heading and action rows when actions exist", () => {
render(
<ActionClassesTable
environmentId="env1"
actionClasses={mockActionClasses}
environment={mockEnvironment}
isReadOnly={false}
otherEnvActionClasses={[]}
otherEnvironment={mockOtherEnvironment}>
{[mockTableHeading, mockActionRows]}
</ActionClassesTable>
);
expect(screen.getByTestId("table-heading")).toBeInTheDocument();
expect(screen.getByTestId("action-row-1")).toBeInTheDocument();
expect(screen.getByTestId("action-row-2")).toBeInTheDocument();
expect(screen.queryByText("No actions found")).not.toBeInTheDocument();
});
test("renders 'No actions found' message when no actions exist", () => {
render(
<ActionClassesTable
environmentId="env1"
actionClasses={[]}
environment={mockEnvironment}
isReadOnly={false}
otherEnvActionClasses={[]}
otherEnvironment={mockOtherEnvironment}>
{[mockTableHeading, []]}
</ActionClassesTable>
);
expect(screen.getByTestId("table-heading")).toBeInTheDocument();
expect(screen.getByText("No actions found")).toBeInTheDocument();
expect(screen.queryByTestId("action-row-1")).not.toBeInTheDocument();
});
test("opens ActionDetailModal with correct action when a row is clicked", async () => {
render(
<ActionClassesTable
environmentId="env1"
actionClasses={mockActionClasses}
environment={mockEnvironment}
isReadOnly={false}
otherEnvActionClasses={[]}
otherEnvironment={mockOtherEnvironment}>
{[mockTableHeading, mockActionRows]}
</ActionClassesTable>
);
// Modal should not be open initially
expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument();
// Find the button wrapping the first action row
const actionButton1 = screen.getByTitle("Action 1");
await userEvent.click(actionButton1);
// Modal should now be open with the correct action name
const modal = screen.getByTestId("action-detail-modal");
expect(modal).toBeInTheDocument();
expect(modal).toHaveTextContent("Modal for Action 1");
// Close the modal
await userEvent.click(screen.getByText("Close Modal"));
expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument();
// Click the second action button
const actionButton2 = screen.getByTitle("Action 2");
await userEvent.click(actionButton2);
// Modal should open for the second action
const modal2 = screen.getByTestId("action-detail-modal");
expect(modal2).toBeInTheDocument();
expect(modal2).toHaveTextContent("Modal for Action 2");
});
});

View File

@@ -0,0 +1,180 @@
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { ActionActivityTab } from "./ActionActivityTab";
import { ActionDetailModal } from "./ActionDetailModal";
// Import mocked components
import { ActionSettingsTab } from "./ActionSettingsTab";
// Mock child components
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
<div data-testid="modal-with-tabs">
<span data-testid="modal-label">{label}</span>
<span data-testid="modal-description">{description}</span>
<span data-testid="modal-open">{open.toString()}</span>
<button onClick={() => setOpen(false)}>Close</button>
{icon}
{tabs.map((tab) => (
<div key={tab.title}>
<h2>{tab.title}</h2>
{tab.children}
</div>
))}
</div>
)),
}));
vi.mock("./ActionActivityTab", () => ({
ActionActivityTab: vi.fn(() => <div data-testid="action-activity-tab">ActionActivityTab</div>),
}));
vi.mock("./ActionSettingsTab", () => ({
ActionSettingsTab: vi.fn(() => <div data-testid="action-settings-tab">ActionSettingsTab</div>),
}));
// Mock the utils file to control ACTION_TYPE_ICON_LOOKUP
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
ACTION_TYPE_ICON_LOOKUP: {
code: <div data-testid="code-icon">Code Icon Mock</div>,
noCode: <div data-testid="nocode-icon">No Code Icon Mock</div>,
// Add other types if needed by other tests or default props
},
}));
const mockEnvironmentId = "test-env-id";
const mockSetOpen = vi.fn();
const mockEnvironment = {
id: mockEnvironmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "production", // Use string literal as TEnvironmentType is not exported
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockActionClass: TActionClass = {
id: "action-class-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action",
description: "This is a test action",
type: "code", // Ensure this matches a key in the mocked ACTION_TYPE_ICON_LOOKUP
environmentId: mockEnvironmentId,
noCodeConfig: null,
key: "test-action-key",
};
const mockActionClasses: TActionClass[] = [mockActionClass];
const mockOtherEnvActionClasses: TActionClass[] = [];
const mockOtherEnvironment = { ...mockEnvironment, id: "other-env-id", name: "Other Environment" };
const defaultProps = {
environmentId: mockEnvironmentId,
environment: mockEnvironment,
open: true,
setOpen: mockSetOpen,
actionClass: mockActionClass,
actionClasses: mockActionClasses,
isReadOnly: false,
otherEnvironment: mockOtherEnvironment,
otherEnvActionClasses: mockOtherEnvActionClasses,
};
describe("ActionDetailModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks(); // Clear mocks after each test
});
test("renders ModalWithTabs with correct props", () => {
render(<ActionDetailModal {...defaultProps} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
expect(mockedModalWithTabs).toHaveBeenCalled();
const props = mockedModalWithTabs.mock.calls[0][0];
// Check basic props
expect(props.open).toBe(true);
expect(props.setOpen).toBe(mockSetOpen);
expect(props.label).toBe(mockActionClass.name);
expect(props.description).toBe(mockActionClass.description);
// Check icon data-testid based on the mock for the default 'code' type
expect(props.icon).toBeDefined();
if (!props.icon) {
throw new Error("Icon prop is not defined");
}
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
// Check tabs structure
expect(props.tabs).toHaveLength(2);
expect(props.tabs[0].title).toBe("common.activity");
expect(props.tabs[1].title).toBe("common.settings");
// Check if the correct mocked components are used as children
// Access the mocked functions directly
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
if (!props.tabs[0].children || !props.tabs[1].children) {
throw new Error("Tabs children are not defined");
}
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
// Check props passed to child components
const activityTabProps = (props.tabs[0].children as any).props;
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
expect(activityTabProps.isReadOnly).toBe(false);
expect(activityTabProps.environment).toBe(mockEnvironment);
expect(activityTabProps.actionClass).toBe(mockActionClass);
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.actionClass).toBe(mockActionClass);
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
expect(settingsTabProps.isReadOnly).toBe(false);
});
test("renders correct icon based on action type", () => {
// Test with 'noCode' type
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
// Expect the 'nocode-icon' based on the updated mock and action type
expect(props.icon).toBeDefined();
if (!props.icon) {
throw new Error("Icon prop is not defined");
}
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
});
test("passes isReadOnly prop correctly", () => {
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
// Access the mocked component directly
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
if (!props.tabs[0].children || !props.tabs[1].children) {
throw new Error("Tabs children are not defined");
}
const activityTabProps = (props.tabs[0].children as any).props;
expect(activityTabProps.isReadOnly).toBe(true);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.isReadOnly).toBe(true);
});
});

View File

@@ -0,0 +1,63 @@
import { timeSince } from "@/lib/time";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { ActionClassDataRow } from "./ActionRowData";
vi.mock("@/lib/time", () => ({
timeSince: vi.fn(),
}));
const mockActionClass: TActionClass = {
id: "testId",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action",
description: "This is a test action",
type: "code",
noCodeConfig: null,
environmentId: "envId",
key: null,
};
const locale = "en-US";
const timeSinceOutput = "2 hours ago";
describe("ActionClassDataRow", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders code action correctly", () => {
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
const actionClass = { ...mockActionClass, type: "code" } as TActionClass;
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
expect(screen.getByText(actionClass.description!)).toBeInTheDocument();
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale);
});
test("renders no-code action correctly", () => {
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
const actionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
expect(screen.getByText(actionClass.description!)).toBeInTheDocument();
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale);
});
test("renders without description", () => {
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
const actionClass = { ...mockActionClass, description: undefined } as unknown as TActionClass;
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
expect(screen.queryByText("This is a test action")).not.toBeInTheDocument();
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
});
});

View File

@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
</div>
</div>
</div>
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)}
</div>
<div className="text-center"></div>

View File

@@ -0,0 +1,265 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TActionClass, TActionClassNoCodeConfig, TActionClassType } from "@formbricks/types/action-classes";
import { ActionSettingsTab } from "./ActionSettingsTab";
// Mock actions
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
deleteActionClassAction: vi.fn(),
updateActionClassAction: vi.fn(),
}));
// Mock utils
vi.mock("@/app/lib/actionClass/actionClass", () => ({
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
}));
// Mock UI components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, loading, ...props }: any) => (
<button onClick={onClick} data-variant={variant} disabled={loading} {...props}>
{loading ? "Loading..." : children}
</button>
),
}));
vi.mock("@/modules/ui/components/code-action-form", () => ({
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="code-action-form" data-readonly={isReadOnly}>
Code Action Form
</div>
),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<span>Delete Dialog</span>
<button onClick={onDelete} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Confirm Delete"}
</button>
<button onClick={() => setOpen(false)}>Cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
No Code Action Form
</div>
),
}));
// Mock icons
vi.mock("lucide-react", () => ({
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
}));
const mockSetOpen = vi.fn();
const mockActionClasses: TActionClass[] = [
{
id: "action1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Existing Action",
description: "An existing action",
type: "noCode",
environmentId: "env1",
noCodeConfig: { type: "click" } as TActionClassNoCodeConfig,
} as unknown as TActionClass,
];
const createMockActionClass = (id: string, type: TActionClassType, name: string): TActionClass =>
({
id,
createdAt: new Date(),
updatedAt: new Date(),
name,
description: `${name} description`,
type,
environmentId: "env1",
...(type === "code" && { key: `${name}-key` }),
...(type === "noCode" && {
noCodeConfig: { type: "url", rule: "exactMatch", value: `http://${name}.com` },
}),
}) as unknown as TActionClass;
describe("ActionSettingsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders correctly for 'code' action type", () => {
const actionClass = createMockActionClass("code1", "code", "Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
expect(
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
});
test("renders correctly for 'noCode' action type", () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
});
test("handles successful deletion", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import(
"@/app/(app)/environments/[environmentId]/actions/actions"
);
vi.mocked(deleteActionClassAction).mockResolvedValue({ data: actionClass } as any);
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ });
await userEvent.click(deleteButtonTrigger);
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" });
await userEvent.click(confirmDeleteButton);
await waitFor(() => {
expect(deleteActionClassAction).toHaveBeenCalledWith({ actionClassId: actionClass.id });
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_deleted_successfully");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("handles deletion failure", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import(
"@/app/(app)/environments/[environmentId]/actions/actions"
);
vi.mocked(deleteActionClassAction).mockRejectedValue(new Error("Deletion failed"));
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ });
await userEvent.click(deleteButtonTrigger);
const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" });
await userEvent.click(confirmDeleteButton);
await waitFor(() => {
expect(deleteActionClassAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
});
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("renders read-only state correctly", () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={true} // Set to read-only
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
});
test("prevents delete when read-only", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import(
"@/app/(app)/environments/[environmentId]/actions/actions"
);
// Render with isReadOnly=true, but simulate a delete attempt
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={true}
/>
);
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
// This test primarily checks the logic within handleDeleteAction if it were called.
// A better approach might be to export handleDeleteAction for direct testing,
// but for now, we assume the UI prevents calling it.
// We can assert that the delete button isn't there to prevent the flow
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(deleteActionClassAction).not.toHaveBeenCalled();
});
test("renders docs link correctly", () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
const docsLink = screen.getByRole("link", { name: "common.read_docs" });
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
expect(docsLink).toHaveAttribute("target", "_blank");
});
});

View File

@@ -0,0 +1,26 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ActionTableHeading } from "./ActionTableHeading";
// Mock the server-side translation function
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
describe("ActionTableHeading", () => {
afterEach(() => {
cleanup();
});
test("renders the table heading with correct column names", async () => {
// Render the async component
const ResolvedComponent = await ActionTableHeading();
render(ResolvedComponent);
// Check if the translated column headers are present
expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
expect(screen.getByText("common.created")).toBeInTheDocument();
// Check for the screen reader only text
expect(screen.getByText("common.edit")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,142 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass, TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
import { AddActionModal } from "./AddActionModal";
// Mock child components and hooks
vi.mock("@/modules/survey/editor/components/create-new-action-tab", () => ({
CreateNewActionTab: vi.fn(({ setOpen }) => (
<div data-testid="create-new-action-tab">
<span>CreateNewActionTab Content</span>
<button onClick={() => setOpen(false)}>Close from Tab</button>
</div>
)),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, ...props }: any) =>
open ? (
<div data-testid="modal" {...props}>
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
) : null,
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
vi.mock("lucide-react", () => ({
MousePointerClickIcon: () => <div data-testid="mouse-pointer-icon" />,
PlusIcon: () => <div data-testid="plus-icon" />,
}));
const mockActionClasses: TActionClass[] = [
{
id: "action1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action 1",
description: "Description 1",
type: "noCode",
environmentId: "env1",
noCodeConfig: { type: "click" } as unknown as TActionClassNoCodeConfig,
} as unknown as TActionClass,
];
const environmentId = "env1";
describe("AddActionModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders the 'Add Action' button initially", () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("opens the modal when the 'Add Action' button is clicked", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
expect(
screen.getByText("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")
).toBeInTheDocument();
expect(screen.getByTestId("create-new-action-tab")).toBeInTheDocument();
});
test("passes correct props to CreateNewActionTab", async () => {
const { CreateNewActionTab } = await import("@/modules/survey/editor/components/create-new-action-tab");
const mockedCreateNewActionTab = vi.mocked(CreateNewActionTab);
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(mockedCreateNewActionTab).toHaveBeenCalled();
const props = mockedCreateNewActionTab.mock.calls[0][0];
expect(props.environmentId).toBe(environmentId);
expect(props.actionClasses).toEqual(mockActionClasses); // Initial state check
expect(props.isReadOnly).toBe(false);
expect(props.setOpen).toBeInstanceOf(Function);
expect(props.setActionClasses).toBeInstanceOf(Function);
});
test("closes the modal when the close button (simulated) is clicked", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
// Simulate closing via the mocked Modal's close button
const closeModalButton = screen.getByText("Close Modal");
await userEvent.click(closeModalButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
// Simulate closing via the mocked CreateNewActionTab's button
const closeFromTabButton = screen.getByText("Close from Tab");
await userEvent.click(closeFromTabButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,44 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
// Mock child components
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="page-content-wrapper">{children}</div>
),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle }: { pageTitle: string }) => <div data-testid="page-header">{pageTitle}</div>,
}));
describe("Loading", () => {
afterEach(() => {
cleanup();
});
test("renders loading state correctly", () => {
render(<Loading />);
// Check if mocked components are rendered
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toHaveTextContent("common.actions");
// Check for translated table headers
expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
expect(screen.getByText("common.created")).toBeInTheDocument();
expect(screen.getByText("common.edit")).toBeInTheDocument(); // Screen reader text
// Check for skeleton elements (presence of animate-pulse class)
const skeletonElements = document.querySelectorAll(".animate-pulse");
expect(skeletonElements.length).toBeGreaterThan(0); // Ensure some skeleton elements are rendered
// Check for the presence of multiple skeleton rows (3 rows * 4 pulse elements per row = 12)
const pulseDivs = screen.getAllByText((_, element) => {
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
});
expect(pulseDivs.length).toBe(3 * 4); // 3 rows, 4 pulsing divs per row (icon, name, desc, created)
});
});

View File

@@ -0,0 +1,161 @@
import { getActionClasses } from "@/lib/actionClass/service";
import { getEnvironments } from "@/lib/environment/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
// Import the component after mocks
import Page from "./page";
// Mock dependencies
vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(),
}));
vi.mock("@/lib/environment/service", () => ({
getEnvironments: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable", () => ({
ActionClassesTable: ({ children }) => <div>ActionClassesTable Mock{children}</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionRowData", () => ({
ActionClassDataRow: ({ actionClass }) => <div>ActionClassDataRow Mock: {actionClass.name}</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading", () => ({
ActionTableHeading: () => <div>ActionTableHeading Mock</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/AddActionModal", () => ({
AddActionModal: () => <div>AddActionModal Mock</div>,
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div>PageContentWrapper Mock{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, cta }) => (
<div>
PageHeader Mock: {pageTitle} {cta && <div>CTA Mock</div>}
</div>
),
}));
// Mock data
const mockEnvironmentId = "test-env-id";
const mockProjectId = "test-project-id";
const mockEnvironment = {
id: mockEnvironmentId,
name: "Test Environment",
type: "development",
} as unknown as TEnvironment;
const mockOtherEnvironment = {
id: "other-env-id",
name: "Other Environment",
type: "production",
} as unknown as TEnvironment;
const mockProject = { id: mockProjectId, name: "Test Project" } as unknown as TProject;
const mockActionClasses = [
{ id: "action1", name: "Action 1", type: "code", environmentId: mockEnvironmentId } as TActionClass,
{ id: "action2", name: "Action 2", type: "noCode", environmentId: mockEnvironmentId } as TActionClass,
];
const mockOtherEnvActionClasses = [
{ id: "action3", name: "Action 3", type: "code", environmentId: mockOtherEnvironment.id } as TActionClass,
];
const mockLocale = "en-US";
const mockParams = { environmentId: mockEnvironmentId };
const mockProps = { params: mockParams };
describe("Actions Page", () => {
beforeEach(() => {
vi.mocked(getActionClasses)
.mockResolvedValueOnce(mockActionClasses) // First call for current env
.mockResolvedValueOnce(mockOtherEnvActionClasses); // Second call for other env
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment, mockOtherEnvironment]);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
test("renders the page correctly with actions", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
project: mockProject,
isBilling: false,
environment: mockEnvironment,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // AddActionModal rendered via CTA
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
expect(screen.getByText("ActionTableHeading Mock")).toBeInTheDocument();
expect(screen.getByText("ActionClassDataRow Mock: Action 1")).toBeInTheDocument();
expect(screen.getByText("ActionClassDataRow Mock: Action 2")).toBeInTheDocument();
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("redirects if isBilling is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
project: mockProject,
isBilling: true,
environment: mockEnvironment,
} as TEnvironmentAuth);
await Page(mockProps);
expect(vi.mocked(redirect)).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/settings/billing`);
});
test("does not render AddActionModal CTA if isReadOnly is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: true,
project: mockProject,
isBilling: false,
environment: mockEnvironment,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
expect(screen.queryByText("CTA Mock")).not.toBeInTheDocument(); // CTA should not be present
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
});
test("renders AddActionModal CTA if isReadOnly is false", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
project: mockProject,
isBilling: false,
environment: mockEnvironment,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // CTA should be present
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,39 @@
import { cleanup, render } from "@testing-library/react";
import { Code2Icon, MousePointerClickIcon } from "lucide-react";
import React from "react";
import { afterEach, describe, expect, test } from "vitest";
import { ACTION_TYPE_ICON_LOOKUP } from "./utils";
describe("ACTION_TYPE_ICON_LOOKUP", () => {
afterEach(() => {
cleanup();
});
test("should contain the correct icon for 'code'", () => {
expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("code");
const IconComponent = ACTION_TYPE_ICON_LOOKUP.code;
expect(React.isValidElement(IconComponent)).toBe(true);
// Render the icon and check if it's the correct Lucide icon
const { container } = render(IconComponent);
const svgElement = container.querySelector("svg");
expect(svgElement).toBeInTheDocument();
// Check for a class or attribute specific to Code2Icon if possible,
// or compare the rendered output structure if necessary.
// For simplicity, we check the component type directly (though this is less robust)
expect(IconComponent.type).toBe(Code2Icon);
});
test("should contain the correct icon for 'noCode'", () => {
expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("noCode");
const IconComponent = ACTION_TYPE_ICON_LOOKUP.noCode;
expect(React.isValidElement(IconComponent)).toBe(true);
// Render the icon and check if it's the correct Lucide icon
const { container } = render(IconComponent);
const svgElement = container.querySelector("svg");
expect(svgElement).toBeInTheDocument();
// Similar check as above for MousePointerClickIcon
expect(IconComponent.type).toBe(MousePointerClickIcon);
});
});

View File

@@ -0,0 +1,391 @@
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembership } from "@formbricks/types/memberships";
import {
TOrganization,
TOrganizationBilling,
TOrganizationBillingPlanLimits,
} from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
// Mock services and utils
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
getEnvironments: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
getOrganizationsByUserId: vi.fn(),
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
getMonthlyOrganizationResponseCount: vi.fn(),
}));
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
getUserProjects: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
let mockIsFormbricksCloud = false;
let mockIsDevelopment = false;
vi.mock("@/lib/constants", () => ({
get IS_FORMBRICKS_CLOUD() {
return mockIsFormbricksCloud;
},
get IS_DEVELOPMENT() {
return mockIsDevelopment;
},
}));
// Mock components
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) =>
environment.type === "development" ? <div data-testid="dev-banner">DevEnvironmentBanner</div> : null,
}));
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
}));
vi.mock("@/modules/ui/components/pending-downgrade-banner", () => ({
PendingDowngradeBanner: ({
isPendingDowngrade,
active,
}: {
isPendingDowngrade: boolean;
active: boolean;
}) =>
isPendingDowngrade && active ? <div data-testid="downgrade-banner">PendingDowngradeBanner</div> : null,
}));
const mockUser = {
id: "user-1",
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
} as unknown as TUser;
const mockOrganization = {
id: "org-1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
} as unknown as TOrganizationBilling,
} as unknown as TOrganization;
const mockEnvironment: TEnvironment = {
id: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "proj-1",
appSetupCompleted: true,
};
const mockProject: TProject = {
id: "proj-1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org-1",
environments: [mockEnvironment],
} as unknown as TProject;
const mockMembership: TMembership = {
organizationId: "org-1",
userId: "user-1",
accepted: true,
role: "owner",
};
const mockLicense = {
plan: "free",
active: false,
lastChecked: new Date(),
features: { isMultiOrgEnabled: false },
} as any;
const mockProjectPermission = {
userId: "user-1",
projectId: "proj-1",
role: "admin",
} as any;
const mockSession: Session = {
user: {
id: "user-1",
},
expires: new Date(Date.now() + 3600 * 1000).toISOString(),
};
describe("EnvironmentLayout", () => {
beforeEach(() => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
mockIsDevelopment = false;
mockIsFormbricksCloud = false;
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
test("renders correctly with default props", async () => {
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
expect(screen.getByText("Child Content")).toBeInTheDocument();
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
});
test("renders DevEnvironmentBanner in development environment", async () => {
const devEnvironment = { ...mockEnvironment, type: "development" as const };
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
mockIsDevelopment = true;
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
});
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
mockIsFormbricksCloud = true;
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
});
test("renders PendingDowngradeBanner when pending downgrade", async () => {
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
});
test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.user_not_found"
);
});
test("throws error if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.organization_not_found"
);
});
test("throws error if environment not found", async () => {
vi.mocked(getEnvironment).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.environment_not_found"
);
});
test("throws error if projects, environments or organizations not found", async () => {
vi.mocked(getUserProjects).mockResolvedValue(null as any);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"environments.projects_environments_organizations_not_found"
);
});
test("throws error if member has no project permission", async () => {
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.project_permission_not_found"
);
});
});

View File

@@ -12,7 +12,8 @@ import {
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";

View File

@@ -0,0 +1,33 @@
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import EnvironmentStorageHandler from "./EnvironmentStorageHandler";
describe("EnvironmentStorageHandler", () => {
test("sets environmentId in localStorage on mount", () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const testEnvironmentId = "test-env-123";
render(<EnvironmentStorageHandler environmentId={testEnvironmentId} />);
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, testEnvironmentId);
setItemSpy.mockRestore();
});
test("updates environmentId in localStorage when prop changes", () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const initialEnvironmentId = "test-env-initial";
const updatedEnvironmentId = "test-env-updated";
const { rerender } = render(<EnvironmentStorageHandler environmentId={initialEnvironmentId} />);
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, initialEnvironmentId);
rerender(<EnvironmentStorageHandler environmentId={updatedEnvironmentId} />);
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, updatedEnvironmentId);
expect(setItemSpy).toHaveBeenCalledTimes(2); // Called on mount and on rerender with new prop
setItemSpy.mockRestore();
});
});

View File

@@ -0,0 +1,149 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { EnvironmentSwitch } from "./EnvironmentSwitch";
// Mock next/navigation
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({
push: mockPush,
})),
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
const mockEnvironmentDev: TEnvironment = {
id: "dev-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "project-id",
appSetupCompleted: true,
};
const mockEnvironmentProd: TEnvironment = {
id: "prod-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "project-id",
appSetupCompleted: true,
};
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
describe("EnvironmentSwitch", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders checked when environment is development", () => {
render(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
const switchElement = screen.getByRole("switch");
expect(switchElement).toBeChecked();
expect(screen.getByText("common.dev_env")).toHaveClass("text-orange-800");
});
test("renders unchecked when environment is production", () => {
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
const switchElement = screen.getByRole("switch");
expect(switchElement).not.toBeChecked();
expect(screen.getByText("common.dev_env")).not.toHaveClass("text-orange-800");
});
test("calls router.push with development environment ID when toggled from production", async () => {
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
const switchElement = screen.getByRole("switch");
expect(switchElement).not.toBeChecked();
await userEvent.click(switchElement);
// Check loading state (switch disabled)
expect(switchElement).toBeDisabled();
// Check router push call
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
});
// Check visual state change (though state update happens before navigation)
// In a real scenario, the component would re-render with the new environment prop after navigation.
// Here, we simulate the state change directly for testing the toggle logic.
await waitFor(() => {
// Re-render or check internal state if possible, otherwise check mock calls
// Since the component manages its own state, we can check the visual state after click
expect(switchElement).toBeChecked(); // State updates immediately
});
});
test("calls router.push with production environment ID when toggled from development", async () => {
render(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
const switchElement = screen.getByRole("switch");
expect(switchElement).toBeChecked();
await userEvent.click(switchElement);
// Check loading state (switch disabled)
expect(switchElement).toBeDisabled();
// Check router push call
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentProd.id}/`);
});
// Check visual state change
await waitFor(() => {
expect(switchElement).not.toBeChecked(); // State updates immediately
});
});
test("does not call router.push if target environment is not found", async () => {
const incompleteEnvironments = [mockEnvironmentProd]; // Only production exists
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={incompleteEnvironments} />);
const switchElement = screen.getByRole("switch");
await userEvent.click(switchElement); // Try to toggle to development
await waitFor(() => {
expect(switchElement).toBeDisabled(); // Loading state still set
});
// router.push should not be called because dev env is missing
expect(mockPush).not.toHaveBeenCalled();
// State still updates visually
await waitFor(() => {
expect(switchElement).toBeChecked();
});
});
test("toggles using the label click", async () => {
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
const labelElement = screen.getByText("common.dev_env");
const switchElement = screen.getByRole("switch");
expect(switchElement).not.toBeChecked();
await userEvent.click(labelElement); // Click the label
// Check loading state (switch disabled)
expect(switchElement).toBeDisabled();
// Check router push call
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
});
// Check visual state change
await waitFor(() => {
expect(switchElement).toBeChecked();
});
});
});

View File

@@ -0,0 +1,333 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { usePathname, useRouter } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import { getLatestStableFbReleaseAction } from "../actions/actions";
import { MainNavigation } from "./MainNavigation";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Mock dependencies
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
usePathname: vi.fn(() => "/environments/env1/surveys"),
}));
vi.mock("next-auth/react", () => ({
signOut: vi.fn(),
}));
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
getLatestStableFbReleaseAction: vi.fn(),
}));
vi.mock("@/app/lib/formbricks", () => ({
formbricksLogout: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: (role?: string) => ({
isAdmin: role === "admin",
isOwner: role === "owner",
isManager: role === "manager",
isMember: role === "member",
isBilling: role === "billing",
}),
}));
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) =>
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
}));
vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
</div>
),
}));
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: (props: any) => <img alt="test" {...props} />,
}));
vi.mock("../../../../../package.json", () => ({
version: "1.0.0",
}));
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
// Mock data
const mockEnvironment: TEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "proj1",
appSetupCompleted: true,
};
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
emailVerified: new Date(),
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;
const mockOrganization = {
id: "org1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: { stripeCustomerId: null, plan: "free", limits: { monthly: { responses: null } } } as any,
} as unknown as TOrganization;
const mockOrganizations: TOrganization[] = [
mockOrganization,
{ ...mockOrganization, id: "org2", name: "Another Org" },
];
const mockProject: TProject = {
id: "proj1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org1",
environments: [mockEnvironment],
config: { channel: "website" },
} as unknown as TProject;
const mockProjects: TProject[] = [mockProject];
const defaultProps = {
environment: mockEnvironment,
organizations: mockOrganizations,
user: mockUser,
organization: mockOrganization,
projects: mockProjects,
isMultiOrgEnabled: true,
isFormbricksCloud: false,
isDevelopment: false,
membershipRole: "owner" as const,
organizationProjectsLimit: 5,
isLicenseActive: true,
};
describe("MainNavigation", () => {
let mockRouterPush: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockRouterPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockRouterPush } as any);
vi.mocked(usePathname).mockReturnValue("/environments/env1/surveys");
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null }); // Default: no new version
localStorage.clear();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders expanded by default and collapses on toggle", async () => {
render(<MainNavigation {...defaultProps} />);
const projectSwitcher = screen.getByTestId("project-switcher");
// Assuming the toggle button is the only one initially without an accessible name
// A more specific selector like data-testid would be better if available.
const toggleButton = screen.getByRole("button", { name: "" });
// Check initial state (expanded)
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
// Check localStorage is not set initially after clear()
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
// Click to collapse
await userEvent.click(toggleButton);
// Check state after first toggle (collapsed)
await waitFor(() => {
// Check that the attribute eventually becomes true
expect(projectSwitcher).toHaveAttribute("data-collapsed", "true");
// Check that localStorage is updated
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
});
// Check that the logo is eventually hidden
await waitFor(() => {
expect(screen.queryByAltText("environments.formbricks_logo")).not.toBeInTheDocument();
});
// Click to expand
await userEvent.click(toggleButton);
// Check state after second toggle (expanded)
await waitFor(() => {
// Check that the attribute eventually becomes false
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
// Check that localStorage is updated
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
});
// Check that the logo is eventually visible
await waitFor(() => {
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
});
});
test("renders correct active navigation link", () => {
vi.mocked(usePathname).mockReturnValue("/environments/env1/actions");
render(<MainNavigation {...defaultProps} />);
const actionsLink = screen.getByRole("link", { name: /common.actions/ });
// Check if the parent li has the active class styling
expect(actionsLink.closest("li")).toHaveClass("border-brand-dark");
});
test("renders user dropdown and handles logout", async () => {
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
render(<MainNavigation {...defaultProps} />);
// Find the avatar and get its parent div which acts as the trigger
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
await userEvent.click(userTrigger);
// Wait for the dropdown content to appear
await waitFor(() => {
expect(screen.getByText("common.account")).toBeInTheDocument();
});
expect(screen.getByText("common.organization")).toBeInTheDocument();
expect(screen.getByText("common.license")).toBeInTheDocument(); // Not cloud, not member
expect(screen.getByText("common.documentation")).toBeInTheDocument();
expect(screen.getByText("common.logout")).toBeInTheDocument();
const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton);
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org1",
redirect: false,
callbackUrl: "/auth/login",
});
await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
});
});
test("handles organization switching", async () => {
render(<MainNavigation {...defaultProps} />);
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
await userEvent.click(userTrigger);
// Wait for the initial dropdown items
await waitFor(() => {
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
});
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor
await userEvent.click(org2Item);
expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/");
});
test("opens create organization modal", async () => {
render(<MainNavigation {...defaultProps} />);
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
await userEvent.click(userTrigger);
// Wait for the initial dropdown items
await waitFor(() => {
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
});
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor
await userEvent.click(createOrgButton);
expect(screen.getByTestId("create-org-modal")).toBeInTheDocument();
});
test("hides new version banner for members or if no new version", async () => {
// Test for member
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
render(<MainNavigation {...defaultProps} membershipRole="member" />);
let toggleButton = screen.getByRole("button", { name: "" });
await userEvent.click(toggleButton);
await waitFor(() => {
expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
});
cleanup(); // Clean up before next render
// Test for no new version
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null });
render(<MainNavigation {...defaultProps} membershipRole="owner" />);
toggleButton = screen.getByRole("button", { name: "" });
await userEvent.click(toggleButton);
await waitFor(() => {
expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
});
});
test("hides main nav and project switcher if user role is billing", () => {
render(<MainNavigation {...defaultProps} membershipRole="billing" />);
expect(screen.queryByRole("link", { name: /common.surveys/ })).not.toBeInTheDocument();
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
});
test("shows billing link and hides license link in cloud", async () => {
render(<MainNavigation {...defaultProps} isFormbricksCloud={true} />);
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
await userEvent.click(userTrigger);
// Wait for dropdown items
await waitFor(() => {
expect(screen.getByText("common.billing")).toBeInTheDocument();
});
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
});
});

View File

@@ -2,11 +2,11 @@
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -43,7 +43,6 @@ import {
UserIcon,
UsersIcon,
} from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
@@ -91,6 +90,7 @@ export const MainNavigation = ({
const [isCollapsed, setIsCollapsed] = useState(true);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const project = projects.find((project) => project.id === environment.projectId);
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
@@ -110,7 +110,7 @@ export const MainNavigation = ({
useEffect(() => {
const toggleTextOpacity = () => {
setIsTextVisible(isCollapsed ? true : false);
setIsTextVisible(isCollapsed);
};
const timeoutId = setTimeout(toggleTextOpacity, 150);
return () => clearTimeout(timeoutId);
@@ -171,7 +171,7 @@ export const MainNavigation = ({
name: t("common.actions"),
href: `/environments/${environment.id}/actions`,
icon: MousePointerClick,
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
isActive: pathname?.includes("/actions"),
},
{
name: t("common.integrations"),
@@ -265,7 +265,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -390,9 +390,14 @@ export const MainNavigation = ({
<DropdownMenuItem
onClick={async () => {
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
router.push(route.url);
await formbricksLogout();
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: false,
callbackUrl: "/auth/login",
});
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -0,0 +1,21 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { NavbarLoading } from "./NavbarLoading";
describe("NavbarLoading", () => {
afterEach(() => {
cleanup();
});
test("renders the correct number of skeleton elements", () => {
render(<NavbarLoading />);
// Find all divs with the animate-pulse class
const skeletonElements = screen.getAllByText((content, element) => {
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
});
// There are 8 skeleton divs in the component
expect(skeletonElements).toHaveLength(8);
});
});

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