Compare commits

...

212 Commits

Author SHA1 Message Date
pandeymangg
86804db467 e2e 2025-09-10 13:01:16 +05:30
pandeymangg
13d6af0183 e2e 2025-09-10 12:28:51 +05:30
pandeymangg
af49205488 e2e 2025-09-10 11:46:49 +05:30
pandeymangg
ddc3c97297 e2e 2025-09-10 11:22:12 +05:30
pandeymangg
51b116bd6b e2e 2025-09-10 10:38:07 +05:30
pandeymangg
145579d864 e2e 2025-09-09 23:23:05 +05:30
pandeymangg
963d1e229f e2e 2025-09-09 23:18:00 +05:30
pandeymangg
d4e1062b53 e2e 2025-09-09 22:39:03 +05:30
pandeymangg
8abb96daa4 feedback 2025-09-09 17:54:08 +05:30
pandeymangg
8b30f59fb9 feedback 2025-09-09 17:49:37 +05:30
pandeymangg
6084669d2d feedback 2025-09-09 17:27:00 +05:30
pandeymangg
fd6aec4dc0 fix: build 2025-09-09 17:15:51 +05:30
pandeymangg
f4b9400ffc fix csp 2025-09-09 17:06:47 +05:30
pandeymangg
738f9e4b8e feedback 2025-09-09 16:23:01 +05:30
pandeymangg
81ff63797c csp 2025-09-09 16:20:48 +05:30
pandeymangg
45b19772f2 fix 2025-09-09 16:05:04 +05:30
pandeymangg
07a150443f coderabbit feedback 2025-09-09 16:04:56 +05:30
pandeymangg
b4d1916812 coderabbit feedback 2025-09-09 14:28:54 +05:30
pandeymangg
11eb8d622f feedback 2025-09-09 12:32:16 +05:30
pandeymangg
2a202b3194 fix test 2025-09-08 11:54:29 +05:30
pandeymangg
8fafc4f940 fixes 2025-09-08 11:14:14 +05:30
pandeymangg
fb38a606f9 file name sanitization 2025-09-05 13:23:11 +05:30
pandeymangg
54065d6e13 adds tests 2025-09-03 12:49:29 +05:30
pandeymangg
0090c31ff8 adds tests 2025-09-03 12:28:33 +05:30
pandeymangg
908a78e211 adds tests 2025-09-03 12:11:39 +05:30
pandeymangg
d55a17086c coderabbit feedback 2025-09-03 11:39:22 +05:30
pandeymangg
2a938caefc fix: moves csv and excel exports to client 2025-09-02 11:03:37 +05:30
pandeymangg
8cbf58fe8d fix: tests 2025-09-02 10:29:36 +05:30
pandeymangg
941098a3fa fix: coderabbit feedback 2025-09-01 13:46:55 +05:30
pandeymangg
8f7d5f8fd5 fix: api client unit test 2025-08-29 12:04:23 +05:30
pandeymangg
1e8c862d80 fix: uses storage package in apps/web 2025-08-29 11:47:46 +05:30
Anshuman Pandey
2502a6ce3c feat: storage package with s3 client (#6449) 2025-08-27 07:21:41 +05:30
pandeymangg
a709d04e8e updates cursor rules 2025-08-26 17:34:21 +05:30
pandeymangg
edc3b4172a updates cursor rules 2025-08-26 17:23:51 +05:30
pandeymangg
2ba079da68 feedback 2025-08-26 16:08:30 +05:30
pandeymangg
e1607def05 updates cursor rules 2025-08-26 15:26:05 +05:30
pandeymangg
9d7dac33be fix: batch size 2025-08-26 14:58:32 +05:30
pandeymangg
b9d544f36f fix: adds error handling 2025-08-26 12:39:13 +05:30
pandeymangg
7abd0e9aed adds pagination 2025-08-25 15:30:53 +05:30
pandeymangg
a9db89ecdd fix: rollback merge 2025-08-25 13:43:53 +05:30
pandeymangg
0155c41593 fix: adds deleteFilesByPrefix service 2025-08-25 12:42:18 +05:30
pandeymangg
df63f2e5d9 Merge branch 'main' into feat/storage-package 2025-08-25 11:42:57 +05:30
Matti Nannt
a3764f0316 chore: increase data migration timeout to 600s (#6455) 2025-08-21 21:46:30 +02:00
pandeymangg
7dd174ffea fix: adds maxSize 2025-08-21 22:10:33 +05:30
pandeymangg
7154f6fe74 fix: feedback 2025-08-21 21:53:03 +05:30
pandeymangg
f25f257f24 fix: jsdoc comments 2025-08-21 18:08:06 +05:30
pandeymangg
b945900fbf fix 2025-08-21 18:04:50 +05:30
pandeymangg
f8869e7522 reverts package versions 2025-08-21 18:02:05 +05:30
pandeymangg
886eb8598a fix: adds file existence check 2025-08-21 17:56:13 +05:30
pandeymangg
fe3c8e010f fix 2025-08-21 17:07:54 +05:30
pandeymangg
a6a76cc3cf adds cursor rules 2025-08-21 17:06:54 +05:30
pandeymangg
9e7a4e38cf feedback 2025-08-21 16:51:35 +05:30
Piyush Gupta
ec52bdf3fe feat: adds stories for logo component (#6448) 2025-08-20 14:57:43 +00:00
pandeymangg
9cff5457d6 fixes 2025-08-20 17:28:05 +05:30
Victor Hugo dos Santos
2e9ad3ce07 fix: community PR check 6400 (#6427)
Co-authored-by: Alex <alexander.seliakov@gmail.com>
2025-08-20 09:04:48 +00:00
Matti Nannt
654bd232d6 chore(cursor): add monorepo overview rule and refine database rule metadata (#6444) 2025-08-20 07:29:03 +00:00
pandeymangg
a362455878 adds storage package 2025-08-20 12:23:39 +05:30
Piyush Gupta
01984cf8ca chore: upgrades prisma to latest version (#6442) 2025-08-19 14:40:31 +00:00
Anshuman Pandey
3eb18bb120 fix: alert message on invalid file in file upload question (#6431) 2025-08-19 12:42:31 +00:00
Piyush Gupta
59859d0e4f fix: organization access checks (#6441) 2025-08-19 11:23:59 +00:00
Piyush Gupta
c60c8cb7bd feat: adds stories for tooltip component (#6433) 2025-08-19 07:48:46 +00:00
Matti Nannt
9fa7aef253 chore: upgrade node-alpine image to 3.22 (#6437) 2025-08-19 07:45:46 +00:00
Piyush Gupta
a23594428a fix: color picker in product logo (#6434)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-19 07:14:23 +00:00
Piyush Gupta
56e7106d6e fix: open text number question logic evaluation (#6439) 2025-08-19 06:03:49 +00:00
Matti Nannt
318f891540 fix: ECR workflow action failing (#6435) 2025-08-18 16:58:30 +02:00
Piyush Gupta
a59881f9ae feat: adds drag and drop to matrix question fields (#6386) 2025-08-18 14:38:53 +00:00
Matti Nannt
7ab4a45ad6 feat: add ecr workflow (#6414) 2025-08-18 15:17:14 +02:00
Matti Nannt
2990e3805f fix: docker security scan action not uploading results (#6432) 2025-08-18 13:54:58 +02:00
Dhruwang Jariwala
29132ab029 fix: metadata issue (#6422)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-15 09:50:15 +00:00
Dhruwang Jariwala
f860d8d25d fix: link preview settings tweaks (#6418) 2025-08-14 15:48:05 +00:00
Anshuman Pandey
3501990a79 fix: syntax issue in docker release action (#6415) 2025-08-14 12:12:42 +00:00
Piyush Gupta
41d60c8a02 chore: custom avatar removal (#6408)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-14 10:17:05 +00:00
Anshuman Pandey
a6269f0fd3 fix: disables share tabs when single use is active (#6410) 2025-08-14 08:49:15 +00:00
Dhruwang Jariwala
9c0d0a16a7 fix: hover on survey close button (#6405) 2025-08-14 08:11:15 +00:00
Piyush Gupta
c6241f7e7f fix: Inconsistent icon - Picture select vs. question header image (#6409) 2025-08-13 13:09:23 +00:00
Piotr Gaczkowski
92f1c2b75a fix: make terraform apply work again (#6403)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-13 12:19:18 +00:00
Dhruwang Jariwala
4d53291c8a fix: checks and rate limiting for email verification survey action (#6406) 2025-08-13 06:42:08 +00:00
Matti Nannt
14b7a69cea fix: permissions in release workflow (#6399) 2025-08-13 08:35:26 +02:00
Piyush Gupta
a9015b008d docs: adds identifier note in saml sso docs (#6402) 2025-08-12 11:18:44 +00:00
Dhruwang Jariwala
d19d624c0c feat: filters for url in metadata (#6387)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-08-12 09:37:12 +00:00
Matti Nannt
3edaab6c2b fix: release workflow environment is not accessible (#6398) 2025-08-12 10:31:05 +02:00
Dhruwang Jariwala
4786ab61e7 feat: customizable link previews (#6361)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-12 06:37:30 +00:00
Dhruwang Jariwala
819380d21c chore: sonarqube label fixes (#6381) 2025-08-11 18:22:21 +00:00
Anshuman Pandey
fd3fedb6ed fix: fixes follow up UI when email is hidden in the contact info question (#6388) 2025-08-11 14:48:32 +00:00
Dhruwang Jariwala
88b1e63771 chore: updated nextjs version (#6389) 2025-08-11 13:24:35 +00:00
Piyush Gupta
3132fe74f1 chore: remove response note feature (#6390) 2025-08-11 12:01:31 +00:00
Harsh Bhat
a27a2a67c8 chore: Change pricing form link (#6394) 2025-08-11 05:13:43 -07:00
Piyush Gupta
4a7ace5a0a feat: adds metadata columns in response table (#6368) 2025-08-11 11:25:06 +00:00
Victor Hugo dos Santos
43628caa3b feat: Add rate limiting to API V1 (#6355)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-08-11 09:10:45 +00:00
Matti Nannt
9d84bc0c8d fix: Uncontrolled data used in path expression in storage service (#6375)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-11 08:37:33 +00:00
Dhruwang Jariwala
babc020085 chore: short url legacy removal (#6391) 2025-08-11 07:55:52 +00:00
Matti Nannt
95ee83ef31 chore: remove semantic PR thank you comment to reduce PR spam (#6380) 2025-08-11 07:24:59 +00:00
Matti Nannt
d994af2dfd chore: Add Docker Image Vulnerability Scanning for SOC-2 Compliance (#6371) 2025-08-08 16:42:55 +00:00
Matti Nannt
4b5b5bf59f chore: add cursor rule for github workflows & actions (#6382) 2025-08-08 13:30:27 +00:00
Anshuman Pandey
62166dc4b1 fix: tidying up the survey card header (#6341)
Co-authored-by: Jakob Schott <jakob@formbricks.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
2025-08-08 10:18:56 +00:00
Matti Nannt
ec6d88bf11 fix: OneLeet Code Scanning Sentry action issues (#6378) 2025-08-08 08:06:57 +00:00
Dhruwang Jariwala
c0240d60a1 feat: romanian translations (#6369) 2025-08-08 04:03:55 +00:00
Dhruwang Jariwala
cd2884d83e chore: app connection info alert (#6370)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-08-07 15:57:49 +00:00
StepSecurity Bot
f7aea2e706 chore: Harden GitHub Actions (#6373)
Signed-off-by: StepSecurity Bot <bot@stepsecurity.io>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-07 16:33:42 +02:00
Matti Nannt
e80fc2ee61 chore: remove unused github workflows/actions (#6372) 2025-08-07 15:26:11 +02:00
Jakob Schott
9b489b0682 chore: Optimize styling for MultiLanguageCard (#6353) 2025-08-07 10:23:25 +00:00
Jakob Schott
2ee0efa1c2 fix: dynamic width for InputCombobox (#6365) 2025-08-06 23:52:09 -07:00
Anshuman Pandey
9ffd67262c fix: updates tolgee key (#6367) 2025-08-07 06:30:00 +00:00
Dhruwang Jariwala
68dc63ce0b chore: search bar and preview on survey list page (#6349)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-07 04:57:28 +00:00
Piyush Gupta
f239ee9697 feat: adds multiLanguageSurveys and accessControl license features (#6331) 2025-08-06 14:35:28 +00:00
Piyush Gupta
282b3e070c fix: sonarqube medium vulnerability issues (#6362) 2025-08-06 11:23:27 +00:00
Johannes
b5f0bd8f9a fix: update wording to match actual behaviour (#6364) 2025-08-06 03:38:47 -07:00
Piyush Gupta
3784bd6b5e fix: Missing space in Access Control Modal (#6356)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-06 08:17:47 +00:00
Piyush Gupta
41d27c2093 fix: use full width on sidebar elements (#6357) 2025-08-06 07:58:28 +00:00
Piyush Gupta
7400ce2e67 fix: secure cookies fix for callback URL (#6358) 2025-08-05 17:44:13 +00:00
Piyush Gupta
355782f404 chore: sonarqube low reliability issues (#6359) 2025-08-05 10:06:53 +00:00
Anshuman Pandey
de70e97940 fix: adds loading state to the responses download button (#6352) 2025-08-05 04:22:22 +00:00
Dhruwang Jariwala
287c45f996 feat: surface option ids (#6339) 2025-08-05 04:03:12 +00:00
Harsh Bhat
3b07a6d013 docs: update multi-language surveys (#6354) 2025-08-04 10:02:31 -07:00
Jonas Höbenreich
0cc2606ec6 fix: Remove rounded-lg Class from Company Logo (#6347) 2025-08-04 01:42:05 -07:00
Dhruwang Jariwala
0fada94b80 chore: Replace entity ids (#6317) 2025-08-04 04:10:41 +00:00
Piyush Gupta
a59ede20c7 fix: one leet security issues (#6303) 2025-08-01 14:35:11 +00:00
Piyush Gupta
84294f9df2 feat: adds debug logs (#6237)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-01 11:10:21 +00:00
Johannes
855e7c78ce docs: add quota docs (#6343) 2025-07-31 06:25:34 -07:00
Piotr Gaczkowski
6c506d90c7 fix: Make EKS endpoint private (#6333)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-31 13:08:18 +00:00
Piyush Gupta
53f6e02ca1 fix: XLSX security vulnerability | Update XLSX to SheetJS (#6321) 2025-07-31 12:12:17 +00:00
Jakob Schott
14de2eab42 feat: 733 warn users when switching survey type (#6336)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-31 08:30:06 +00:00
Piyush Gupta
ad1f80331a fix: Low severity vulnerability in on-headers@1.0.2 (#6319) 2025-07-31 06:42:03 +00:00
Piyush Gupta
3527ac337b feat: adds response status select in filters (#6325) 2025-07-31 06:33:11 +00:00
Victor Hugo dos Santos
23c2d3dce9 feat: Add Regex No Code Action Page Filter (#6305)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-31 05:48:12 +00:00
Anshuman Pandey
da652bd860 fix: adds proxy agent to next-auth (#6326) 2025-07-31 05:08:33 +00:00
Harsh Bhat
6f88dde1a0 chore: SUS template (#6328)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-30 05:27:58 -07:00
Jakob Schott
3b90223101 style: scroll indicator update (#6310) 2025-07-30 05:27:15 -07:00
Victor Hugo dos Santos
e29a67b1f6 chore: run checks for PR 6304 (#6309)
Co-authored-by: ompharate <ompharate31@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-29 22:20:01 +00:00
Anshuman Pandey
78f5de2f35 fix: adds swift and kotlin language conventions to formbricks docs (#6316) 2025-07-29 11:09:01 +00:00
Dhruwang Jariwala
b1a35d4a69 fix: unformatted db message in client display api (#6176) 2025-07-29 04:08:16 +00:00
Dhruwang Jariwala
2166c44470 feat: ID badge component (#6281) 2025-07-28 09:44:43 +00:00
Anshuman Pandey
080cf741e9 fix: adds api v1/responses docs for limit and skip parameters (#6314) 2025-07-28 07:44:04 +00:00
Anshuman Pandey
8881691509 refactor: refurbish logic editor UI (#6216) 2025-07-25 12:05:49 +00:00
Anshuman Pandey
3045f4437f fix: fixes status schedule updation (#6312) 2025-07-25 10:27:28 +00:00
Dhruwang Jariwala
91ace0e821 fix: scroll to bottom on error (#6301) 2025-07-25 09:11:41 +00:00
Dhruwang Jariwala
6ef281647a fix: unauthorised error on survey list page (#6302) 2025-07-25 06:10:48 +00:00
Dhruwang Jariwala
0aaaaa54ee chore: Don't force Project Onboarding for each project (#6299)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-25 06:10:30 +00:00
Harsh Bhat
b1f78e7bf2 docs: webhook payload (#6307) 2025-07-25 06:00:00 +00:00
Piyush Gupta
7086ce2ca3 fix: removes unused translations (#6308) 2025-07-24 12:55:02 +00:00
Piyush Gupta
8f8b549b1d chore: Remove the public result sharing page. (#6298)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-24 12:06:59 +00:00
Piyush Gupta
28514487e0 chore: sunset weekly summary (#6282) 2025-07-24 12:01:39 +00:00
Piyush Gupta
ee20af54c3 feat: adds an underline option in the rich text editor (#6274)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-23 10:54:05 +00:00
Johannes
d08ec4c9ab docs: Fix domain split docs (#6300)
Co-authored-by: Victor Hugo dos Santos <115753265+victorvhs017@users.noreply.github.com>
2025-07-23 03:54:53 -07:00
Piyush Gupta
891c83e232 fix: CTA question button URL validation (#6284)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-23 05:48:18 +00:00
Johannes
0b02b00b72 fix: link input length and accessibility error (#6283)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-23 05:01:16 +00:00
Harsh Thakur
a217cdd501 fix: email embed preview spacing issue (#6262) 2025-07-22 17:14:07 +00:00
om pharate
ebe50a4821 fix: render copy link button based on single use survey (#6288) 2025-07-22 16:31:54 +00:00
Johannes
cb68d9defc chore: enable blank issue (#6291) 2025-07-22 10:02:49 -07:00
Victor Hugo dos Santos
c42a706789 fix: Experimental workflow package.json version update (#6287) 2025-07-22 15:19:38 +00:00
Anshuman Pandey
3803111b19 fix: fixes personalized links when single use id is enabled (#6270) 2025-07-22 12:08:45 +00:00
Dhruwang Jariwala
30fdcff737 feat: reset survey (#6267) 2025-07-22 12:04:26 +00:00
Dhruwang Jariwala
e83cfa85a4 fix: github annotations (#6240) 2025-07-22 10:38:34 +00:00
Piyush Gupta
eee9ee8995 chore: Replaces Unkey and Update rate limiting in the management API v2. (#6273) 2025-07-22 09:33:29 +00:00
Dhruwang Jariwala
ed89f12af8 chore: rate limiting for server actions (#6271) 2025-07-22 09:18:12 +00:00
Piyush Gupta
f043314537 fix: required action revert logic (#6269) 2025-07-22 04:10:09 +00:00
Victor Hugo dos Santos
2ce842dd8d chore: updated SAML SSO docs (#6280) 2025-07-22 04:09:11 +00:00
Johannes
43b43839c5 chore: auto-add bug to eng project (#6277) 2025-07-21 08:33:27 -07:00
Piyush Gupta
8b6e3fec37 fix: response filters icons and text (#6266) 2025-07-21 08:48:10 +00:00
Anshuman Pandey
31bcf98779 fix: fixes PIN 4 digit length error (#6265) 2025-07-21 07:30:03 +00:00
Matti Nannt
b35cabcbcc chore(infra): enable cluster public access to mitigate tailscale issues (#6264) 2025-07-19 08:53:31 +02:00
Matti Nannt
4f435f1a1f fix: enable Tailscale subnet routes for EKS access (#6263) 2025-07-18 21:32:01 +02:00
Victor Hugo dos Santos
99c1e434df feat: Deploy to staging on pre-release builds (#6261) 2025-07-18 15:35:00 +00:00
Piyush Gupta
b13699801b fix: survey preview for suid enabled surveys (#6253)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-18 08:54:48 +00:00
Jakob Schott
ceb2e85d96 chore: 742 storybook setup and cursor rule (#6220)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-18 08:03:39 +00:00
Anshuman Pandey
c5f8b5ec32 fix: removes suid UI from the survey-editor (#6249)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-18 07:41:05 +00:00
Anshuman Pandey
bdbd57c2fc fix: adds read only survey url (#6252) 2025-07-18 05:14:32 +00:00
Victor Hugo dos Santos
d44aa17814 feat: add sentry sourcemaps to pre-releases (#6242) 2025-07-17 16:11:28 +00:00
Jakob Schott
23d38b4c5b chore: move tab component to storybook (#6214) 2025-07-17 09:26:31 +00:00
Piyush Gupta
58213969e8 feat: remove brevo contact on account deletion (#6231)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-16 16:00:34 +00:00
Victor Hugo dos Santos
ef973c8995 chore: merge rate limiter epic branch into main (#6236)
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Aditya <162564995+Naidu-4444@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: Suraj <surajsuthar0067@gmail.com>
Co-authored-by: Kshitij Sharma <63995641+kshitij-codes@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2025-07-16 12:28:59 +00:00
dependabot[bot]
bea02ba3b5 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#6161)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-16 10:42:54 +00:00
Piyush Jain
1c1e2ee09c chore: add timeout settings for production LB (#5884) 2025-07-16 09:08:11 +00:00
Piyush Gupta
2bf7fe6c54 docs: adds email address validation note (#6239) 2025-07-16 01:55:21 -07:00
Saurav Jain
9639402c39 fix: allow read and write API key permissions for /v1/management/me (#6178)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-16 07:52:10 +00:00
Victor Hugo dos Santos
53213b41ee feat: New share modal - "In App" tab (#6225)
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: Jakob Schott <jakob@formbricks.com>
2025-07-15 17:53:47 +00:00
Dhruwang Jariwala
b8b5eead7a fix: close survey on response limit setting behaviour (#6203)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-15 16:36:03 +00:00
Jakob Schott
a0044ce376 chore: reduced the breakpoint (#6232)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-15 13:49:26 +00:00
Piyush Gupta
b3a1f24683 fix: emails font size (#6228) 2025-07-15 13:37:13 +00:00
Dhruwang Jariwala
f06d48698a feat: social media tab (#6219)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-15 13:28:32 +00:00
Anshuman Pandey
acd508ba19 feat: sharing modal anonymous links (#6224)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-15 08:03:10 +00:00
Piyush Gupta
e5591686b4 fix: source tracking in link surveys (#6209) 2025-07-14 09:23:22 -07:00
Dhruwang Jariwala
7be7466eee feat: qr code tab (#6212)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-14 10:53:52 +00:00
Victor Hugo dos Santos
8af6c15998 feat: new share modal website embed and pop out (#6217) 2025-07-11 12:45:42 +00:00
Piyush Gupta
17d60eb1e7 feat: revamp sharing modal shell (#6190)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-11 04:17:43 +00:00
Johannes
d6ecafbc23 docs: add hidden fields for SDK note (#6215) 2025-07-10 07:35:09 -07:00
Dhruwang Jariwala
599e847686 chore: removed integrity hash chain from audit logging (#6202) 2025-07-10 10:43:57 +00:00
Victor Hugo dos Santos
4e52556f7e feat: add single contact using the API V2 (#6168) 2025-07-10 10:34:18 +00:00
Kshitij Sharma
492a59e7de fix: show multi-choice question first in styling preview (#6150)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-10 01:41:02 -07:00
Jakob Schott
e0be53805e fix: Spelling mistake for Nodemailer in docs (#5988) 2025-07-10 00:29:50 -07:00
Johannes
5c2860d1a4 docs: Personal Link docs (#6034)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-10 00:13:29 -07:00
Piyush Gupta
18ba5bbd8a fix: types in audit log wrapper (#6200) 2025-07-10 03:55:28 +00:00
Johannes
572b613034 docs: update prefilling docs (#6062) 2025-07-09 08:52:53 -07:00
Abhi-Bohora
a9c7140ba6 fix: Edit Recall button flicker when user types into the edit field (#6121)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-09 08:51:42 -07:00
Abhishek Sharma
7fa95cd74a fix: recall fallback input to be displayed on top of other contai… (#6124)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-09 08:51:27 -07:00
Nathanaël
8c7f36d496 chore: Update docker-compose.yml, fix syntax (#6158) 2025-07-09 17:39:58 +02:00
Jakob Schott
42dcbd3e7e chore: changed date format on license alert to MMM dd, YYYY (#6182) 2025-07-09 14:57:04 +00:00
Piyush Gupta
1c1cd99510 fix: unsaved survey dialog (#6201) 2025-07-09 08:14:32 +00:00
Dhruwang Jariwala
b0a7e212dd fix: suid copy issue on safari (#6174) 2025-07-08 10:50:02 +00:00
Dhruwang Jariwala
0c1f6f3c3a fix: translations (#6186) 2025-07-08 08:52:36 +00:00
Matti Nannt
9399b526b8 fix: run PR checks on every pull requests (#6185) 2025-07-08 11:07:03 +02:00
Dhruwang Jariwala
cd60032bc9 fix: row/column deletion in matrix question (#6184) 2025-07-08 07:12:16 +00:00
Dhruwang Jariwala
a941f994ea fix: removed userId from contact endpoint response (#6175)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-08 06:36:56 +00:00
Jakob Schott
75d170bce5 chore: removed unnecessary text bullet point from dialog (#6180) 2025-07-07 15:29:44 +00:00
Piyush Gupta
16caae6dd6 chore: upgrade to storybook 9 (#6141) 2025-07-07 09:55:22 +00:00
Kshitij Sharma
a490600479 fix: ensure date question respects question color styling (#6155)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-07 00:43:21 -07:00
Suraj
be28641722 fix: changing project name doesn't update in the sidebar and project selector (#6130)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-07 05:36:17 +00:00
Dhruwang Jariwala
4fdea3221b feat: Personal links (#6138)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-04 14:17:40 +00:00
Jakob Schott
fef30c54b2 feat: replace deprecated modals with new one (5824) (#5903)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
2025-07-04 11:44:36 +00:00
Johannes
75362eac7a chore: updating contribution docs (#6157) 2025-07-04 04:56:14 -07:00
Dhruwang Jariwala
6e3b224944 chore: sunset card shadow color (#6152) 2025-07-04 10:44:32 +00:00
Aditya
ef1be219b4 fix: Show Specific Error for Duplicate Tag Names (#6057)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-04 08:47:49 +00:00
1068 changed files with 65665 additions and 30151 deletions

View File

@@ -7,6 +7,7 @@ description: >
globs: [] globs: []
alwaysApply: agent-requested alwaysApply: agent-requested
--- ---
# Formbricks Database Schema Reference # Formbricks Database Schema Reference
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly. This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
@@ -16,6 +17,7 @@ This rule provides a reference to the Formbricks database structure. For the mos
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations. Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
### Core Hierarchy ### Core Hierarchy
``` ```
Organization Organization
└── Project └── Project
@@ -29,6 +31,7 @@ Organization
## Schema Reference ## Schema Reference
For the complete and up-to-date database schema, please refer to: For the complete and up-to-date database schema, please refer to:
- Main schema: `packages/database/schema.prisma` - Main schema: `packages/database/schema.prisma`
- JSON type definitions: `packages/database/json-types.ts` - JSON type definitions: `packages/database/json-types.ts`
@@ -37,17 +40,22 @@ The schema.prisma file contains all model definitions, relationships, enums, and
## Data Access Patterns ## Data Access Patterns
### Multi-tenancy ### Multi-tenancy
- All data is scoped by Organization - All data is scoped by Organization
- Environment-level isolation for surveys and contacts - Environment-level isolation for surveys and contacts
- Project-level grouping for related surveys - Project-level grouping for related surveys
### Soft Deletion ### Soft Deletion
Some models use soft deletion patterns: Some models use soft deletion patterns:
- Check `isActive` fields where present - Check `isActive` fields where present
- Use proper filtering in queries - Use proper filtering in queries
### Cascading Deletes ### Cascading Deletes
Configured cascade relationships: Configured cascade relationships:
- Organization deletion cascades to all child entities - Organization deletion cascades to all child entities
- Survey deletion removes responses, displays, triggers - Survey deletion removes responses, displays, triggers
- Contact deletion removes attributes and responses - Contact deletion removes attributes and responses
@@ -55,6 +63,7 @@ Configured cascade relationships:
## Common Query Patterns ## Common Query Patterns
### Survey with Responses ### Survey with Responses
```typescript ```typescript
// Include response count and latest responses // Include response count and latest responses
const survey = await prisma.survey.findUnique({ const survey = await prisma.survey.findUnique({
@@ -62,40 +71,40 @@ const survey = await prisma.survey.findUnique({
include: { include: {
responses: { responses: {
take: 10, take: 10,
orderBy: { createdAt: 'desc' } orderBy: { createdAt: "desc" },
}, },
_count: { _count: {
select: { responses: true } select: { responses: true },
} },
} },
}); });
``` ```
### Environment Scoping ### Environment Scoping
```typescript ```typescript
// Always scope by environment // Always scope by environment
const surveys = await prisma.survey.findMany({ const surveys = await prisma.survey.findMany({
where: { where: {
environmentId: environmentId, environmentId: environmentId,
// Additional filters... // Additional filters...
} },
}); });
``` ```
### Contact with Attributes ### Contact with Attributes
```typescript ```typescript
const contact = await prisma.contact.findUnique({ const contact = await prisma.contact.findUnique({
where: { id: contactId }, where: { id: contactId },
include: { include: {
attributes: { attributes: {
include: { include: {
attributeKey: true attributeKey: true,
} },
} },
} },
}); });
``` ```
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security. This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.

View File

@@ -18,7 +18,6 @@ apps/web/
│ ├── (app)/ # Main application routes │ ├── (app)/ # Main application routes
│ ├── (auth)/ # Authentication routes │ ├── (auth)/ # Authentication routes
│ ├── api/ # API routes │ ├── api/ # API routes
│ └── share/ # Public sharing routes
├── components/ # Shared components ├── components/ # Shared components
├── lib/ # Utility functions and services ├── lib/ # Utility functions and services
└── modules/ # Feature-specific modules └── modules/ # Feature-specific modules
@@ -43,7 +42,6 @@ The application uses Next.js 13+ app router with route groups:
### Dynamic Routes ### Dynamic Routes
- `[environmentId]` - Environment-specific routes - `[environmentId]` - Environment-specific routes
- `[surveyId]` - Survey-specific routes - `[surveyId]` - Survey-specific routes
- `[sharingKey]` - Public sharing routes
## Service Layer Pattern ## Service Layer Pattern

View File

@@ -0,0 +1,232 @@
---
description: Security best practices and guidelines for writing GitHub Actions and workflows
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
---
# GitHub Actions Security Best Practices
## Required Security Measures
### 1. Set Minimum GITHUB_TOKEN Permissions
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
```yaml
permissions:
contents: read
# Only add additional permissions if absolutely necessary:
# pull-requests: write # for commenting on PRs
# issues: write # for creating/updating issues
# checks: write # for publishing check results
```
### 2. Add Harden-Runner as First Step
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
```yaml
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit # or 'block' for stricter security
```
### 3. Pin Actions to Full Commit SHA
**Always** pin third-party actions to their full commit SHA, not tags:
```yaml
# ❌ BAD - uses mutable tag
- uses: actions/checkout@v4
# ✅ GOOD - pinned to immutable commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
### 4. Secure Variable Handling
Prevent command injection by properly quoting variables:
```yaml
# ❌ BAD - potential command injection
run: echo "Processing ${{ inputs.user_input }}"
# ✅ GOOD - properly quoted
env:
USER_INPUT: ${{ inputs.user_input }}
run: echo "Processing ${USER_INPUT}"
```
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
### 5. Environment Variables for Secrets
Store sensitive data in environment variables, not inline:
```yaml
# ❌ BAD
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
# ✅ GOOD
env:
API_TOKEN: ${{ secrets.TOKEN }}
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
```
## Workflow Structure Best Practices
### Required Workflow Elements
```yaml
name: "Descriptive Workflow Name"
on:
# Define specific triggers
push:
branches: [main]
pull_request:
branches: [main]
# Always set explicit permissions
permissions:
contents: read
jobs:
job-name:
name: "Descriptive Job Name"
runs-on: ubuntu-latest
timeout-minutes: 30 # tune per job; standardize repo-wide
# Set job-level permissions if different from workflow level
permissions:
contents: read
steps:
# Always start with Harden-Runner on ubuntu-latest
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
# Pin all actions to commit SHA
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
### Input Validation for Actions
For composite actions, always validate inputs:
```yaml
inputs:
user_input:
description: "User provided input"
required: true
runs:
using: "composite"
steps:
- name: Validate input
shell: bash
run: |
# Harden shell and validate input format/content before use
set -euo pipefail
USER_INPUT="${{ inputs.user_input }}"
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "❌ Invalid input format"
exit 1
fi
```
## Docker Security in Actions
### Pin Docker Images to Digests
```yaml
# ❌ BAD - mutable tag
container: node:18
# ✅ GOOD - pinned to digest
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
```
## Common Patterns
### Secure File Operations
```yaml
- name: Process files securely
shell: bash
env:
FILE_PATH: ${{ inputs.file_path }}
run: |
set -euo pipefail # Fail on errors, undefined vars, pipe failures
# Use absolute paths and validate
SAFE_PATH=$(realpath "${FILE_PATH}")
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
echo "❌ Path outside workspace"
exit 1
fi
```
### Artifact Handling
```yaml
- name: Upload artifacts securely
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: build-artifacts
path: |
dist/
!dist/**/*.log # Exclude sensitive files
retention-days: 30
```
### GHCR authentication for pulls/scans
```yaml
# Minimal permissions required for GHCR pulls/scans
permissions:
contents: read
packages: read
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
```
## Security Checklist
- [ ] Minimum GITHUB_TOKEN permissions set
- [ ] Harden-Runner added to all ubuntu-latest jobs
- [ ] All third-party actions pinned to commit SHA
- [ ] Input validation implemented for custom actions
- [ ] Variables properly quoted in shell scripts
- [ ] Secrets stored in environment variables
- [ ] Docker images pinned to digests (if used)
- [ ] Error handling with `set -euo pipefail`
- [ ] File paths validated and sanitized
- [ ] No sensitive data in logs or outputs
- [ ] GHCR login performed before pulls/scans (packages: read)
- [ ] Job timeouts configured (`timeout-minutes`)
## Recommended Additional Workflows
Consider adding these security-focused workflows to your repository:
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
3. **Dependabot Configuration** - Automated dependency updates
## Resources
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)

View File

@@ -1,5 +1,5 @@
--- ---
description: description: Migrate deprecated UI components to a unified component
globs: globs:
alwaysApply: false alwaysApply: false
--- ---

View File

@@ -0,0 +1,177 @@
---
description: Create a story in Storybook for a given component
globs:
alwaysApply: false
---
# Formbricks Storybook Stories
## When generating Storybook stories for Formbricks components:
### 1. **File Structure**
- Create `stories.tsx` (not `.stories.tsx`) in component directory
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
- Import component from `"./index"`
### 2. **Story Structure Template**
```tsx
import { Meta, StoryObj } from "@storybook/react-vite";
import { ComponentName } from "./index";
// For complex components with configurable options
// consider this as an example the options need to reflect the props types
interface StoryOptions {
showIcon: boolean;
numberOfElements: number;
customLabels: string[];
}
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
const meta: Meta<StoryProps> = {
title: "UI/ComponentName",
component: ComponentName,
tags: ["autodocs"],
parameters: {
layout: "centered",
controls: { sort: "alpha", exclude: [] },
docs: {
description: {
component: "The **ComponentName** component provides [description].",
},
},
},
argTypes: {
// Organize in exactly these categories: Behavior, Appearance, Content
},
};
export default meta;
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
```
### 3. **ArgTypes Organization**
Organize ALL argTypes into exactly three categories:
- **Behavior**: disabled, variant, onChange, etc.
- **Appearance**: size, color, layout, styling, etc.
- **Content**: text, icons, numberOfElements, etc.
Format:
```tsx
argTypes: {
propName: {
control: "select" | "boolean" | "text" | "number",
options: ["option1", "option2"], // for select
description: "Clear description",
table: {
category: "Behavior" | "Appearance" | "Content",
type: { summary: "string" },
defaultValue: { summary: "default" },
},
order: 1,
},
}
```
### 4. **Required Stories**
Every component must include:
- `Default`: Most common use case
- `Disabled`: If component supports disabled state
- `WithIcon`: If component supports icons
- Variant stories for each variant (Primary, Secondary, Error, etc.)
- Edge case stories (ManyElements, LongText, CustomStyling)
### 5. **Story Format**
```tsx
export const Default: Story = {
args: {
// Props with realistic values
},
};
export const EdgeCase: Story = {
args: { /* ... */ },
parameters: {
docs: {
description: {
story: "Use this when [specific scenario].",
},
},
},
};
```
### 6. **Dynamic Content Pattern**
For components with dynamic content, create render function:
```tsx
const renderComponent = (args: StoryProps) => {
const { numberOfElements, showIcon, customLabels } = args;
// Generate dynamic content
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
id: `element-${i}`,
label: customLabels[i] || `Element ${i + 1}`,
icon: showIcon ? <IconComponent /> : undefined,
}));
return <ComponentName {...args} elements={elements} />;
};
export const Dynamic: Story = {
render: renderComponent,
args: {
numberOfElements: 3,
showIcon: true,
customLabels: ["First", "Second", "Third"],
},
};
```
### 7. **State Management**
For interactive components:
```tsx
import { useState } from "react";
const ComponentWithState = (args: any) => {
const [value, setValue] = useState(args.defaultValue);
return (
<ComponentName
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue);
args.onChange?.(newValue);
}}
/>
);
};
export const Interactive: Story = {
render: ComponentWithState,
args: { defaultValue: "initial" },
};
```
### 8. **Quality Requirements**
- Include component description in parameters.docs
- Add story documentation for non-obvious use cases
- Test edge cases (overflow, empty states, many elements)
- Ensure no TypeScript errors
- Use realistic prop values
- Include at least 3-5 story variants
- Example values need to be in the context of survey application
### 9. **Naming Conventions**
- **Story titles**: "UI/ComponentName"
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
- **Props**: camelCase matching component props
### 10. **Special Cases**
- **Generic components**: Remove `component` from meta if type conflicts
- **Form components**: Include Invalid, WithValue stories
- **Navigation**: Include ManyItems stories
- **Modals, Dropdowns and Popups **: Include trigger and content structure
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.

View File

@@ -90,7 +90,7 @@ When testing hooks that use React Context:
vi.mocked(useResponseFilter).mockReturnValue({ vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: { selectedFilter: {
filter: [], filter: [],
onlyComplete: false, responseStatus: "all",
}, },
setSelectedFilter: vi.fn(), setSelectedFilter: vi.fn(),
selectedOptions: { selectedOptions: {
@@ -291,11 +291,6 @@ test("handles different modes", async () => {
expect(vi.mocked(regularApi)).toHaveBeenCalled(); expect(vi.mocked(regularApi)).toHaveBeenCalled();
}); });
// Test sharing mode
vi.mocked(useParams).mockReturnValue({
surveyId: "123",
sharingKey: "share-123"
});
rerender(); rerender();
await waitFor(() => { await waitFor(() => {

View File

@@ -62,9 +62,6 @@ SMTP_PASSWORD=smtpPassword
# Uncomment the variables you would like to use and customize the values. # Uncomment the variables you would like to use and customize the values.
# Custom local storage path for file uploads
#UPLOADS_DIR=
############## ##############
# S3 STORAGE # # S3 STORAGE #
############## ##############
@@ -189,15 +186,11 @@ ENTERPRISE_LICENSE_KEY=
UNSPLASH_ACCESS_KEY= UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided) # 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_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this) # 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: # REDIS_HTTP_URL:
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# INTERCOM_APP_ID= # INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY= # INTERCOM_SECRET_KEY=
@@ -219,7 +212,7 @@ UNKEY_ROOT_KEY=
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours) # Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400 # SESSION_MAX_AGE=86400
# Audit logs options. Requires REDIS_URL env varibale. Default 0. # Audit logs options. Default 0.
# AUDIT_LOG_ENABLED=0 # AUDIT_LOG_ENABLED=0
# If the ip should be added in the log or not. Default 0 # If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0 # AUDIT_LOG_GET_USER_IP=0

View File

@@ -1,6 +1,7 @@
name: Bug report name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D" description: "Found a bug? Please fill out the sections below. \U0001F44D"
type: bug type: bug
projects: "formbricks/8"
labels: ["bug"] labels: ["bug"]
body: body:
- type: textarea - type: textarea

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: false blank_issues_enabled: true
contact_links: contact_links:
- name: Questions - name: Questions
url: https://github.com/formbricks/formbricks/discussions url: https://github.com/formbricks/formbricks/discussions

View File

@@ -62,10 +62,12 @@ runs:
shell: bash shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env - name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: | run: |
RANDOM_KEY=$(openssl rand -hex 32) RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
shell: bash shell: bash
- run: | - run: |

View File

@@ -1,66 +1,49 @@
name: 'Upload Sentry Sourcemaps' name: "Upload Sentry Sourcemaps"
description: 'Extract sourcemaps from Docker image and upload to Sentry' description: "Extract sourcemaps from Docker image and upload to Sentry"
inputs: inputs:
docker_image: docker_image:
description: 'Docker image to extract sourcemaps from' description: "Docker image to extract sourcemaps from"
required: true required: true
release_version: release_version:
description: 'Sentry release version (e.g., v1.2.3)' description: "Sentry release version (e.g., v1.2.3)"
required: true required: true
sentry_auth_token: sentry_auth_token:
description: 'Sentry authentication token' description: "Sentry authentication token"
required: true required: true
environment:
description: "Sentry environment (e.g., production, staging)"
required: false
default: "staging"
runs: runs:
using: 'composite' using: "composite"
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Validate Sentry auth token - name: Extract sourcemaps from Docker image
shell: bash shell: bash
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}
run: | run: |
set -euo pipefail set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling # Validate docker image format (basic validation)
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}" if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
# Test the token by making a simple API call to Sentry echo "Provided: ${DOCKER_IMAGE}"
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
"https://sentry.io/api/0/organizations/formbricks/")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" != "200" ]; then
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
if [ -f /tmp/sentry_response.json ]; then
echo "Response body:"
cat /tmp/sentry_response.json
fi
exit 1 exit 1
fi fi
echo "✅ Sentry auth token validated successfully" echo "📦 Extracting sourcemaps from Docker image: ${DOCKER_IMAGE}"
# Clean up temp file
rm -f /tmp/sentry_response.json
- name: Extract sourcemaps from Docker image
shell: bash
run: |
set -euo pipefail
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
# Create temporary container from the image and capture its ID # Create temporary container from the image and capture its ID
echo "Creating temporary container..." echo "Creating temporary container..."
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}") CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
echo "Container created with ID: $CONTAINER_ID" echo "Container created with ID: ${CONTAINER_ID}"
# Set up cleanup function to ensure container is removed on script exit # Set up cleanup function to ensure container is removed on script exit
cleanup_container() { cleanup_container() {
@@ -72,13 +55,13 @@ runs:
# Remove the container if it exists (ignore errors if already removed) # Remove the container if it exists (ignore errors if already removed)
if [ -n "$CONTAINER_ID" ]; then if [ -n "$CONTAINER_ID" ]; then
docker rm -f "$CONTAINER_ID" 2>/dev/null || true docker rm -f "$CONTAINER_ID" 2>/dev/null || true
echo "Container $CONTAINER_ID removed" echo "Container ${CONTAINER_ID} removed"
fi fi
# Exit with the original exit code to preserve script success/failure status # Exit with the original exit code to preserve script success/failure status
exit $original_exit_code exit $original_exit_code
} }
# Register cleanup function to run on script exit (success or failure) # Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT trap cleanup_container EXIT
@@ -93,7 +76,7 @@ runs:
fi fi
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l) sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
echo "✅ Found $sourcemap_count sourcemap files" echo "✅ Found ${sourcemap_count} sourcemap files"
if [ "$sourcemap_count" -eq 0 ]; then if [ "$sourcemap_count" -eq 0 ]; then
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled." echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
@@ -107,9 +90,9 @@ runs:
SENTRY_ORG: formbricks SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud SENTRY_PROJECT: formbricks-cloud
with: with:
environment: production environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }} version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/' sourcemaps: "./extracted-next/"
- name: Clean up extracted files - name: Clean up extracted files
shell: bash shell: bash

View File

@@ -1,82 +0,0 @@
name: "Apply issue labels to PR"
on:
pull_request_target:
types:
- opened
permissions:
contents: read
jobs:
label_on_pr:
runs-on: ubuntu-latest
permissions:
contents: none
issues: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Apply labels from linked issue to PR
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
async function getLinkedIssues(owner, repo, prNumber) {
const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
closingIssuesReferences(first: 10) {
nodes {
number
labels(first: 10) {
nodes {
name
}
}
}
}
}
}
}`;
const variables = {
owner: owner,
repo: repo,
prNumber: prNumber,
};
const result = await github.graphql(query, variables);
return result.repository.pullRequest.closingIssuesReferences.nodes;
}
const pr = context.payload.pull_request;
const linkedIssues = await getLinkedIssues(
context.repo.owner,
context.repo.repo,
pr.number
);
const labelsToAdd = new Set();
for (const issue of linkedIssues) {
if (issue.labels && issue.labels.nodes) {
for (const label of issue.labels.nodes) {
labelsToAdd.add(label.name);
}
}
}
if (labelsToAdd.size) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: Array.from(labelsToAdd),
});
}

View File

@@ -0,0 +1,99 @@
name: Build & Push Docker to ECR
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to push (e.g., v3.16.1)"
required: true
default: "v3.16.1"
permissions:
contents: read
id-token: write
env:
ECR_REGION: ${{ vars.ECR_REGION }}
# ECR settings are sourced from repository/environment variables for portability across envs/forks
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
DOCKERFILE: apps/web/Dockerfile
CONTEXT: .
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate image tag input
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
if [[ -z "${IMAGE_TAG}" ]]; then
echo "❌ Image tag is required (non-empty)."
exit 1
fi
if (( ${#IMAGE_TAG} > 128 )); then
echo "❌ Image tag must be at most 128 characters."
exit 1
fi
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
exit 1
fi
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
echo "❌ Image tag must not start or end with '.' or '-'."
exit 1
fi
- name: Validate required variables
shell: bash
env:
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
ECR_REGION: ${{ env.ECR_REGION }}
run: |
set -euo pipefail
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
exit 1
fi
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
with:
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
aws-region: ${{ env.ECR_REGION }}
- name: Log in to Amazon ECR
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Build and push image (Depot remote builder)
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: ${{ env.CONTEXT }}
file: ${{ env.DOCKERFILE }}
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ inputs.image_tag }}
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}

View File

@@ -6,12 +6,14 @@ on:
- main - main
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
chromatic: chromatic:
name: Run Chromatic name: Run Chromatic
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read
packages: write packages: write
id-token: write id-token: write
actions: read actions: read

View File

@@ -1,27 +0,0 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
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
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0

View File

@@ -17,8 +17,8 @@ on:
required: true required: true
type: choice type: choice
options: options:
- stage - staging
- prod - production
workflow_call: workflow_call:
inputs: inputs:
VERSION: VERSION:
@@ -37,21 +37,27 @@ on:
permissions: permissions:
id-token: write id-token: write
contents: write contents: read
jobs: jobs:
helmfile-deploy: helmfile-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout - name: Checkout
uses: actions/checkout@v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale - name: Tailscale
uses: tailscale/github-action@v3 uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
with: with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github tags: tag:github
args: --accept-routes
- name: Configure AWS Credentials - name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0 uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
@@ -65,9 +71,9 @@ jobs:
env: env:
AWS_REGION: eu-central-1 AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@v2 - uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
name: Deploy Formbricks Cloud Prod name: Deploy Formbricks Cloud Production
if: inputs.ENVIRONMENT == 'prod' if: inputs.ENVIRONMENT == 'production'
env: env:
VERSION: ${{ inputs.VERSION }} VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }} REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -83,9 +89,9 @@ jobs:
helmfile-auto-init: "false" helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@v2 - uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
name: Deploy Formbricks Cloud Stage name: Deploy Formbricks Cloud Staging
if: inputs.ENVIRONMENT == 'stage' if: inputs.ENVIRONMENT == 'staging'
env: env:
VERSION: ${{ inputs.VERSION }} VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }} REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -101,19 +107,20 @@ jobs:
helmfile-workdirectory: infra/formbricks-cloud-helm helmfile-workdirectory: infra/formbricks-cloud-helm
- name: Purge Cloudflare Cache - name: Purge Cloudflare Cache
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }} if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
env: env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
run: | run: |
# Set hostname based on environment # Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then if [[ "$ENVIRONMENT" == "production" ]]; then
PURGE_HOST="app.formbricks.com" PURGE_HOST="app.formbricks.com"
else else
PURGE_HOST="stage.app.formbricks.com" PURGE_HOST="stage.app.formbricks.com"
fi fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)" echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
# Prepare JSON payload for selective cache purge # Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF json_payload=$(cat << EOF

View File

@@ -39,42 +39,68 @@ jobs:
--health-retries 5 --health-retries 5
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build Docker Image - name: Build Docker Image
uses: docker/build-push-action@v6 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
env:
GITHUB_SHA: ${{ github.sha }}
with: with:
context: . context: .
file: ./apps/web/Dockerfile file: ./apps/web/Dockerfile
push: false push: false
load: true load: true
tags: formbricks-test:${{ github.sha }} tags: formbricks-test:${{ env.GITHUB_SHA }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
secrets: | secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }} database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Verify PostgreSQL Connection - name: Verify and Initialize PostgreSQL
run: | run: |
echo "Verifying PostgreSQL connection..." echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection # Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql # Test connection using psql with timeout and proper error handling
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL" echo "Testing PostgreSQL connection with 30 second timeout..."
if timeout 30 bash -c 'until PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" >/dev/null 2>&1; do
echo "Waiting for PostgreSQL to be ready..."
sleep 2
done'; then
echo "✅ PostgreSQL connection successful"
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "SELECT version();"
# Enable necessary extensions that might be required by migrations
echo "Enabling required PostgreSQL extensions..."
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "CREATE EXTENSION IF NOT EXISTS vector;" || echo "Vector extension already exists or not available"
else
echo "❌ PostgreSQL connection failed after 30 seconds"
exit 1
fi
# Show network configuration # Show network configuration
echo "Network configuration:" echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432" netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Test Docker Image with Health Check - name: Test Docker Image with Health Check
shell: bash shell: bash
env:
GITHUB_SHA: ${{ github.sha }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
run: | run: |
echo "🧪 Testing if the Docker image starts correctly..." echo "🧪 Testing if the Docker image starts correctly..."
@@ -86,29 +112,12 @@ jobs:
$DOCKER_RUN_ARGS \ $DOCKER_RUN_ARGS \
-p 3000:3000 \ -p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \ -e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \ -e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-d formbricks-test:${{ github.sha }} -d "formbricks-test:$GITHUB_SHA"
# Give it more time to start up # Start health check polling immediately (every 5 seconds for up to 5 minutes)
echo "Waiting 45 seconds for application to start..." echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."
sleep 45 MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes
# Check if the container is running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
echo "❌ Container failed to start properly!"
docker logs formbricks-test
exit 1
else
echo "✅ Container started successfully!"
fi
# Try connecting to PostgreSQL from inside the container
echo "Testing PostgreSQL connection from inside container..."
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
# Try to access the health endpoint
echo "🏥 Testing /health endpoint..."
MAX_RETRIES=10
RETRY_COUNT=0 RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false HEALTH_CHECK_SUCCESS=false
@@ -116,38 +125,32 @@ jobs:
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1)) RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Check if container is still running
# Show container logs before each attempt to help debugging if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then
if [ $RETRY_COUNT -gt 1 ]; then echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!"
echo "📋 Current container logs:" echo "📋 Container logs:"
docker logs --tail 20 formbricks-test docker logs formbricks-test
exit 1
fi fi
# Get detailed curl output for debugging # Show progress and diagnostic info every 12 attempts (1 minute intervals)
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1) if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then
CURL_EXIT_CODE=$? echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..."
echo "📋 Recent container logs:"
echo "Curl exit code: $CURL_EXIT_CODE" docker logs --tail 10 formbricks-test
echo "Curl output: $HTTP_OUTPUT"
if [ $CURL_EXIT_CODE -eq 0 ]; then
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
echo "Status code detected: $STATUS_CODE"
if [ "$STATUS_CODE" = "200" ]; then
echo "✅ Health check successful!"
HEALTH_CHECK_SUCCESS=true
break
else
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
fi
else
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
fi fi
echo "Waiting 15 seconds before next attempt..." # Try health endpoint with shorter timeout for faster polling
sleep 15 # Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx)
if curl -f -s -m 10 http://localhost:3000/health >/dev/null 2>&1; then
echo "✅ Health check successful after $((RETRY_COUNT * 5)) seconds!"
HEALTH_CHECK_SUCCESS=true
break
fi
# Wait 5 seconds before next attempt
sleep 5
done done
# Show full container logs for debugging # Show full container logs for debugging
@@ -160,7 +163,7 @@ jobs:
# Exit with failure if health check did not succeed # Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $MAX_RETRIES attempts" echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)"
exit 1 exit 1
fi fi

View File

@@ -0,0 +1,70 @@
name: Docker Security Scan
on:
schedule:
- cron: "0 2 * * *" # Daily at 2 AM UTC
workflow_dispatch:
workflow_run:
workflows: ["Docker Release to Github"]
types: [completed]
permissions:
contents: read
packages: read
security-events: write
jobs:
scan:
name: Vulnerability Scan
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout (for SARIF fingerprinting only)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
- name: Determine ref and commit for upload
id: gitref
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "workflow_run" ]]; then
echo "ref=refs/heads/${HEAD_BRANCH}" >> "$GITHUB_OUTPUT"
echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
else
echo "ref=${GITHUB_REF}" >> "$GITHUB_OUTPUT"
echo "sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
with:
image-ref: "ghcr.io/${{ github.repository }}:latest"
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH,MEDIUM,LOW"
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
if: ${{ always() }}
with:
sarif_file: "trivy-results.sarif"
ref: ${{ steps.gitref.outputs.ref }}
sha: ${{ steps.gitref.outputs.sha }}
category: "trivy-container-scan"

View File

@@ -55,6 +55,18 @@ jobs:
--health-interval=10s --health-interval=10s
--health-timeout=5s --health-timeout=5s
--health-retries=5 --health-retries=5
minio:
image: bitnami/minio:2025.7.23-debian-12-r5
env:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- 9000:9000
options: >-
--health-cmd="curl -fsS http://localhost:9000/minio/health/live || exit 1"
--health-interval=10s
--health-timeout=5s
--health-retries=20
steps: steps:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -89,10 +101,53 @@ jobs:
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${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/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env echo "" >> .env
echo "E2E_TESTING=1" >> .env echo "E2E_TESTING=1" >> .env
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=minioadmin" >> .env
echo "S3_SECRET_KEY=minioadmin" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash shell: bash
- name: Install MinIO client (mc)
run: |
wget -qO mc https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x mc
sudo mv mc /usr/local/bin/mc
- name: Wait for MinIO and create S3 bucket
run: |
echo "Waiting for MinIO to be ready..."
for i in {1..60}; do
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
echo "MinIO is up after ${i} seconds"
break
fi
echo "Waiting for MinIO... (attempt ${i}/60)"
sleep 1
done
# Give MinIO a bit more time to fully initialize
sleep 5
echo "Configuring MinIO client..."
mc alias set local http://localhost:9000 minioadmin minioadmin
echo "Creating S3 bucket..."
mc mb --ignore-existing local/formbricks-e2e
echo "Verifying bucket creation..."
mc ls local/
echo "Testing MinIO connectivity..."
curl -fsS http://localhost:9000/minio/health/live && echo "✓ MinIO health check passed"
echo "Testing S3 API endpoint..."
curl -fsS http://localhost:9000/ && echo "✓ MinIO S3 API accessible" || echo "✗ MinIO S3 API not accessible"
- name: Build App - name: Build App
run: | run: |
pnpm build --filter=@formbricks/web... pnpm build --filter=@formbricks/web...
@@ -102,6 +157,12 @@ jobs:
# pnpm prisma migrate deploy # pnpm prisma migrate deploy
pnpm db:migrate:dev pnpm db:migrate:dev
- name: Run Rate Limiter Load Tests
run: |
echo "Running rate limiter load tests with Redis/Valkey..."
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
shell: bash
- name: Check for Enterprise License - name: Check for Enterprise License
run: | run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-) LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
@@ -129,6 +190,22 @@ jobs:
sleep 10 sleep 10
done done
- name: Test Storage and MinIO Integration
run: |
echo "Testing MinIO file upload with mc client..."
# Test file upload using MinIO client
echo "test content" > test-file.txt
mc cp test-file.txt local/formbricks-e2e/test-file.txt
echo "Verifying file was uploaded..."
mc ls local/formbricks-e2e/
echo "Testing file download..."
mc cp local/formbricks-e2e/test-file.txt downloaded-test-file.txt
cat downloaded-test-file.txt
echo "MinIO integration test completed successfully!"
- name: Install Playwright - name: Install Playwright
run: pnpm exec playwright install --with-deps run: pnpm exec playwright install --with-deps

View File

@@ -1,20 +1,29 @@
name: Build, release & deploy Formbricks images name: Build, release & deploy Formbricks images
on: on:
workflow_dispatch: release:
push: types: [published]
tags:
- "v*" permissions:
contents: read
jobs: jobs:
docker-build: docker-build:
name: Build & release stable docker image name: Build & release docker image
if: startsWith(github.ref, 'refs/tags/v') permissions:
contents: read
packages: write
id-token: write
uses: ./.github/workflows/release-docker-github.yml uses: ./.github/workflows/release-docker-github.yml
secrets: inherit secrets: inherit
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
helm-chart-release: helm-chart-release:
name: Release Helm Chart name: Release Helm Chart
permissions:
contents: read
packages: write
uses: ./.github/workflows/release-helm-chart.yml uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit secrets: inherit
needs: needs:
@@ -24,6 +33,9 @@ jobs:
deploy-formbricks-cloud: deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud name: Deploy Helm Chart to Formbricks Cloud
permissions:
contents: read
id-token: write
secrets: inherit secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml uses: ./.github/workflows/deploy-formbricks-cloud.yml
needs: needs:
@@ -31,7 +43,7 @@ jobs:
- helm-chart-release - helm-chart-release
with: with:
VERSION: v${{ needs.docker-build.outputs.VERSION }} VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod" ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
upload-sentry-sourcemaps: upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps name: Upload Sentry Sourcemaps
@@ -42,8 +54,13 @@ jobs:
- docker-build - docker-build
- deploy-formbricks-cloud - deploy-formbricks-cloud
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout - name: Checkout
uses: actions/checkout@v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -54,3 +71,4 @@ jobs:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }} docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }} release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: ${{ github.event.release.prerelease && 'staging' || 'production' }}

View File

@@ -10,8 +10,6 @@ permissions:
on: on:
pull_request: pull_request:
branches:
- main
merge_group: merge_group:
workflow_dispatch: workflow_dispatch:

View File

@@ -29,6 +29,10 @@ jobs:
# with sigstore/fulcio when running outside of PRs. # with sigstore/fulcio when running outside of PRs.
id-token: write id-token: write
outputs:
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
steps: steps:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -37,6 +41,55 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Generate SemVer version from branch or tag
id: generate_version
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type from environment variables
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
if [[ "$REF_TYPE" == "tag" ]]; then
# If running from a tag, use the tag name
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
echo "Using SemVer tag: $VERSION"
else
# Tag is not SemVer, treat as prerelease
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_TAG"
echo "Using tag as prerelease: $VERSION"
fi
else
# Running from branch, use branch name as prerelease
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_BRANCH"
echo "Using branch as prerelease: $VERSION"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set Sentry environment in .env
run: |
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
echo "SENTRY_ENVIRONMENT=staging" >> .env
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
else
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
fi
- name: Set up Depot CLI - name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0 uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
@@ -83,6 +136,21 @@ jobs:
database_url=${{ secrets.DUMMY_DATABASE_URL }} database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Extract image info for sourcemap upload
id: extract_image_info
run: |
# Use the first readable tag from metadata action output
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
# Use the generated version for Sentry release
RELEASE_VERSION="$VERSION"
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
echo "Docker image: $DOCKER_IMAGE"
echo "Release version: $RELEASE_VERSION"
echo "Available tags: ${{ steps.meta.outputs.tags }}"
# Sign the resulting Docker image digest except on PRs. # Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker # This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish # repository is public to avoid leaking data. If you would like to publish
@@ -97,3 +165,30 @@ jobs:
# This step uses the identity token to provision an ephemeral certificate # This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance. # against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- build
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }}
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: staging

View File

@@ -7,6 +7,12 @@ name: Docker Release to Github
on: on:
workflow_call: workflow_call:
inputs:
IS_PRERELEASE:
description: "Whether this is a prerelease (affects latest tag)"
required: false
type: boolean
default: false
outputs: outputs:
VERSION: VERSION:
description: release version description: release version
@@ -20,6 +26,9 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -45,10 +54,23 @@ jobs:
- name: Get Release Tag - name: Get Release Tag
id: extract_release_tag id: extract_release_tag
run: | run: |
TAG=${{ github.ref }} # Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG="$GITHUB_REF"
TAG=${TAG#refs/tags/v} TAG=${TAG#refs/tags/v}
# Validate the extracted tag format
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Original ref: $GITHUB_REF"
echo "Extracted tag: $TAG"
exit 1
fi
# Safely add to environment variables
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"
- name: Update package.json version - name: Update package.json version
run: | run: |
@@ -81,6 +103,13 @@ jobs:
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Default semver tags (version, major.minor, major)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Only tag as 'latest' for stable releases (not prereleases)
type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action

View File

@@ -26,8 +26,23 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Extract release version - name: Validate input version
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV env:
INPUT_VERSION: ${{ inputs.VERSION }}
run: |
set -euo pipefail
# Validate input version format (expects clean semver without 'v' prefix)
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Expected: clean version without 'v' prefix"
echo "Provided: $INPUT_VERSION"
exit 1
fi
# Store validated version in environment variable
echo "VERSION<<EOF" >> $GITHUB_ENV
echo "$INPUT_VERSION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Set up Helm - name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5 uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
@@ -35,15 +50,18 @@ jobs:
version: latest version: latest
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Install YQ - name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1 uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version - name: Update Chart.yaml with new version
run: | run: |
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
- name: Package Helm chart - name: Package Helm chart
run: | run: |
@@ -51,4 +69,4 @@ jobs:
- name: Push Helm chart to GitHub Container Registry - name: Push Helm chart to GitHub Container Registry
run: | run: |
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts

View File

@@ -1,81 +0,0 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: "17 17 * * 6"
push:
branches: ["main"]
workflow_dispatch:
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Add this permission
actions: write # Required for artifact upload
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: sarif
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
sarif_file: results.sarif

View File

@@ -56,11 +56,3 @@ jobs:
``` ```
${{ steps.lint_pr_title.outputs.error_message }} ${{ steps.lint_pr_title.outputs.error_message }}
``` ```
# 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@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions for pull request titles! 🙏

View File

@@ -43,6 +43,7 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${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/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Run tests with coverage - name: Run tests with coverage
run: | run: |

View File

@@ -14,12 +14,14 @@ on:
paths: paths:
- "infra/terraform/**" - "infra/terraform/**"
permissions:
contents: read
jobs: jobs:
terraform: terraform:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
id-token: write id-token: write
contents: read
pull-requests: write pull-requests: write
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -33,7 +35,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale - name: Tailscale
uses: tailscale/github-action@v3 uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
with: with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}

View File

@@ -41,6 +41,7 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${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/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Test - name: Test
run: pnpm test run: pnpm test

View File

@@ -27,10 +27,18 @@ jobs:
- name: Get source branch name - name: Get source branch name
id: branch-name id: branch-name
env:
RAW_BRANCH: ${{ github.head_ref }}
run: | run: |
RAW_BRANCH="${{ github.head_ref }}" # Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g') SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
# Additional validation - ensure branch name is not empty after sanitization
if [[ -z "$SOURCE_BRANCH" ]]; then
echo "❌ Error: Branch name is empty after sanitization"
echo "Original branch: $RAW_BRANCH"
exit 1
fi
# Safely add to environment variables using GitHub's recommended method # Safely add to environment variables using GitHub's recommended method
# This prevents environment variable injection attacks # This prevents environment variable injection attacks

View File

@@ -23,24 +23,26 @@ jobs:
upload-sourcemaps: upload-sourcemaps:
name: Upload Sourcemaps to Sentry name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout - name: Checkout
uses: actions/checkout@v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set Docker Image - name: Set Docker Image
run: | run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
if [ -n "${{ inputs.tag_version }}" ]; then env:
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
- name: Upload Sourcemaps to Sentry - name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps uses: ./.github/actions/upload-sentry-sourcemaps
with: with:
docker_image: ${{ env.DOCKER_IMAGE }} docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }} release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -1,32 +0,0 @@
name: "Welcome new contributors"
on:
issues:
types: opened
pull_request_target:
types: opened
permissions:
pull-requests: write
issues: write
jobs:
welcome-message:
name: Welcoming New Users
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event.action == 'opened'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you so much for making your first Pull Request and taking the time to improve Formbricks! 🚀🙏❤️
Feel free to join the conversation on [Github Discussions](https://github.com/formbricks/formbricks/discussions) if you need any help or have any questions. 😊
issue-message: |
Thank you for opening your first issue! 🙏❤️ One of our team members will review it and get back to you as soon as it possible. 😊

View File

@@ -31,6 +31,10 @@
{ {
"language": "pt-PT", "language": "pt-PT",
"path": "./apps/web/locales/pt-PT.json" "path": "./apps/web/locales/pt-PT.json"
},
{
"language": "ro-RO",
"path": "./apps/web/locales/ro-RO.json"
} }
], ],
"forceMode": "OVERRIDE" "forceMode": "OVERRIDE"

View File

@@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
## 🛠 Crafting Pull Requests ## 🛠 Crafting Pull Requests
Ready to dive into the code and make a real impact? Here's your path: For the time being, we don't have the capacity to properly facilitate community contributions. It's a lot of engineering attention often spent on issues which don't follow our prioritization, so we've decided to only facilitate community code contributions in rare exceptions in the coming months.
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
1. **Tweak and Transform:** Work your coding magic and apply your changes.
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
## 🚀 Aspiring Features ## 🚀 Aspiring Features

View File

@@ -192,7 +192,7 @@ Here are a few options:
- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap. - Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap.
Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information. - Note: For the time being, we can only facilitate code contributions as an exception.
## All Thanks To Our Contributors ## All Thanks To Our Contributors

View File

@@ -1,23 +1,25 @@
import type { StorybookConfig } from "@storybook/react-vite"; import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join } from "path"; import { dirname, join } from "path";
const require = createRequire(import.meta.url);
/** /**
* This function is used to resolve the absolute path of a package. * This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo. * It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/ */
const getAbsolutePath = (value: string) => { function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, "package.json"))); return dirname(require.resolve(join(value, "package.json")));
}; }
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"], stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
addons: [ addons: [
getAbsolutePath("@storybook/addon-onboarding"), getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"), getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@chromatic-com/storybook"), getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("@storybook/addon-a11y"), getAbsolutePath("@storybook/addon-a11y"),
getAbsolutePath("@storybook/addon-docs"),
], ],
framework: { framework: {
name: getAbsolutePath("@storybook/react-vite"), name: getAbsolutePath("@storybook/react-vite"),

View File

@@ -1,5 +1,21 @@
import type { Preview } from "@storybook/react"; import type { Preview } from "@storybook/react-vite";
import { TolgeeProvider } from "@tolgee/react";
import React from "react";
import "../../web/modules/ui/globals.css"; import "../../web/modules/ui/globals.css";
import { TolgeeBase } from "../../web/tolgee/shared";
// Create a Storybook-specific Tolgee decorator
const withTolgee = (Story: any) => {
const tolgee = TolgeeBase().init({
tagNewKeys: [], // No branch tagging in Storybook
});
return React.createElement(
TolgeeProvider,
{ tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } },
React.createElement(Story)
);
};
const preview: Preview = { const preview: Preview = {
parameters: { parameters: {
@@ -10,6 +26,7 @@ const preview: Preview = {
}, },
}, },
}, },
decorators: [withTolgee],
}; };
export default preview; export default preview;

View File

@@ -14,23 +14,19 @@
"eslint-plugin-react-refresh": "0.4.20" "eslint-plugin-react-refresh": "0.4.20"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "3.2.6", "@chromatic-com/storybook": "^4.0.1",
"@storybook/addon-a11y": "8.6.12", "@storybook/addon-a11y": "9.0.15",
"@storybook/addon-essentials": "8.6.12", "@storybook/addon-links": "9.0.15",
"@storybook/addon-interactions": "8.6.12", "@storybook/addon-onboarding": "9.0.15",
"@storybook/addon-links": "8.6.12", "@storybook/react-vite": "9.0.15",
"@storybook/addon-onboarding": "8.6.12",
"@storybook/blocks": "8.6.12",
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.32.0", "@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0", "@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1", "@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4", "esbuild": "0.25.4",
"eslint-plugin-storybook": "0.12.0", "eslint-plugin-storybook": "9.0.15",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"storybook": "8.6.12", "storybook": "9.0.15",
"vite": "6.3.5" "vite": "6.3.5",
"@storybook/addon-docs": "9.0.15"
} }
} }

View File

@@ -1,4 +1,4 @@
import { Meta } from "@storybook/blocks"; import { Meta } from "@storybook/addon-docs/blocks";
import Accessibility from "./assets/accessibility.png"; import Accessibility from "./assets/accessibility.png";
import AddonLibrary from "./assets/addon-library.png"; import AddonLibrary from "./assets/addon-library.png";

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine3.21 AS base FROM node:22-alpine3.22 AS base
# #
## step 1: Prune monorepo ## step 1: Prune monorepo

View File

@@ -86,7 +86,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));

View File

@@ -45,7 +45,7 @@ afterEach(() => {
}); });
describe("LandingSidebar component", () => { describe("LandingSidebar component", () => {
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any; const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
const organization = { id: "o1", name: "orgOne" } as any; const organization = { id: "o1", name: "orgOne" } as any;
const organizations = [ const organizations = [
{ id: "o2", name: "betaOrg" }, { id: "o2", name: "betaOrg" },

View File

@@ -80,25 +80,25 @@ export const LandingSidebar = ({
<DropdownMenuTrigger <DropdownMenuTrigger
asChild asChild
id="userDropdownTrigger" id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none"> className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}> <div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center gap-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} /> <ProfileAvatar userId={user.id} />
<> <>
<div> <div className="grow overflow-hidden">
<p <p
title={user?.email} title={user?.email}
className={cn( className={cn(
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700" "ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}> )}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>} {user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p> </p>
<p <p
title={capitalizeFirstLetter(organization?.name)} title={capitalizeFirstLetter(organization?.name)}
className="max-w-28 truncate text-sm text-slate-500"> className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)} {capitalizeFirstLetter(organization?.name)}
</p> </p>
</div> </div>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} /> <ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
</> </>
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@@ -89,7 +89,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));

View File

@@ -97,7 +97,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));

View File

@@ -35,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));

View File

@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));

View File

@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
industry: "ind", industry: "ind",
defaultBrandColor: "#fff", defaultBrandColor: "#fff",
organizationTeams: [], organizationTeams: [],
canDoRoleManagement: false, isAccessControlAllowed: false,
userProjectsCount: 0, userProjectsCount: 0,
} as any; } as any;

View File

@@ -42,7 +42,7 @@ interface ProjectSettingsProps {
industry: TProjectConfigIndustry; industry: TProjectConfigIndustry;
defaultBrandColor: string; defaultBrandColor: string;
organizationTeams: TOrganizationTeam[]; organizationTeams: TOrganizationTeam[];
canDoRoleManagement: boolean; isAccessControlAllowed: boolean;
userProjectsCount: number; userProjectsCount: number;
} }
@@ -53,7 +53,7 @@ export const ProjectSettings = ({
industry, industry,
defaultBrandColor, defaultBrandColor,
organizationTeams, organizationTeams,
canDoRoleManagement = false, isAccessControlAllowed = false,
userProjectsCount, userProjectsCount,
}: ProjectSettingsProps) => { }: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false); const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -174,7 +174,7 @@ export const ProjectSettings = ({
)} )}
/> />
{canDoRoleManagement && userProjectsCount > 0 && ( {isAccessControlAllowed && userProjectsCount > 0 && (
<FormField <FormField
control={form.control} control={form.control}
name="teamIds" name="teamIds"

View File

@@ -1,6 +1,6 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils"; import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
// Mocks before component import // Mocks before component import
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() })); vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() })); vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() })); vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() })); vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) })); vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
@@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => {
} as any); } as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any); vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any); vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any); vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any);
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found"); await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
}); });
@@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => {
} as any); } as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any); vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any); vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any); vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams }); const element = await Page({ params, searchParams });
render(element as React.ReactElement); render(element as React.ReactElement);
@@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => {
} as any); } as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any); vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any); vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any); vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams }); const element = await Page({ params, searchParams });
render(element as React.ReactElement); render(element as React.ReactElement);

View File

@@ -2,7 +2,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants"; import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils"; import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
@@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId); const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!organizationTeams) { if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found")); throw new Error(t("common.organization_teams_not_found"));
@@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
industry={industry} industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR} defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams} organizationTeams={organizationTeams}
canDoRoleManagement={canDoRoleManagement} isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length} userProjectsCount={projects.length}
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (

View File

@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true, IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1, AUDIT_LOG_ENABLED: 1,
REDIS_URL: "redis://localhost:6379", REDIS_URL: undefined,
})); }));
vi.mock("@/lib/env", () => ({ vi.mock("@/lib/env", () => ({

View File

@@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { import {
getAccessControlPermission,
getOrganizationProjectsLimit, getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project"; import { createProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod"; import { z } from "zod";
@@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
} }
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) { if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!canDoRoleManagement) { if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles"); throw new OperationNotAllowedError("You do not have permission to manage roles");
} }
} }
@@ -71,10 +71,6 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
alert: { alert: {
...user.notificationSettings?.alert, ...user.notificationSettings?.alert,
}, },
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
}; };
await updateUser(user.id, { await updateUser(user.id, {

View File

@@ -24,14 +24,17 @@ export const ActionClassesTable = ({
otherEnvActionClasses, otherEnvActionClasses,
otherEnvironment, otherEnvironment,
}: ActionClassesTableProps) => { }: ActionClassesTableProps) => {
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false); const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
const [activeActionClass, setActiveActionClass] = useState<TActionClass>(); const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => { const handleOpenActionDetailModalClick = (
e: React.MouseEvent<HTMLButtonElement>,
actionClass: TActionClass
) => {
e.preventDefault(); e.preventDefault();
setActiveActionClass(actionClass); setActiveActionClass(actionClass);
setActionDetailModalOpen(true); setIsActionDetailModalOpen(true);
}; };
return ( return (
@@ -42,7 +45,7 @@ export const ActionClassesTable = ({
{actionClasses.length > 0 ? ( {actionClasses.length > 0 ? (
actionClasses.map((actionClass, index) => ( actionClasses.map((actionClass, index) => (
<button <button
onClick={(e) => { onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
handleOpenActionDetailModalClick(e, actionClass); handleOpenActionDetailModalClick(e, actionClass);
}} }}
className="w-full" className="w-full"
@@ -63,7 +66,7 @@ export const ActionClassesTable = ({
environmentId={environmentId} environmentId={environmentId}
environment={environment} environment={environment}
open={isActionDetailModalOpen} open={isActionDetailModalOpen}
setOpen={setActionDetailModalOpen} setOpen={setIsActionDetailModalOpen}
actionClasses={actionClasses} actionClasses={actionClasses}
actionClass={activeActionClass} actionClass={activeActionClass}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}

View File

@@ -1,5 +1,5 @@
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs"; import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render } from "@testing-library/react"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes"; import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
@@ -8,23 +8,40 @@ import { ActionDetailModal } from "./ActionDetailModal";
// Import mocked components // Import mocked components
import { ActionSettingsTab } from "./ActionSettingsTab"; import { ActionSettingsTab } from "./ActionSettingsTab";
// Mock child components // Mock the Dialog components
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => ( Dialog: ({
<div data-testid="modal-with-tabs"> open,
<span data-testid="modal-label">{label}</span> onOpenChange,
<span data-testid="modal-description">{description}</span> children,
<span data-testid="modal-open">{open.toString()}</span> }: {
<button onClick={() => setOpen(false)}>Close</button> open: boolean;
{icon} onOpenChange: (open: boolean) => void;
{tabs.map((tab) => ( children: React.ReactNode;
<div key={tab.title}> }) =>
<h2>{tab.title}</h2> open ? (
{tab.children} <div data-testid="dialog">
</div> {children}
))} <button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
</div> Close
)), </button>
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
})); }));
vi.mock("./ActionActivityTab", () => ({ vi.mock("./ActionActivityTab", () => ({
@@ -44,6 +61,22 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
}, },
})); }));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.activity": "Activity",
"common.settings": "Settings",
"common.no_code": "No Code",
"common.action": "Action",
"common.code": "Code",
};
return translations[key] || key;
},
}),
}));
const mockEnvironmentId = "test-env-id"; const mockEnvironmentId = "test-env-id";
const mockSetOpen = vi.fn(); const mockSetOpen = vi.fn();
@@ -89,58 +122,68 @@ describe("ActionDetailModal", () => {
vi.clearAllMocks(); // Clear mocks after each test vi.clearAllMocks(); // Clear mocks after each test
}); });
test("renders ModalWithTabs with correct props", () => { test("renders correctly when open", () => {
render(<ActionDetailModal {...defaultProps} />); render(<ActionDetailModal {...defaultProps} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action");
expect(screen.getByTestId("code-icon")).toBeInTheDocument();
expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
// Only the first tab (Activity) should be active initially
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
expect(mockedModalWithTabs).toHaveBeenCalled(); test("does not render when open is false", () => {
const props = mockedModalWithTabs.mock.calls[0][0]; render(<ActionDetailModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
// Check basic props test("switches tabs correctly", async () => {
expect(props.open).toBe(true); const user = userEvent.setup();
expect(props.setOpen).toBe(mockSetOpen); render(<ActionDetailModal {...defaultProps} />);
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 // Initially shows activity tab (first tab is active)
expect(props.icon).toBeDefined(); expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
if (!props.icon) { expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
throw new Error("Icon prop is not defined");
}
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
// Check tabs structure // Click settings tab
expect(props.tabs).toHaveLength(2); const settingsTab = screen.getByText("Settings");
expect(props.tabs[0].title).toBe("common.activity"); await user.click(settingsTab);
expect(props.tabs[1].title).toBe("common.settings");
// Check if the correct mocked components are used as children // Now shows settings tab content
// Access the mocked functions directly expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument();
const mockedActionActivityTab = vi.mocked(ActionActivityTab); expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
if (!props.tabs[0].children || !props.tabs[1].children) { // Click activity tab again
throw new Error("Tabs children are not defined"); const activityTab = screen.getByText("Activity");
} await user.click(activityTab);
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab); // Back to activity tab content
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab); expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
// Check props passed to child components test("resets to first tab when modal is reopened", async () => {
const activityTabProps = (props.tabs[0].children as any).props; const user = userEvent.setup();
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses); const { rerender } = render(<ActionDetailModal {...defaultProps} />);
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; // Switch to settings tab
expect(settingsTabProps.actionClass).toBe(mockActionClass); const settingsTab = screen.getByText("Settings");
expect(settingsTabProps.actionClasses).toBe(mockActionClasses); await user.click(settingsTab);
expect(settingsTabProps.setOpen).toBe(mockSetOpen); expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
expect(settingsTabProps.isReadOnly).toBe(false);
// Close modal
rerender(<ActionDetailModal {...defaultProps} open={false} />);
// Reopen modal
rerender(<ActionDetailModal {...defaultProps} open={true} />);
// Should be back to activity tab (first tab)
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
}); });
test("renders correct icon based on action type", () => { test("renders correct icon based on action type", () => {
@@ -148,33 +191,68 @@ describe("ActionDetailModal", () => {
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass; const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />); render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs); expect(screen.getByTestId("nocode-icon")).toBeInTheDocument();
const props = mockedModalWithTabs.mock.calls[0][0]; expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument();
});
// Expect the 'nocode-icon' based on the updated mock and action type test("handles action without description", () => {
expect(props.icon).toBeDefined(); const actionWithoutDescription = { ...mockActionClass, description: "" };
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
if (!props.icon) { expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
throw new Error("Icon prop is not defined"); expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action");
} });
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon"); test("passes correct props to ActionActivityTab", () => {
render(<ActionDetailModal {...defaultProps} />);
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
expect(mockedActionActivityTab).toHaveBeenCalledWith(
{
otherEnvActionClasses: mockOtherEnvActionClasses,
otherEnvironment: mockOtherEnvironment,
isReadOnly: false,
environment: mockEnvironment,
actionClass: mockActionClass,
environmentId: mockEnvironmentId,
},
undefined
);
});
test("passes correct props to ActionSettingsTab when tab is active", async () => {
const user = userEvent.setup();
render(<ActionDetailModal {...defaultProps} />);
// ActionSettingsTab should not be called initially since first tab is active
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
expect(mockedActionSettingsTab).not.toHaveBeenCalled();
// Click the settings tab to activate ActionSettingsTab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Now ActionSettingsTab should be called with correct props
expect(mockedActionSettingsTab).toHaveBeenCalledWith(
{
actionClass: mockActionClass,
actionClasses: mockActionClasses,
setOpen: mockSetOpen,
isReadOnly: false,
},
undefined
);
}); });
test("passes isReadOnly prop correctly", () => { test("passes isReadOnly prop correctly", () => {
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />); 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) { const mockedActionActivityTab = vi.mocked(ActionActivityTab);
throw new Error("Tabs children are not defined"); expect(mockedActionActivityTab).toHaveBeenCalledWith(
} expect.objectContaining({
isReadOnly: true,
const activityTabProps = (props.tabs[0].children as any).props; }),
expect(activityTabProps.isReadOnly).toBe(true); undefined
);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.isReadOnly).toBe(true);
}); });
}); });

View File

@@ -59,16 +59,24 @@ export const ActionDetailModal = ({
}, },
]; ];
const typeDescription = () => {
if (actionClass.description) return actionClass.description;
else
return (
(actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) +
" " +
t("common.action").toLowerCase()
);
};
return ( return (
<> <ModalWithTabs
<ModalWithTabs open={open}
open={open} setOpen={setOpen}
setOpen={setOpen} tabs={tabs}
tabs={tabs} icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]} label={actionClass.name}
label={actionClass.name} description={typeDescription()}
description={actionClass.description || ""} />
/>
</>
); );
}; };

View File

@@ -11,6 +11,21 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
updateActionClassAction: vi.fn(), updateActionClassAction: vi.fn(),
})); }));
// Mock action utils
vi.mock("@/modules/survey/editor/lib/action-utils", () => ({
useActionClassKeys: vi.fn(() => ["existing-key"]),
createActionClassZodResolver: vi.fn(() => vi.fn()),
validatePermissions: vi.fn(),
}));
// Mock action builder
vi.mock("@/modules/survey/editor/lib/action-builder", () => ({
buildActionObject: vi.fn((data, environmentId, t) => ({
...data,
environmentId,
})),
}));
// Mock utils // Mock utils
vi.mock("@/app/lib/actionClass/actionClass", () => ({ vi.mock("@/app/lib/actionClass/actionClass", () => ({
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"), isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
@@ -24,6 +39,7 @@ vi.mock("@/modules/ui/components/button", () => ({
</button> </button>
), ),
})); }));
vi.mock("@/modules/ui/components/code-action-form", () => ({ vi.mock("@/modules/ui/components/code-action-form", () => ({
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => ( CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="code-action-form" data-readonly={isReadOnly}> <div data-testid="code-action-form" data-readonly={isReadOnly}>
@@ -31,6 +47,7 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/delete-dialog", () => ({ vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) => DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
open ? ( open ? (
@@ -43,6 +60,26 @@ vi.mock("@/modules/ui/components/delete-dialog", () => ({
</div> </div>
) : null, ) : null,
})); }));
vi.mock("@/modules/ui/components/action-name-description-fields", () => ({
ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => (
<div data-testid="action-name-description-fields">
<input
data-testid={`name-input-${nameInputId}`}
placeholder="environments.actions.eg_clicked_download"
disabled={isReadOnly}
defaultValue="Test Action"
/>
<input
data-testid={`description-input-${descriptionInputId}`}
placeholder="environments.actions.user_clicked_download_button"
disabled={isReadOnly}
defaultValue="Test Description"
/>
</div>
),
}));
vi.mock("@/modules/ui/components/no-code-action-form", () => ({ vi.mock("@/modules/ui/components/no-code-action-form", () => ({
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => ( NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="no-code-action-form" data-readonly={isReadOnly}> <div data-testid="no-code-action-form" data-readonly={isReadOnly}>
@@ -56,6 +93,23 @@ vi.mock("lucide-react", () => ({
TrashIcon: () => <div data-testid="trash-icon">Trash</div>, TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
})); }));
// Mock react-hook-form
const mockHandleSubmit = vi.fn();
const mockForm = {
handleSubmit: mockHandleSubmit,
control: {},
formState: { errors: {} },
};
vi.mock("react-hook-form", async () => {
const actual = await vi.importActual("react-hook-form");
return {
...actual,
useForm: vi.fn(() => mockForm),
FormProvider: ({ children }: any) => <div>{children}</div>,
};
});
const mockSetOpen = vi.fn(); const mockSetOpen = vi.fn();
const mockActionClasses: TActionClass[] = [ const mockActionClasses: TActionClass[] = [
{ {
@@ -88,6 +142,7 @@ const createMockActionClass = (id: string, type: TActionClassType, name: string)
describe("ActionSettingsTab", () => { describe("ActionSettingsTab", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockHandleSubmit.mockImplementation((fn) => fn);
}); });
afterEach(() => { afterEach(() => {
@@ -105,13 +160,9 @@ describe("ActionSettingsTab", () => {
/> />
); );
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue( expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
actionClass.name expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("code-action-form")).toBeInTheDocument(); expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
expect( expect(
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base") screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
@@ -131,18 +182,104 @@ describe("ActionSettingsTab", () => {
/> />
); );
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
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.getByTestId("no-code-action-form")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
}); });
test("renders correctly for other action types (fallback)", () => {
const actionClass = {
...createMockActionClass("auto1", "noCode", "Auto Action"),
type: "automatic" as any,
};
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(
screen.getByText(
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
)
).toBeInTheDocument();
});
test("calls utility functions on initialization", async () => {
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
});
test("handles successful form submission", async () => {
const { updateActionClassAction } = await import(
"@/app/(app)/environments/[environmentId]/actions/actions"
);
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
vi.mocked(updateActionClassAction).mockResolvedValue({ data: {} } as any);
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
// Check that utility functions were called during component initialization
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
});
test("handles permission validation error", async () => {
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
vi.mocked(actionUtilsMock.validatePermissions).mockImplementation(() => {
throw new Error("Not authorized");
});
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
const submitButton = screen.getByRole("button", { name: "common.save_changes" });
mockHandleSubmit.mockImplementation((fn) => (e) => {
e.preventDefault();
return fn({ name: "Test", type: "noCode" });
});
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Not authorized");
});
});
test("handles successful deletion", async () => { test("handles successful deletion", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import( const { deleteActionClassAction } = await import(
@@ -209,17 +346,16 @@ describe("ActionSettingsTab", () => {
actionClass={actionClass} actionClass={actionClass}
actionClasses={mockActionClasses} actionClasses={mockActionClasses}
setOpen={mockSetOpen} setOpen={mockSetOpen}
isReadOnly={true} // Set to read-only isReadOnly={true}
/> />
); );
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled();
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled(); expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled();
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true"); 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.save_changes" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument();
}); });
test("prevents delete when read-only", async () => { test("prevents delete when read-only", async () => {
@@ -228,7 +364,6 @@ describe("ActionSettingsTab", () => {
"@/app/(app)/environments/[environmentId]/actions/actions" "@/app/(app)/environments/[environmentId]/actions/actions"
); );
// Render with isReadOnly=true, but simulate a delete attempt
render( render(
<ActionSettingsTab <ActionSettingsTab
actionClass={actionClass} actionClass={actionClass}
@@ -238,12 +373,6 @@ describe("ActionSettingsTab", () => {
/> />
); );
// 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(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(deleteActionClassAction).not.toHaveBeenCalled(); expect(deleteActionClassAction).not.toHaveBeenCalled();
}); });
@@ -262,4 +391,19 @@ describe("ActionSettingsTab", () => {
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code"); expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
expect(docsLink).toHaveAttribute("target", "_blank"); expect(docsLink).toHaveAttribute("target", "_blank");
}); });
test("uses correct input IDs for ActionNameDescriptionFields", () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
});
}); });

View File

@@ -4,14 +4,17 @@ import {
deleteActionClassAction, deleteActionClassAction,
updateActionClassAction, updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/actions/actions"; } from "@/app/(app)/environments/[environmentId]/actions/actions";
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass"; import { buildActionObject } from "@/modules/survey/editor/lib/action-builder";
import {
createActionClassZodResolver,
useActionClassKeys,
validatePermissions,
} from "@/modules/survey/editor/lib/action-utils";
import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { CodeActionForm } from "@/modules/ui/components/code-action-form"; import { CodeActionForm } from "@/modules/ui/components/code-action-form";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form"; import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { TrashIcon } from "lucide-react"; import { TrashIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -19,8 +22,7 @@ import { useRouter } from "next/navigation";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { z } from "zod"; import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
interface ActionSettingsTabProps { interface ActionSettingsTabProps {
actionClass: TActionClass; actionClass: TActionClass;
@@ -48,63 +50,51 @@ export const ActionSettingsTab = ({
[actionClass.id, actionClasses] [actionClass.id, actionClasses]
); );
const actionClassKeys = useActionClassKeys(actionClasses);
const form = useForm<TActionClassInput>({ const form = useForm<TActionClassInput>({
defaultValues: { defaultValues: {
...restActionClass, ...restActionClass,
}, },
resolver: zodResolver( resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t),
ZActionClassInput.superRefine((data, ctx) => {
if (data.name && actionClassNames.includes(data.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
});
}
})
),
mode: "onChange", mode: "onChange",
}); });
const { handleSubmit, control } = form; const { handleSubmit, control } = form;
const renderActionForm = () => {
if (actionClass.type === "code") {
return (
<>
<CodeActionForm form={form} isReadOnly={true} />
<p className="text-sm text-slate-600">
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
</p>
</>
);
}
if (actionClass.type === "noCode") {
return <NoCodeActionForm form={form} isReadOnly={isReadOnly} />;
}
return (
<p className="text-sm text-slate-600">
{t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")}
</p>
);
};
const onSubmit = async (data: TActionClassInput) => { const onSubmit = async (data: TActionClassInput) => {
try { try {
if (isReadOnly) {
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
}
setIsUpdatingAction(true); setIsUpdatingAction(true);
validatePermissions(isReadOnly, t);
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
if (data.name && actionClassNames.includes(data.name)) {
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
}
if (
data.type === "noCode" &&
data.noCodeConfig?.type === "click" &&
data.noCodeConfig.elementSelector.cssSelector &&
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
) {
throw new Error(t("environments.actions.invalid_css_selector"));
}
const updatedData: TActionClassInput = {
...data,
...(data.type === "noCode" &&
data.noCodeConfig?.type === "click" && {
noCodeConfig: {
...data.noCodeConfig,
elementSelector: {
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
},
},
}),
};
await updateActionClassAction({ await updateActionClassAction({
actionClassId: actionClass.id, actionClassId: actionClass.id,
updatedAction: updatedData, updatedAction: updatedAction,
}); });
setOpen(false); setOpen(false);
router.refresh(); router.refresh();
@@ -123,7 +113,7 @@ export const ActionSettingsTab = ({
router.refresh(); router.refresh();
toast.success(t("environments.actions.action_deleted_successfully")); toast.success(t("environments.actions.action_deleted_successfully"));
setOpen(false); setOpen(false);
} catch (error) { } catch {
toast.error(t("common.something_went_wrong_please_try_again")); toast.error(t("common.something_went_wrong_please_try_again"));
} finally { } finally {
setIsDeletingAction(false); setIsDeletingAction(false);
@@ -135,89 +125,23 @@ export const ActionSettingsTab = ({
<FormProvider {...form}> <FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto"> <div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
<div className="grid w-full grid-cols-2 gap-x-4"> <ActionNameDescriptionFields
<div className="col-span-1"> control={control}
<FormField isReadOnly={isReadOnly}
control={control} nameInputId="actionNameSettingsInput"
name="name" descriptionInputId="actionDescriptionSettingsInput"
disabled={isReadOnly} />
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel htmlFor="actionNameSettingsInput">
{actionClass.type === "noCode"
? t("environments.actions.what_did_your_user_do")
: t("environments.actions.display_name")}
</FormLabel>
<FormControl> {renderActionForm()}
<Input
type="text"
id="actionNameSettingsInput"
{...field}
placeholder={t("environments.actions.eg_clicked_download")}
isInvalid={!!error?.message}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="col-span-1">
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="actionDescriptionSettingsInput">
{t("common.description")}
</FormLabel>
<FormControl>
<Input
type="text"
id="actionDescriptionSettingsInput"
{...field}
placeholder={t("environments.actions.user_clicked_download_button")}
value={field.value ?? ""}
disabled={isReadOnly}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
{actionClass.type === "code" ? (
<>
<CodeActionForm form={form} isReadOnly={true} />
<p className="text-sm text-slate-600">
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
</p>
</>
) : actionClass.type === "noCode" ? (
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
) : (
<p className="text-sm text-slate-600">
{t(
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
)}
</p>
)}
</div> </div>
<div className="flex justify-between border-t border-slate-200 py-6"> <div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div> <div className="flex items-center gap-x-2">
{!isReadOnly ? ( {!isReadOnly ? (
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
onClick={() => setOpenDeleteDialog(true)} onClick={() => setOpenDeleteDialog(true)}
className="mr-3"
id="deleteActionModalTrigger"> id="deleteActionModalTrigger">
<TrashIcon /> <TrashIcon />
{t("common.delete")} {t("common.delete")}

View File

@@ -22,14 +22,29 @@ vi.mock("@/modules/ui/components/button", () => ({
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ children, open, setOpen, ...props }: any) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? ( open ? (
<div data-testid="modal" {...props}> <div data-testid="dialog" role="dialog">
{children} {children}
<button onClick={() => setOpen(false)}>Close Modal</button> <button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div> </div>
) : null, ) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children, className }: any) => (
<h2 data-testid="dialog-title" className={className}>
{children}
</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
})); }));
vi.mock("@tolgee/react", () => ({ vi.mock("@tolgee/react", () => ({
@@ -70,17 +85,21 @@ describe("AddActionModal", () => {
); );
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument(); expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("opens the modal when the 'Add Action' button is clicked", async () => { test("opens the dialog when the 'Add Action' button is clicked", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument(); expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument(); expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
expect( expect(
@@ -108,35 +127,35 @@ describe("AddActionModal", () => {
expect(props.setActionClasses).toBeInstanceOf(Function); expect(props.setActionClasses).toBeInstanceOf(Function);
}); });
test("closes the modal when the close button (simulated) is clicked", async () => { test("closes the dialog when the close button (simulated) is clicked", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked Modal's close button // Simulate closing via the mocked Dialog's close button
const closeModalButton = screen.getByText("Close Modal"); const closeDialogButton = screen.getByText("Close Dialog");
await userEvent.click(closeModalButton); await userEvent.click(closeDialogButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("closes the modal when setOpen is called from CreateNewActionTab", async () => { test("closes the dialog when setOpen is called from CreateNewActionTab", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked CreateNewActionTab's button // Simulate closing via the mocked CreateNewActionTab's button
const closeFromTabButton = screen.getByText("Close from Tab"); const closeFromTabButton = screen.getByText("Close from Tab");
await userEvent.click(closeFromTabButton); await userEvent.click(closeFromTabButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
}); });

View File

@@ -2,7 +2,14 @@
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab"; import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal"; import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { MousePointerClickIcon, PlusIcon } from "lucide-react"; import { MousePointerClickIcon, PlusIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -26,36 +33,26 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
{t("common.add_action")} {t("common.add_action")}
<PlusIcon /> <PlusIcon />
</Button> </Button>
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow> <Dialog open={open} onOpenChange={setOpen}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent disableCloseOnOutsideClick>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <MousePointerClickIcon />
<div className="flex items-center space-x-2"> <DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
<div className="mr-1.5 h-6 w-6 text-slate-500"> <DialogDescription>
<MousePointerClickIcon className="h-5 w-5" /> {t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
</div> </DialogDescription>
<div> </DialogHeader>
<div className="text-xl font-medium text-slate-700"> <DialogBody>
{t("environments.actions.track_new_user_action")} <CreateNewActionTab
</div> actionClasses={newActionClasses}
<div className="text-sm text-slate-500"> environmentId={environmentId}
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")} isReadOnly={isReadOnly}
</div> setActionClasses={setNewActionClasses}
</div> setOpen={setOpen}
</div> />
</div> </DialogBody>
</div> </DialogContent>
</div> </Dialog>
<div className="px-6 py-4">
<CreateNewActionTab
actionClasses={newActionClasses}
environmentId={environmentId}
isReadOnly={isReadOnly}
setActionClasses={setNewActionClasses}
setOpen={setOpen}
/>
</div>
</Modal>
</> </>
); );
}; };

View File

@@ -9,8 +9,12 @@ import {
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import {
getAccessControlPermission,
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -49,10 +53,14 @@ vi.mock("@/lib/membership/utils", () => ({
})); }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(), getOrganizationProjectsLimit: vi.fn(),
getAccessControlPermission: vi.fn(),
})); }));
vi.mock("@/modules/ee/teams/lib/roles", () => ({ vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(), getProjectPermissionByUserId: vi.fn(),
})); }));
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
getTeamsByOrganizationId: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({ vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key, getTranslate: async () => (key: string) => key,
})); }));
@@ -71,7 +79,13 @@ vi.mock("@/lib/constants", () => ({
// Mock components // Mock components
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({ vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>, MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => (
<div data-testid="main-navigation">
MainNavigation
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed?.toString() || "false"}</div>
</div>
),
})); }));
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({ vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>, TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
@@ -99,12 +113,11 @@ const mockUser = {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} }, notificationSettings: { alert: {} },
} as unknown as TUser; } as unknown as TUser;
const mockOrganization = { const mockOrganization = {
@@ -156,6 +169,17 @@ const mockProjectPermission = {
role: "admin", role: "admin",
} as any; } as any;
const mockOrganizationTeams = [
{
id: "team-1",
name: "Development Team",
},
{
id: "team-2",
name: "Marketing Team",
},
];
const mockSession: Session = { const mockSession: Session = {
user: { user: {
id: "user-1", id: "user-1",
@@ -176,6 +200,8 @@ describe("EnvironmentLayout", () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500); vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any); vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission); vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
mockIsDevelopment = false; mockIsDevelopment = false;
mockIsFormbricksCloud = false; mockIsFormbricksCloud = false;
}); });
@@ -288,6 +314,110 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument(); expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
}); });
test("passes isAccessControlAllowed props to MainNavigation", 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("is-access-control-allowed")).toHaveTextContent("true");
expect(vi.mocked(getAccessControlPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
});
test("handles empty organizationTeams array", async () => {
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
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("organization-teams")).toHaveTextContent("[]");
});
test("handles null organizationTeams", async () => {
vi.mocked(getTeamsByOrganizationId).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"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
});
test("handles isAccessControlAllowed false", async () => {
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
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("is-access-control-allowed")).toHaveTextContent("false");
});
test("throws error if user not found", async () => { test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null); vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules(); vi.resetModules();

View File

@@ -13,7 +13,10 @@ import {
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import {
getAccessControlPermission,
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner"; import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner"; import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
@@ -48,9 +51,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
throw new Error(t("common.environment_not_found")); throw new Error(t("common.environment_not_found"));
} }
const [projects, environments] = await Promise.all([ const [projects, environments, isAccessControlAllowed] = await Promise.all([
getUserProjects(user.id, organization.id), getUserProjects(user.id, organization.id),
getEnvironments(environment.projectId), getEnvironments(environment.projectId),
getAccessControlPermission(organization.billing.plan),
]); ]);
if (!projects || !environments || !organizations) { if (!projects || !environments || !organizations) {
@@ -101,6 +105,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
isPendingDowngrade={isPendingDowngrade ?? false} isPendingDowngrade={isPendingDowngrade ?? false}
active={active} active={active}
environmentId={environment.id} environmentId={environment.id}
locale={user.locale}
/> />
<div className="flex h-full"> <div className="flex h-full">
@@ -116,15 +121,16 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
membershipRole={membershipRole} membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active} isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
/> />
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50"> <div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar <TopControlBar
environment={environment} environment={environment}
environments={environments} environments={environments}
membershipRole={membershipRole} membershipRole={membershipRole}
projectPermission={projectPermission} projectPermission={projectPermission}
/> />
<div className="mt-14">{children}</div> <div className="flex-1 overflow-y-auto">{children}</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
@@ -52,9 +53,19 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null, open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
})); }));
vi.mock("@/modules/projects/components/project-switcher", () => ({ vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => ( ProjectSwitcher: ({
isCollapsed,
organizationTeams,
isAccessControlAllowed,
}: {
isCollapsed: boolean;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
}) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}> <div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher Project Switcher
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed.toString()}</div>
</div> </div>
), ),
})); }));
@@ -100,13 +111,12 @@ const mockUser = {
id: "user1", id: "user1",
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
emailVerified: new Date(), emailVerified: new Date(),
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} }, notificationSettings: { alert: {} },
role: "project_manager", role: "project_manager",
objective: "other", objective: "other",
} as unknown as TUser; } as unknown as TUser;
@@ -146,6 +156,7 @@ const defaultProps = {
membershipRole: "owner" as const, membershipRole: "owner" as const,
organizationProjectsLimit: 5, organizationProjectsLimit: 5,
isLicenseActive: true, isLicenseActive: true,
isAccessControlAllowed: true,
}; };
describe("MainNavigation", () => { describe("MainNavigation", () => {
@@ -334,4 +345,23 @@ describe("MainNavigation", () => {
}); });
expect(screen.queryByText("common.license")).not.toBeInTheDocument(); expect(screen.queryByText("common.license")).not.toBeInTheDocument();
}); });
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
});
test("handles no organizationTeams", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
});
test("handles isAccessControlAllowed false", () => {
render(<MainNavigation {...defaultProps} isAccessControlAllowed={false} />);
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
});
}); });

View File

@@ -66,6 +66,7 @@ interface NavigationProps {
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
organizationProjectsLimit: number; organizationProjectsLimit: number;
isLicenseActive: boolean; isLicenseActive: boolean;
isAccessControlAllowed: boolean;
} }
export const MainNavigation = ({ export const MainNavigation = ({
@@ -80,6 +81,7 @@ export const MainNavigation = ({
organizationProjectsLimit, organizationProjectsLimit,
isLicenseActive, isLicenseActive,
isDevelopment, isDevelopment,
isAccessControlAllowed,
}: NavigationProps) => { }: NavigationProps) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -323,6 +325,7 @@ export const MainNavigation = ({
isTextVisible={isTextVisible} isTextVisible={isTextVisible}
organization={organization} organization={organization}
organizationProjectsLimit={organizationProjectsLimit} organizationProjectsLimit={organizationProjectsLimit}
isAccessControlAllowed={isAccessControlAllowed}
/> />
)} )}
@@ -336,27 +339,30 @@ export const MainNavigation = ({
<div <div
tabIndex={0} tabIndex={0}
className={cn( className={cn(
"flex cursor-pointer flex-row items-center space-x-3", "flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "pl-2" : "pl-4" isCollapsed ? "justify-center px-2" : "px-4"
)}> )}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} /> <ProfileAvatar userId={user.id} />
{!isCollapsed && !isTextVisible && ( {!isCollapsed && !isTextVisible && (
<> <>
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}> <div
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<p <p
title={user?.email} title={user?.email}
className={cn( className={cn(
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700" "ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}> )}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>} {user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p> </p>
<p <p
title={capitalizeFirstLetter(organization?.name)} title={capitalizeFirstLetter(organization?.name)}
className="max-w-28 truncate text-sm text-slate-500"> className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)} {capitalizeFirstLetter(organization?.name)}
</p> </p>
</div> </div>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} /> <ChevronRightIcon
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
</> </>
)} )}
</div> </div>

View File

@@ -28,7 +28,7 @@ const TestComponent = () => {
return ( return (
<div> <div>
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div> <div data-testid="responseStatus">{selectedFilter.responseStatus}</div>
<div data-testid="filterLength">{selectedFilter.filter.length}</div> <div data-testid="filterLength">{selectedFilter.filter.length}</div>
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div> <div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div> <div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
@@ -44,7 +44,7 @@ const TestComponent = () => {
filterType: { filterValue: "value1", filterComboBoxValue: "option1" }, filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
}, },
], ],
onlyComplete: true, responseStatus: "complete",
}) })
}> }>
Update Filter Update Filter
@@ -81,7 +81,7 @@ describe("ResponseFilterContext", () => {
</ResponseFilterProvider> </ResponseFilterProvider>
); );
expect(screen.getByTestId("onlyComplete").textContent).toBe("false"); expect(screen.getByTestId("responseStatus").textContent).toBe("all");
expect(screen.getByTestId("filterLength").textContent).toBe("0"); expect(screen.getByTestId("filterLength").textContent).toBe("0");
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0"); expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0"); expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
@@ -99,7 +99,7 @@ describe("ResponseFilterContext", () => {
const updateButton = screen.getByText("Update Filter"); const updateButton = screen.getByText("Update Filter");
await userEvent.click(updateButton); await userEvent.click(updateButton);
expect(screen.getByTestId("onlyComplete").textContent).toBe("true"); expect(screen.getByTestId("responseStatus").textContent).toBe("complete");
expect(screen.getByTestId("filterLength").textContent).toBe("1"); expect(screen.getByTestId("filterLength").textContent).toBe("1");
}); });

View File

@@ -16,9 +16,11 @@ export interface FilterValue {
}; };
} }
export type TResponseStatus = "all" | "complete" | "partial";
export interface SelectedFilterValue { export interface SelectedFilterValue {
filter: FilterValue[]; filter: FilterValue[];
onlyComplete: boolean; responseStatus: TResponseStatus;
} }
interface SelectedFilterOptions { interface SelectedFilterOptions {
@@ -47,7 +49,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
// state holds the filter selected value // state holds the filter selected value
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({ const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
filter: [], filter: [],
onlyComplete: false, responseStatus: "all",
}); });
// state holds all the options of the responses fetched // state holds all the options of the responses fetched
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({ const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
@@ -67,7 +69,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
}); });
setSelectedFilter({ setSelectedFilter({
filter: [], filter: [],
onlyComplete: false, responseStatus: "all",
}); });
}, []); }, []);

View File

@@ -44,10 +44,8 @@ describe("TopControlBar", () => {
); );
// Check if the main div is rendered // Check if the main div is rendered
const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement; const mainDiv = screen.getByTestId("fb__global-top-control-bar");
expect(mainDiv).toHaveClass( expect(mainDiv).toHaveClass("flex h-14 w-full items-center justify-end bg-slate-50 px-6");
"fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"
);
// Check if the mocked child component is rendered // Check if the mocked child component is rendered
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument(); expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();

View File

@@ -17,7 +17,9 @@ export const TopControlBar = ({
projectPermission, projectPermission,
}: SideBarProps) => { }: SideBarProps) => {
return ( return (
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"> <div
className="flex h-14 w-full items-center justify-end bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<div className="shadow-xs z-10"> <div className="shadow-xs z-10">
<div className="flex w-fit items-center space-x-2 py-2"> <div className="flex w-fit items-center space-x-2 py-2">
<TopControlButtons <TopControlButtons

View File

@@ -0,0 +1,157 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
import { EnvironmentContextWrapper, useEnvironment } from "./environment-context";
// Mock environment data
const mockEnvironment: TEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "test-project-id",
appSetupCompleted: true,
};
// Mock project data
const mockProject = {
id: "test-project-id",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "test-org-id",
config: {
channel: "app",
industry: "saas",
},
linkSurveyBranding: true,
styling: {
allowStyleOverwrite: true,
brandColor: {
light: "#ffffff",
dark: "#000000",
},
questionColor: {
light: "#000000",
dark: "#ffffff",
},
inputColor: {
light: "#000000",
dark: "#ffffff",
},
inputBorderColor: {
light: "#cccccc",
dark: "#444444",
},
cardBackgroundColor: {
light: "#ffffff",
dark: "#000000",
},
cardBorderColor: {
light: "#cccccc",
dark: "#444444",
},
isDarkModeEnabled: false,
isLogoHidden: false,
hideProgressBar: false,
roundness: 8,
cardArrangement: {
linkSurveys: "casual",
appSurveys: "casual",
},
},
recontactDays: 30,
inAppSurveyBranding: true,
logo: {
url: "test-logo.png",
bgColor: "#ffffff",
},
placement: "bottomRight",
clickOutsideClose: true,
} as TProject;
// Test component that uses the hook
const TestComponent = () => {
const { environment, project } = useEnvironment();
return (
<div>
<div data-testid="environment-id">{environment.id}</div>
<div data-testid="environment-type">{environment.type}</div>
<div data-testid="project-id">{project.id}</div>
<div data-testid="project-organization-id">{project.organizationId}</div>
</div>
);
};
describe("EnvironmentContext", () => {
afterEach(() => {
cleanup();
});
test("provides environment and project data to child components", () => {
render(
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
<TestComponent />
</EnvironmentContextWrapper>
);
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
expect(screen.getByTestId("project-organization-id")).toHaveTextContent("test-org-id");
});
test("throws error when useEnvironment is used outside of provider", () => {
const TestComponentWithoutProvider = () => {
useEnvironment();
return <div>Should not render</div>;
};
expect(() => {
render(<TestComponentWithoutProvider />);
}).toThrow("useEnvironment must be used within an EnvironmentProvider");
});
test("updates context value when environment or project changes", () => {
const { rerender } = render(
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
<TestComponent />
</EnvironmentContextWrapper>
);
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
const updatedEnvironment = {
...mockEnvironment,
type: "production" as const,
};
rerender(
<EnvironmentContextWrapper environment={updatedEnvironment} project={mockProject}>
<TestComponent />
</EnvironmentContextWrapper>
);
expect(screen.getByTestId("environment-type")).toHaveTextContent("production");
});
test("memoizes context value correctly", () => {
const { rerender } = render(
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
<TestComponent />
</EnvironmentContextWrapper>
);
// Re-render with same props
rerender(
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
<TestComponent />
</EnvironmentContextWrapper>
);
// Should still work correctly
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
});
});

View File

@@ -0,0 +1,47 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
export const useEnvironment = () => {
const context = useContext(EnvironmentContext);
if (!context) {
throw new Error("useEnvironment must be used within an EnvironmentProvider");
}
return context;
};
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
organizationId: project.organizationId,
}),
[environment, project]
);
return (
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
);
};

View File

@@ -92,14 +92,24 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ children, open, setOpen }) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? ( open ? (
<div data-testid="modal"> <div data-testid="dialog" role="dialog">
{children} {children}
<button onClick={() => setOpen(false)}>Close Modal</button> <button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div> </div>
) : null, ) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
vi.mock("@/modules/ui/components/alert", () => ({ vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }) => <div data-testid="alert">{children}</div>, Alert: ({ children }) => <div data-testid="alert">{children}</div>,

View File

@@ -10,8 +10,16 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -19,11 +27,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/modules/ui/components/select"; } from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react"; import { TFnType, useTranslate } from "@tolgee/react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Control, Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { import {
@@ -68,6 +76,80 @@ const NoBaseFoundError = () => {
); );
}; };
const renderQuestionSelection = ({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: {
t: TFnType;
selectedSurvey: TSurvey;
control: Control<IntegrationModalInputs>;
includeVariables: boolean;
setIncludeVariables: (value: boolean) => void;
includeHiddenFields: boolean;
includeMetadata: boolean;
setIncludeHiddenFields: (value: boolean) => void;
setIncludeMetadata: (value: boolean) => void;
includeCreatedAt: boolean;
setIncludeCreatedAt: (value: boolean) => void;
}) => {
return (
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)}
/>
))}
</div>
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
);
};
export const AddIntegrationModal = ({ export const AddIntegrationModal = ({
open, open,
setOpenWithStates, setOpenWithStates,
@@ -210,182 +292,148 @@ export const AddIntegrationModal = ({
}; };
return ( return (
<Modal open={open} setOpen={handleClose} noPadding> <Dialog open={open} onOpenChange={setOpenWithStates}>
<div className="rounded-t-lg bg-slate-100"> <DialogContent className="overflow-visible md:overflow-visible">
<div className="flex w-full items-center justify-between p-6"> <DialogHeader>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <div className="relative size-8">
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" /> <Image
fill
className="object-contain object-center"
src={AirtableLogo}
alt={t("environments.integrations.airtable.airtable_logo")}
/>
</div> </div>
<div> <div className="space-y-0.5">
<div className="text-xl font-medium text-slate-700"> <DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
{t("environments.integrations.airtable.link_airtable_table")} <DialogDescription>
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.airtable.sync_responses_with_airtable")} {t("environments.integrations.airtable.sync_responses_with_airtable")}
</div> </DialogDescription>
</div> </div>
</div> </div>
</div> </DialogHeader>
</div> <form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
<form onSubmit={handleSubmit(submitHandler)}> <DialogBody className="overflow-visible">
<div className="flex rounded-lg p-6"> <div className="flex w-full flex-col gap-y-4">
<div className="flex w-full flex-col gap-y-4 pt-5"> {airtableArray.length ? (
{airtableArray.length ? ( <BaseSelectDropdown
<BaseSelectDropdown
control={control}
isLoading={isLoading}
fetchTable={fetchTable}
airtableArray={airtableArray}
setValue={setValue}
defaultValue={defaultData?.base}
/>
) : (
<NoBaseFoundError />
)}
<div className="flex w-full flex-col">
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex">
<Controller
control={control} control={control}
name="table" isLoading={isLoading}
render={({ field }) => ( fetchTable={fetchTable}
<Select airtableArray={airtableArray}
required setValue={setValue}
disabled={!tables.length} defaultValue={defaultData?.base}
onValueChange={(val) => {
field.onChange(val);
}}
defaultValue={defaultData?.table}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
)}
/> />
</div> ) : (
</div> <NoBaseFoundError />
)}
{surveys.length ? (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<Label htmlFor="survey">{t("common.select_survey")}</Label> <Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex"> <div className="mt-1 flex">
<Controller <Controller
control={control} control={control}
name="survey" name="table"
render={({ field }) => ( render={({ field }) => (
<Select <Select
required required
disabled={!tables.length}
onValueChange={(val) => { onValueChange={(val) => {
field.onChange(val); field.onChange(val);
setValue("questions", []);
}} }}
defaultValue={defaultData?.survey}> defaultValue={defaultData?.table}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> {tables.length ? (
{surveys.map((item) => ( <SelectContent>
<SelectItem key={item.id} value={item.id}> {tables.map((item) => (
{item.name} <SelectItem key={item.id} value={item.id}>
</SelectItem> {item.name}
))} </SelectItem>
</SelectContent> ))}
</SelectContent>
) : null}
</Select> </Select>
)} )}
/> />
</div> </div>
</div> </div>
) : null}
{!surveys.length ? ( {surveys.length ? (
<p className="m-1 text-xs text-slate-500"> <div className="flex w-full flex-col">
{t("environments.integrations.create_survey_warning")} <Label htmlFor="survey">{t("common.select_survey")}</Label>
</p> <div className="mt-1 flex">
) : null} <Controller
control={control}
{survey && selectedSurvey && ( name="survey"
<div className="space-y-4"> render={({ field }) => (
<div> <Select
<Label htmlFor="Surveys">{t("common.questions")}</Label> required
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200"> onValueChange={(val) => {
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900"> field.onChange(val);
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => ( setValue("questions", []);
<Controller }}
key={question.id} defaultValue={defaultData?.survey}>
control={control} <SelectTrigger>
name={"questions"} <SelectValue />
render={({ field }) => ( </SelectTrigger>
<div className="my-1 flex items-center space-x-2"> <SelectContent>
<label htmlFor={question.id} className="flex cursor-pointer items-center"> {surveys.map((item) => (
<Checkbox <SelectItem key={item.id} value={item.id}>
type="button" {item.name}
id={question.id} </SelectItem>
value={question.id} ))}
className="bg-white" </SelectContent>
checked={field.value?.includes(question.id)} </Select>
onCheckedChange={(checked) => { )}
return checked />
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)}
/>
))}
</div>
</div> </div>
</div> </div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
)}
<div className="flex justify-end gap-x-2">
{isEditMode ? (
<Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : ( ) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}> <p className="m-1 text-xs text-slate-500">
{t("common.cancel")} {t("environments.integrations.create_survey_warning")}
</Button> </p>
)} )}
<Button type="submit">{t("common.save")}</Button> {survey &&
selectedSurvey &&
renderQuestionSelection({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
})}
</div> </div>
</div> </DialogBody>
</div> <DialogFooter>
</form> {isEditMode ? (
</Modal> <Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
)}
<Button type="submit">{t("common.save")}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
); );
}; };

View File

@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
locale: TUserLocale; locale: TUserLocale;
} }
const tableHeaders = [
"common.survey",
"environments.integrations.airtable.table_name",
"common.questions",
"common.updated_at",
];
export const ManageIntegration = (props: ManageIntegrationProps) => { export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props; const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslate(); const { t } = useTranslate();
const tableHeaders = [
t("common.survey"),
t("environments.integrations.airtable.table_name"),
t("common.questions"),
t("common.updated_at"),
];
const [isDeleting, setisDeleting] = useState(false); const [isDeleting, setisDeleting] = useState(false);
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false); const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>( const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900"> <div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{tableHeaders.map((header) => ( {tableHeaders.map((header) => (
<div key={header} className={`col-span-2 hidden text-center sm:block`}> <div key={header} className={`col-span-2 hidden text-center sm:block`}>
{t(header)} {header}
</div> </div>
))} ))}
</div> </div>

View File

@@ -49,7 +49,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));

View File

@@ -88,9 +88,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? <div data-testid="modal">{children}</div> : null, open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -205,7 +220,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] }, hiddenFields: { enabled: true, fieldIds: [] },
pin: null, pin: null,
resultShareKey: null,
displayLimit: null, displayLimit: null,
} as unknown as TSurvey, } as unknown as TSurvey,
{ {
@@ -243,7 +257,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] }, hiddenFields: { enabled: true, fieldIds: [] },
pin: null, pin: null,
resultShareKey: null,
displayLimit: null, displayLimit: null,
} as unknown as TSurvey, } as unknown as TSurvey,
]; ];
@@ -304,10 +317,9 @@ describe("AddIntegrationModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
).toBeInTheDocument();
// Use getByPlaceholderText for the input // Use getByPlaceholderText for the input
expect( expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>") screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -332,10 +344,9 @@ describe("AddIntegrationModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
).toBeInTheDocument();
// Use getByPlaceholderText for the input // Use getByPlaceholderText for the input
expect( expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>") screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")

View File

@@ -14,10 +14,18 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -202,31 +210,28 @@ export const AddIntegrationModal = ({
}; };
return ( return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}> <Dialog open={open} onOpenChange={setOpenWithStates}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2"> <div className="relative size-8">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <Image
<Image fill
className="w-12" className="object-contain object-center"
src={GoogleSheetLogo} src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")} alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/> />
</div> </div>
<div> <div className="space-y-0.5">
<div className="text-xl font-medium text-slate-700"> <DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
{t("environments.integrations.google_sheets.link_google_sheet")} <DialogDescription>
</div> {t("environments.integrations.google_sheets.google_sheets_integration_description")}
<div className="text-sm text-slate-500"> </DialogDescription>
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</div>
</div>
</div> </div>
</div> </div>
</div> </DialogHeader>
<form onSubmit={handleSubmit(linkSheet)}> <form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
<div className="flex justify-between rounded-lg p-6"> <DialogBody>
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
</div> </div>
)} )}
</div> </div>
</div> </DialogBody>
<div className="flex justify-end border-t border-slate-200 p-6"> <DialogFooter>
<div className="flex space-x-2"> {selectedIntegration ? (
{selectedIntegration ? ( <Button
<Button type="button"
type="button" variant="destructive"
variant="destructive" loading={isDeleting}
loading={isDeleting} onClick={() => {
onClick={() => { deleteLink();
deleteLink(); }}>
}}> {t("common.delete")}
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button> </Button>
</div> ) : (
</div> <Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button>
</DialogFooter>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };

View File

@@ -119,7 +119,6 @@ const mockSurveys: TSurvey[] = [
displayPercentage: null, displayPercentage: null,
languages: [], languages: [],
pin: null, pin: null,
resultShareKey: null,
segment: null, segment: null,
singleUse: null, singleUse: null,
styling: null, styling: null,

View File

@@ -74,13 +74,41 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
vi.mock("@/modules/ui/components/label", () => ({ vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>, Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null, open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p data-testid="dialog-description" className={className}>
{children}
</p>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
})); }));
vi.mock("lucide-react", () => ({ vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>, PlusIcon: () => <span data-testid="plus-icon">+</span>,
XIcon: () => <span data-testid="x-icon">x</span>, TrashIcon: () => <span data-testid="trash-icon">🗑</span>,
})); }));
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -208,7 +236,6 @@ const surveys: TSurvey[] = [
languages: [], languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null, pin: null,
resultShareKey: null,
displayLimit: null, displayLimit: null,
} as unknown as TSurvey, } as unknown as TSurvey,
{ {
@@ -244,7 +271,6 @@ const surveys: TSurvey[] = [
languages: [], languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null, pin: null,
resultShareKey: null,
displayLimit: null, displayLimit: null,
} as unknown as TSurvey, } as unknown as TSurvey,
]; ];
@@ -334,7 +360,7 @@ describe("AddIntegrationModal (Notion)", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument(); expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument(); expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument(); expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
@@ -359,7 +385,7 @@ describe("AddIntegrationModal (Notion)", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id); expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id); expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument(); expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
@@ -381,7 +407,7 @@ describe("AddIntegrationModal (Notion)", () => {
expect(columnDropdowns[1]).toHaveValue("p2"); expect(columnDropdowns[1]).toHaveValue("p2");
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0); expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0); expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0);
}); });
expect(screen.getByText("Delete")).toBeInTheDocument(); expect(screen.getByText("Delete")).toBeInTheDocument();
@@ -445,8 +471,8 @@ describe("AddIntegrationModal (Notion)", () => {
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2); expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button
await userEvent.click(xButton); await userEvent.click(trashButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1); expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
}); });

View File

@@ -12,11 +12,19 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions"; import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { PlusIcon, XIcon } from "lucide-react"; import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -336,9 +344,9 @@ export const AddIntegrationModal = ({
col={mapping[idx].column} col={mapping[idx].column}
ques={mapping[idx].question} ques={mapping[idx].question}
/> />
<div className="flex w-full items-center"> <div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<div className="w-[340px] max-w-full"> <div className="max-w-full flex-1">
<DropdownSelector <DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")} placeholder={t("environments.integrations.notion.select_a_survey_question")}
items={filteredQuestionItems} items={filteredQuestionItems}
@@ -384,7 +392,7 @@ export const AddIntegrationModal = ({
/> />
</div> </div>
<div className="h-px w-4 border-t border-t-slate-300" /> <div className="h-px w-4 border-t border-t-slate-300" />
<div className="w-[340px] max-w-full"> <div className="max-w-full flex-1">
<DropdownSelector <DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")} placeholder={t("environments.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()} items={getFilteredDbItems()}
@@ -430,53 +438,45 @@ export const AddIntegrationModal = ({
/> />
</div> </div>
</div> </div>
<button <div className="flex space-x-2">
type="button" {mapping.length > 1 && (
className={`rounded-md p-1 hover:bg-slate-300 ${ <Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
idx === mapping.length - 1 ? "visible" : "invisible" <TrashIcon />
}`} </Button>
onClick={addRow}> )}
<PlusIcon className="h-5 w-5 font-bold text-slate-500" /> <Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
</button> <PlusIcon />
<button </Button>
type="button" </div>
className={`flex-1 rounded-md p-1 hover:bg-red-100 ${
mapping.length > 1 ? "visible" : "invisible"
}`}
onClick={deleteRow}>
<XIcon className="h-5 w-5 text-red-500" />
</button>
</div> </div>
</div> </div>
); );
}; };
return ( return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg"> <Dialog open={open} onOpenChange={setOpen}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <div className="mb-4 flex items-start space-x-2">
<div className="flex items-center space-x-2"> <div className="relative size-8">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <Image
<Image fill
className="w-12" className="object-contain object-center"
src={NotionLogo} src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")} alt={t("environments.integrations.notion.notion_logo")}
/> />
</div> </div>
<div> <div className="space-y-0.5">
<div className="text-xl font-medium text-slate-700"> <DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
{t("environments.integrations.notion.link_notion_database")} <DialogDescription>
</div> {t("environments.integrations.notion.notion_integration_description")}
<div className="text-sm text-slate-500"> </DialogDescription>
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
</div>
</div>
</div> </div>
</div> </div>
</div> </DialogHeader>
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
<div className="flex justify-between rounded-lg p-6"> <form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4">
<DialogBody>
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
<Label> <Label>
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")} {t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label> </Label>
<div className="mt-4 max-h-[20vh] w-full overflow-y-auto"> <div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((_, idx) => ( {mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} /> <MappingRow idx={idx} key={idx} />
))} ))}
@@ -530,43 +530,40 @@ export const AddIntegrationModal = ({
)} )}
</div> </div>
</div> </div>
</div> </DialogBody>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2"> <DialogFooter>
{selectedIntegration ? ( {selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button <Button
type="submit" type="button"
loading={isLinkingDatabase} variant="destructive"
disabled={mapping.filter((m) => m.error).length > 0}> loading={isDeleting}
{selectedIntegration onClick={() => {
? t("common.update") deleteLink();
: t("environments.integrations.notion.link_database")} }}>
{t("common.delete")}
</Button> </Button>
</div> ) : (
</div> <Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
</Button>
</DialogFooter>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };

View File

@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret", GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url", GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-redis-url", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));

View File

@@ -128,7 +128,6 @@ const mockSurveys: TSurvey[] = [
displayPercentage: null, displayPercentage: null,
languages: [], languages: [],
pin: null, pin: null,
resultShareKey: null,
segment: null, segment: null,
singleUse: null, singleUse: null,
styling: null, styling: null,

View File

@@ -83,9 +83,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? <div data-testid="modal">{children}</div> : null, open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -121,6 +136,8 @@ vi.mock("@tolgee/react", async () => {
if (key === "common.all_questions") return "All questions"; if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions"; if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel"; if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel";
if (key === "environments.integrations.slack.slack_integration_description")
return "Send responses directly to Slack.";
if (key === "common.update") return "Update"; if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete"; if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel"; if (key === "common.cancel") return "Cancel";
@@ -209,7 +226,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] }, hiddenFields: { enabled: true, fieldIds: [] },
pin: null, pin: null,
resultShareKey: null,
displayLimit: null, displayLimit: null,
} as unknown as TSurvey, } as unknown as TSurvey,
{ {
@@ -247,7 +263,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] }, hiddenFields: { enabled: true, fieldIds: [] },
pin: null, pin: null,
resultShareKey: null,
displayLimit: null, displayLimit: null,
} as unknown as TSurvey, } as unknown as TSurvey,
]; ];
@@ -312,10 +327,9 @@ describe("AddChannelMappingModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument(); expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument(); expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument(); expect(screen.getByText("Cancel")).toBeInTheDocument();
@@ -339,10 +353,9 @@ describe("AddChannelMappingModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id); expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id); expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument(); expect(screen.getByText("Questions")).toBeInTheDocument();

View File

@@ -7,9 +7,17 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { CircleHelpIcon } from "lucide-react"; import { CircleHelpIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
@@ -189,24 +197,28 @@ export const AddChannelMappingModal = ({
); );
return ( return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}> <Dialog open={open} onOpenChange={setOpenWithStates}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2"> <div className="relative size-8">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <Image
<Image className="w-12" src={SlackLogo} alt="Slack logo" /> fill
</div> className="object-contain object-center"
<div> src={SlackLogo}
<div className="text-xl font-medium text-slate-700"> alt={t("environments.integrations.slack.slack_logo")}
{t("environments.integrations.slack.link_slack_channel")} />
</div> </div>
</div> <div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.slack.link_slack_channel")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.slack.slack_integration_description")}
</DialogDescription>
</div> </div>
</div> </div>
</div> </DialogHeader>
<form onSubmit={handleSubmit(linkChannel)}> <form className="space-y-4" onSubmit={handleSubmit(linkChannel)}>
<div className="flex justify-between rounded-lg p-6"> <DialogBody>
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -289,31 +301,29 @@ export const AddChannelMappingModal = ({
</div> </div>
)} )}
</div> </div>
</div> </DialogBody>
<div className="flex justify-end border-t border-slate-200 p-6"> <DialogFooter>
<div className="flex space-x-2"> {selectedIntegration ? (
{selectedIntegration ? ( <Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}> {t("common.delete")}
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</Button> </Button>
</div> ) : (
</div> <Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</Button>
</DialogFooter>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };

View File

@@ -114,7 +114,6 @@ const mockSurveys: TSurvey[] = [
languages: [], languages: [],
styling: null, styling: null,
segment: null, segment: null,
resultShareKey: null,
displayPercentage: null, displayPercentage: null,
closeOnDate: null, closeOnDate: null,
runOnDate: null, runOnDate: null,

View File

@@ -1,3 +1,4 @@
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
@@ -5,6 +6,7 @@ import { cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembership } from "@formbricks/types/memberships"; import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
@@ -13,12 +15,20 @@ import EnvLayout from "./layout";
// Mock sub-components to render identifiable elements // Mock sub-components to render identifiable elements
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({ vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>, EnvironmentLayout: ({ children, environmentId, session }: any) => (
<div data-testid="EnvironmentLayout" data-environment-id={environmentId} data-session={session?.user?.id}>
{children}
</div>
),
})); }));
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => ( EnvironmentIdBaseLayout: ({ children, environmentId, session, user, organization }: any) => (
<div data-testid="EnvironmentIdBaseLayout"> <div
{environmentId} data-testid="EnvironmentIdBaseLayout"
data-environment-id={environmentId}
data-session={session?.user?.id}
data-user={user?.id}
data-organization={organization?.id}>
{children} {children}
</div> </div>
), ),
@@ -27,7 +37,24 @@ vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="ToasterClient" />, ToasterClient: () => <div data-testid="ToasterClient" />,
})); }));
vi.mock("./components/EnvironmentStorageHandler", () => ({ vi.mock("./components/EnvironmentStorageHandler", () => ({
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>, default: ({ environmentId }: any) => (
<div data-testid="EnvironmentStorageHandler" data-environment-id={environmentId} />
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
EnvironmentContextWrapper: ({ children, environment, project }: any) => (
<div
data-testid="EnvironmentContextWrapper"
data-environment-id={environment?.id}
data-project-id={project?.id}>
{children}
</div>
),
}));
// Mock navigation
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
})); }));
// Mocks for dependencies // Mocks for dependencies
@@ -37,26 +64,43 @@ vi.mock("@/modules/environments/lib/utils", () => ({
vi.mock("@/lib/project/service", () => ({ vi.mock("@/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(), getProjectByEnvironmentId: vi.fn(),
})); }));
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({ vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(), getMembershipByUserIdOrganizationId: vi.fn(),
})); }));
describe("EnvLayout", () => { describe("EnvLayout", () => {
const mockSession = { user: { id: "user1" } } as Session;
const mockUser = { id: "user1", email: "user1@example.com" } as TUser;
const mockOrganization = { id: "org1", name: "Org1", billing: {} } as TOrganization;
const mockProject = { id: "proj1", name: "Test Project" } as TProject;
const mockEnvironment = { id: "env1", type: "production" } as TEnvironment;
const mockMembership = {
id: "member1",
role: "owner",
organizationId: "org1",
userId: "user1",
accepted: true,
} as TMembership;
const mockTranslation = ((key: string) => key) as any;
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks();
}); });
test("renders successfully when all dependencies return valid data", async () => { test("renders successfully when all dependencies return valid data", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test t: mockTranslation,
session: { user: { id: "user1" } } as Session, session: mockSession,
user: { id: "user1", email: "user1@example.com" } as TUser, user: mockUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization, organization: mockOrganization,
}); });
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject); vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({ vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
id: "member1", vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
} as unknown as TMembership);
const result = await EnvLayout({ const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }), params: Promise.resolve({ environmentId: "env1" }),
@@ -64,56 +108,43 @@ describe("EnvLayout", () => {
}); });
render(result); render(result);
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1"); // Verify main layout structure
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1"); expect(screen.getByTestId("EnvironmentIdBaseLayout")).toBeInTheDocument();
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined(); expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-environment-id", "env1");
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-session", "user1");
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-user", "user1");
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-organization", "org1");
// Verify environment storage handler
expect(screen.getByTestId("EnvironmentStorageHandler")).toBeInTheDocument();
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveAttribute("data-environment-id", "env1");
// Verify context wrapper
expect(screen.getByTestId("EnvironmentContextWrapper")).toBeInTheDocument();
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1");
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-project-id", "proj1");
// Verify environment layout
expect(screen.getByTestId("EnvironmentLayout")).toBeInTheDocument();
expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-environment-id", "env1");
expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-session", "user1");
// Verify children are rendered
expect(screen.getByTestId("child")).toHaveTextContent("Content"); expect(screen.getByTestId("child")).toHaveTextContent("Content");
// Verify all services were called with correct parameters
expect(environmentIdLayoutChecks).toHaveBeenCalledWith("env1");
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
expect(getEnvironment).toHaveBeenCalledWith("env1");
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
}); });
test("throws error if project is not found", async () => { test("redirects when session is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any, t: mockTranslation,
session: { user: { id: "user1" } } as Session, session: null as unknown as Session,
user: { id: "user1", email: "user1@example.com" } as TUser, user: mockUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization, organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.project_not_found");
});
test("throws error if membership is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.membership_not_found");
});
test("calls redirect when session is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: undefined as unknown as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
}); });
vi.mocked(redirect).mockImplementationOnce(() => { vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called"); throw new Error("Redirect called");
@@ -125,18 +156,16 @@ describe("EnvLayout", () => {
children: <div>Content</div>, children: <div>Content</div>,
}) })
).rejects.toThrow("Redirect called"); ).rejects.toThrow("Redirect called");
expect(redirect).toHaveBeenCalledWith("/auth/login");
}); });
test("throws error if user is null", async () => { test("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any, t: mockTranslation,
session: { user: { id: "user1" } } as Session, session: mockSession,
user: undefined as unknown as TUser, user: null as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization, organization: mockOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
}); });
await expect( await expect(
@@ -145,5 +174,154 @@ describe("EnvLayout", () => {
children: <div>Content</div>, children: <div>Content</div>,
}) })
).rejects.toThrow("common.user_not_found"); ).rejects.toThrow("common.user_not_found");
// Verify redirect was not called
expect(redirect).not.toHaveBeenCalled();
});
test("throws error if project is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.project_not_found");
// Verify both project and environment were called in Promise.all
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
expect(getEnvironment).toHaveBeenCalledWith("env1");
});
test("throws error if environment is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.environment_not_found");
// Verify both project and environment were called in Promise.all
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
expect(getEnvironment).toHaveBeenCalledWith("env1");
});
test("throws error if membership is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.membership_not_found");
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
});
test("handles Promise.all correctly for project and environment", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
// Mock Promise.all to verify it's called correctly
const getProjectSpy = vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
const getEnvironmentSpy = vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Content</div>,
});
render(result);
// Verify both calls were made
expect(getProjectSpy).toHaveBeenCalledWith("env1");
expect(getEnvironmentSpy).toHaveBeenCalledWith("env1");
// Verify successful rendering
expect(screen.getByTestId("child")).toBeInTheDocument();
});
test("handles different environment types correctly", async () => {
const developmentEnvironment = { id: "env1", type: "development" } as TEnvironment;
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getEnvironment).mockResolvedValueOnce(developmentEnvironment);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Content</div>,
});
render(result);
// Verify context wrapper receives the development environment
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1");
expect(screen.getByTestId("child")).toBeInTheDocument();
});
test("handles different user roles correctly", async () => {
const memberMembership = {
id: "member1",
role: "member",
organizationId: "org1",
userId: "user1",
accepted: true,
} as TMembership;
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(memberMembership);
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Content</div>,
});
render(result);
// Verify successful rendering with member role
expect(screen.getByTestId("child")).toBeInTheDocument();
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
}); });
}); });

View File

@@ -1,4 +1,6 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"; import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
@@ -11,7 +13,6 @@ const EnvLayout = async (props: {
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId); const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
@@ -24,11 +25,19 @@ const EnvLayout = async (props: {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
const project = await getProjectByEnvironmentId(params.environmentId); const [project, environment] = await Promise.all([
getProjectByEnvironmentId(params.environmentId),
getEnvironment(params.environmentId),
]);
if (!project) { if (!project) {
throw new Error(t("common.project_not_found")); throw new Error(t("common.project_not_found"));
} }
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
if (!membership) { if (!membership) {
@@ -42,9 +51,11 @@ const EnvLayout = async (props: {
user={user} user={user}
organization={organization}> organization={organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} /> <EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentLayout environmentId={params.environmentId} session={session}> <EnvironmentContextWrapper environment={environment} project={project}>
{children} <EnvironmentLayout environmentId={params.environmentId} session={session}>
</EnvironmentLayout> {children}
</EnvironmentLayout>
</EnvironmentContextWrapper>
</EnvironmentIdBaseLayout> </EnvironmentIdBaseLayout>
); );
}; };

View File

@@ -37,7 +37,6 @@ describe("EnvironmentPage", () => {
id: mockUserId, id: mockUserId,
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
imageUrl: "",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1, AUDIT_LOG_ENABLED: 1,
})); }));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1, AUDIT_LOG_ENABLED: 1,
})); }));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1, AUDIT_LOG_ENABLED: 1,
})); }));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1, AUDIT_LOG_ENABLED: 1,
})); }));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));

View File

@@ -41,7 +41,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));

View File

@@ -49,7 +49,6 @@ const mockUser = {
email: "test@example.com", email: "test@example.com",
notificationSettings: { notificationSettings: {
alert: {}, alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [], unsubscribedOrganizationIds: [],
}, },
role: "project_manager", role: "project_manager",

View File

@@ -1,166 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { EditWeeklySummary } from "./EditWeeklySummary";
vi.mock("lucide-react", () => ({
UsersIcon: () => <div data-testid="users-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href} data-testid="link">
{children}
</a>
),
}));
const mockNotificationSwitch = vi.fn();
vi.mock("./NotificationSwitch", () => ({
NotificationSwitch: (props: any) => {
mockNotificationSwitch(props);
return (
<div data-testid={`notification-switch-${props.surveyOrProjectOrOrganizationId}`}>
NotificationSwitch
</div>
);
},
}));
const mockT = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: mockT,
}),
}));
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {
proj1: true,
proj3: false,
},
unsubscribedOrganizationIds: [],
},
role: "project_manager",
objective: "other",
emailVerified: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
identityProvider: "email",
twoFactorEnabled: false,
} as unknown as TUser;
const mockMemberships: Membership[] = [
{
organization: {
id: "org1",
name: "Organization 1",
projects: [
{ id: "proj1", name: "Project 1", environments: [] },
{ id: "proj2", name: "Project 2", environments: [] },
],
},
},
{
organization: {
id: "org2",
name: "Organization 2",
projects: [{ id: "proj3", name: "Project 3", environments: [] }],
},
},
];
const environmentId = "test-env-id";
describe("EditWeeklySummary", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with multiple memberships and projects", () => {
render(<EditWeeklySummary memberships={mockMemberships} user={mockUser} environmentId={environmentId} />);
expect(screen.getByText("Organization 1")).toBeInTheDocument();
expect(screen.getByText("Project 1")).toBeInTheDocument();
expect(screen.getByText("Project 2")).toBeInTheDocument();
expect(screen.getByText("Organization 2")).toBeInTheDocument();
expect(screen.getByText("Project 3")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj1",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj1")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj2",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj2")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj3",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj3")).toBeInTheDocument();
const inviteLinks = screen.getAllByTestId("link");
expect(inviteLinks.length).toBe(mockMemberships.length);
inviteLinks.forEach((link) => {
expect(link).toHaveAttribute("href", `/environments/${environmentId}/settings/general`);
expect(link).toHaveTextContent("common.invite_them");
});
expect(screen.getAllByTestId("users-icon").length).toBe(mockMemberships.length);
expect(screen.getAllByText("common.project")[0]).toBeInTheDocument();
expect(screen.getAllByText("common.weekly_summary")[0]).toBeInTheDocument();
expect(
screen.getAllByText("environments.settings.notifications.want_to_loop_in_organization_mates?").length
).toBe(mockMemberships.length);
});
test("renders correctly with no memberships", () => {
render(<EditWeeklySummary memberships={[]} user={mockUser} environmentId={environmentId} />);
expect(screen.queryByText("Organization 1")).not.toBeInTheDocument();
expect(screen.queryByTestId("users-icon")).not.toBeInTheDocument();
});
test("renders correctly when an organization has no projects", () => {
const membershipsWithNoProjects: Membership[] = [
{
organization: {
id: "org3",
name: "Organization No Projects",
projects: [],
},
},
];
render(
<EditWeeklySummary
memberships={membershipsWithNoProjects}
user={mockUser}
environmentId={environmentId}
/>
);
expect(screen.getByText("Organization No Projects")).toBeInTheDocument();
expect(screen.queryByText("Project 1")).not.toBeInTheDocument(); // Check that no projects are listed under it
expect(mockNotificationSwitch).not.toHaveBeenCalled(); // No projects, so no switches for projects
});
});

View File

@@ -1,59 +0,0 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { UsersIcon } from "lucide-react";
import Link from "next/link";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { NotificationSwitch } from "./NotificationSwitch";
interface EditAlertsProps {
memberships: Membership[];
user: TUser;
environmentId: string;
}
export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAlertsProps) => {
const { t } = useTranslate();
return (
<>
{memberships.map((membership) => (
<div key={membership.organization.id}>
<div className="mb-5 flex items-center space-x-3 text-sm font-medium">
<UsersIcon className="h-6 w-7 text-slate-600" />
<p className="text-slate-800">{membership.organization.name}</p>
</div>
<div className="mb-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-3 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">{t("common.project")}</div>
<div className="col-span-1 text-center">{t("common.weekly_summary")}</div>
</div>
<div className="space-y-1 p-2">
{membership.organization.projects.map((project) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center justify-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={project.id}>
<div className="col-span-2">{project?.name}</div>
<div className="col-span-1 flex items-center justify-center">
<NotificationSwitch
surveyOrProjectOrOrganizationId={project.id}
notificationSettings={user.notificationSettings!}
notificationType={"weeklySummary"}
/>
</div>
</div>
))}
</div>
<p className="pb-3 pl-4 text-xs text-slate-400">
{t("environments.settings.notifications.want_to_loop_in_organization_mates")}?{" "}
<Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
{t("common.invite_them")}
</Link>
</p>
</div>
</div>
))}
</>
);
};

View File

@@ -29,7 +29,6 @@ const organizationId = "org1";
const baseNotificationSettings: TUserNotificationSettings = { const baseNotificationSettings: TUserNotificationSettings = {
alert: {}, alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [], unsubscribedOrganizationIds: [],
}; };
@@ -68,19 +67,6 @@ describe("NotificationSwitch", () => {
expect(switchInput.checked).toBe(false); expect(switchInput.checked).toBe(false);
}); });
test("renders with initial checked state for 'weeklySummary' (true)", () => {
const settings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: projectId,
notificationSettings: settings,
notificationType: "weeklySummary",
});
const switchInput = screen.getByLabelText(
"toggle notification settings for weeklySummary"
) as HTMLInputElement;
expect(switchInput.checked).toBe(true);
});
test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => { test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => {
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] }; const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
renderSwitch({ renderSwitch({
@@ -268,31 +254,6 @@ describe("NotificationSwitch", () => {
expect(toast.success).not.toHaveBeenCalled(); expect(toast.success).not.toHaveBeenCalled();
}); });
test("shows error toast when updateNotificationSettingsAction fails for 'weeklySummary' type", async () => {
const mockErrorResponse = { serverError: "Database connection failed" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: projectId,
notificationSettings: initialSettings,
notificationType: "weeklySummary",
});
const switchInput = screen.getByLabelText("toggle notification settings for weeklySummary");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, weeklySummary: { [projectId]: false } },
});
expect(toast.error).toHaveBeenCalledWith("Database connection failed", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => { test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
const mockErrorResponse = { serverError: "Permission denied" }; const mockErrorResponse = { serverError: "Permission denied" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse); vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);

View File

@@ -12,7 +12,7 @@ import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps { interface NotificationSwitchProps {
surveyOrProjectOrOrganizationId: string; surveyOrProjectOrOrganizationId: string;
notificationSettings: TUserNotificationSettings; notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "weeklySummary" | "unsubscribedOrganizationIds"; notificationType: "alert" | "unsubscribedOrganizationIds";
autoDisableNotificationType?: string; autoDisableNotificationType?: string;
autoDisableNotificationElementId?: string; autoDisableNotificationElementId?: string;
} }

View File

@@ -34,17 +34,5 @@ describe("Loading Notifications Settings", () => {
.getByText("environments.settings.notifications.email_alerts_surveys") .getByText("environments.settings.notifications.email_alerts_surveys")
.closest("div[class*='rounded-xl']"); // Find parent card .closest("div[class*='rounded-xl']"); // Find parent card
expect(alertsCard).toBeInTheDocument(); expect(alertsCard).toBeInTheDocument();
// Check for Weekly Summary LoadingCard
expect(
screen.getByText("environments.settings.notifications.weekly_summary_projects")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
).toBeInTheDocument();
const weeklySummaryCard = screen
.getByText("environments.settings.notifications.weekly_summary_projects")
.closest("div[class*='rounded-xl']"); // Find parent card
expect(weeklySummaryCard).toBeInTheDocument();
}); });
}); });

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