Compare commits

...

140 Commits

Author SHA1 Message Date
Dhruwang
e92e51b030 fix: browser back behaviour 2025-07-30 12:22:40 +05:30
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
Piyush Gupta
ba9b01a969 fix: survey list refresh (#6104)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-04 08:16:27 +00:00
Harsh Bhat
e810e38333 chore: change pricing (#5850)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-03 13:40:19 +00:00
victorvhs017
dab8ad00d5 feat: Add Sentry source maps (#6047) 2025-07-03 13:03:59 +00:00
Anshuman Pandey
2c34f43c83 fix: adds build step to the database package for optimizing docker build (#5970)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-02 03:42:01 +00:00
Kunal Garg
979fd71a11 feat: reset password in accounts page (#5219)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-01 15:41:14 +00:00
Harsh Bhat
1be23eebbb docs: Add audit logs, domain split in the license details (#6139) 2025-07-01 04:57:42 -07:00
Dhruwang Jariwala
d10cff917d fix: recall parsing for headlines with empty strings (#6131) 2025-07-01 08:16:14 +00:00
Dhruwang Jariwala
da72101320 fix: active tab scaling issue (#6127) 2025-06-30 11:10:33 +00:00
Aditya
5f02ad49c1 fix: allow dynamic height for action cards to show full text (#6106)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-30 02:29:06 -07:00
Dhruwang Jariwala
6644bba6ea fix: formatted databse error message for response endpoint (#6111) 2025-06-30 06:15:50 +00:00
Piyush Gupta
0b7734f725 fix: optional fields in update response API (#6113) 2025-06-30 06:13:42 +00:00
Dhruwang Jariwala
1536bf6907 fix: question change issue (#6091) 2025-06-29 11:10:30 -07:00
Varun Singh
e81190214f feat: Enable recall for welcome cards. (#5963)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-06-29 10:24:54 -07:00
Romit
48c8906a89 fix: Preview in Email embed is broken (#6120) 2025-06-29 09:31:26 -07:00
Johannes
717b30115b fix: align settings card height plus border radius (#6119) 2025-06-27 07:20:52 -07:00
victorvhs017
1f3962d2d5 fix: updated url validation (#6096)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-06-27 13:01:36 +00:00
Piyush Gupta
619f6e408f fix: /api/v2/management/contact-attribute-keys returns 500 instead of 409 on duplicate record (#6100) 2025-06-27 12:50:35 +00:00
Dhruwang Jariwala
4a8719abaa fix: auto subscribe (#6114) 2025-06-27 12:33:08 +00:00
Dhruwang Jariwala
7b59eb3b26 fix: name and description updation in contact attribute key via api (#6089) 2025-06-27 12:09:41 +00:00
Piyush Gupta
8ac280268d fix: update preview URL construction in survey dropdown menu (#6117) 2025-06-27 11:42:14 +00:00
Dhruwang Jariwala
34e8f4931d chore: simplified sharing modal access (#6103) 2025-06-27 11:39:15 +00:00
Piyush Gupta
ac46850a24 fix: unformatted db errors in contact attribute keys management v1 API (#6102) 2025-06-27 05:48:08 +00:00
victorvhs017
6328be220a fix: updated api docs to use - instead of > (#6107) 2025-06-26 09:54:34 -07:00
Dhruwang Jariwala
882ad99ed7 fix: templates page back button (#6088)
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-26 10:38:45 +00:00
Piyush Gupta
ce47b4c2d8 fix: improper zod validation in action classes management API (#6084) 2025-06-26 10:21:01 +00:00
Matti Nannt
ce8f9de8ec fix: confetti animation display issue (#6085)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-26 06:35:19 +00:00
Anshuman Pandey
ed3c2d2b58 fix: fixes shrinking checkbox (#6092) 2025-06-26 05:14:54 +00:00
Anshuman Pandey
9ae226329b fix: decreases environment ttl to 5 minutes (#6087) 2025-06-25 10:30:36 +00:00
Piyush Gupta
12c3899b85 fix: input validation in management v2 webhooks API (#6078) 2025-06-25 09:49:56 +00:00
Piyush Gupta
ccb1353eb5 fix: split domain docs (#6086) 2025-06-25 00:50:23 -07:00
Johannes
22eb0b79ee chore: update issue templates (#6081) 2025-06-24 13:42:10 -07:00
Abhishek Sharma
5eb7a496da fix: "Add ending" button ui distortion in safari browser (#6048) 2025-06-24 11:50:17 -07:00
Matti Nannt
7ea55e199f chore(infra): always pull new images on staging (#6079) 2025-06-24 19:45:00 +02:00
Varun Singh
83eb472acd fix: Empty survey list state after deleting the last survey. (#6044)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-24 07:52:18 -07:00
Jakob Schott
d9fe6ee4f4 fix: styling update and loading animation for survey media (#6020) 2025-06-24 09:53:27 +00:00
Anshuman Pandey
51b58be079 docs: fixes the bulk contact upload api docs and adds the email property (#6066)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-06-24 01:44:34 -07:00
Harsh Bhat
397643330a docs: Update docs for Private file upload and general client API (#6045) 2025-06-23 08:26:10 -07:00
Piyush Gupta
e5fa4328e1 fix: tls handshake failure in self-hosting license generation (#6050) 2025-06-23 08:42:08 +00:00
Jakob Schott
4b777f1907 feat: unify modal component in storybook (#5901) 2025-06-22 13:54:04 +00:00
Piyush Gupta
c3547ccb36 fix: default environment redirect (#6033) 2025-06-20 16:46:43 +00:00
Johannes
a0f334b300 chore: add rules (#6036) 2025-06-19 09:02:25 -07:00
Jakob Schott
a9f635b768 chore: Satisfy SonarQube ReadOnly props for all question types (#6021) 2025-06-19 06:10:11 +00:00
Jakob Schott
d385b4a0d6 fix: Set non-required as default value on questions (#6018) 2025-06-19 06:09:36 +00:00
Matti Nannt
5e825413d2 chore(infra): switch staging to internal lb (#6012) 2025-06-18 12:04:53 +00:00
Johannes
8c3e816ccd fix: remove Formbricks branding from Link Pages (#5989)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-16 16:18:25 +00:00
Anshuman Pandey
6ddc91ee85 fix: deletes local storage environment id on logout (#5957) 2025-06-16 14:01:16 +00:00
Saurav Jain
14023ca8a9 fix: keyboard accessibility issue (#3768) (#5941) 2025-06-16 15:45:52 +02:00
Dhruwang Jariwala
385e8a4262 fix: Airtable fix (#5976)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-16 12:37:05 +00:00
Matti Nannt
e358104f7c chore: fast return ping endpoint when telemetry is disabled (#5893)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-16 12:14:07 +00:00
Dhruwang Jariwala
c8e9194ab6 fix: broken email embed for rating question (#5890)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-16 11:49:19 +00:00
Matti Nannt
bebe29815d feat: domain based access control (#5985)
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-06-16 11:37:02 +00:00
victorvhs017
7f40502c94 fix: Removed footer on follow-up email if white labelling enabled (#5984) 2025-06-16 10:59:57 +00:00
Dhruwang Jariwala
5fb5215680 fix: email enumeration via signup page (#5853)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-13 16:25:40 +00:00
Varun Singh
19b80ff042 fix: misplaced button text for 'preview survey' (#5972) 2025-06-13 05:29:41 -07:00
Jakob Schott
2dfdba2acf chore: Optimize text sizing and alignment for Drop-Off table (#5914)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-06-13 11:21:45 +00:00
936 changed files with 43645 additions and 21781 deletions

View File

@@ -0,0 +1,23 @@
---
description: Guideline for writing end-user facing documentation in the apps/docs folder
globs:
alwaysApply: false
---
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
---
title: "FEATURE NAME"
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
icon: "link"
---
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
- If a feature is part of the Enterprise Edition, use this note:
<Note>
FEATURE NAME is part of the @Enterprise Edition.
</Note>

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,216 @@
---
description: Migrate deprecated UI components to a unified component
globs:
alwaysApply: false
---
# Component Migration Automation Rule
## Overview
This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases.
## Trigger
When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration").
## Process
### Step 1: Discovery and Planning
1. **Identify migration parameters:**
- Ask user for deprecated component name (e.g., "Modal")
- Ask user for new component name(s) (e.g., "Dialog")
- Ask for any components to exclude (e.g., "ModalWithTabs")
- Ask for specific import paths if needed
2. **Scan codebase** for deprecated components:
- Search for `import.*[DeprecatedComponent]` patterns
- Exclude specified components that should not be migrated
- List all found components with file paths
- Present numbered list to user for confirmation
### Step 2: Component-by-Component Migration
For each component, follow this exact sequence:
#### 2.1 Component Migration
- **Import changes:**
- Ask user to provide the new import structure
- Example transformation pattern:
```typescript
// FROM:
import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]"
// TO:
import {
[NewComponent],
[NewComponentPart1],
[NewComponentPart2],
// ... other parts
} from "@/components/ui/[NewComponent]"
```
- **Props transformation:**
- Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`)
- Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`)
- Apply transformations based on user specifications
- **Structure transformation:**
- Ask user for the new component structure pattern
- Apply the transformation maintaining all functionality
- Preserve all existing logic, state management, and event handlers
#### 2.2 Wait for User Approval
- Present the migration changes
- Wait for explicit user approval before proceeding
- If rejected, ask for specific feedback and iterate
#### 2.3 Re-read and Apply Additional Changes
- Re-read the component file to capture any user modifications
- Apply any additional improvements the user made
- Ensure all changes are incorporated
#### 2.4 Test File Updates
- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`)
- **Update test mocks:**
- Ask user for new component mock structure
- Replace old component mocks with new ones
- Example pattern:
```typescript
// Add to test setup:
jest.mock("@/components/ui/[NewComponent]", () => ({
[NewComponent]: ({ children, [props] }: any) => ([mock implementation]),
[NewComponentPart1]: ({ children }: any) => <div data-testid="[new-component-part1]">{children}</div>,
[NewComponentPart2]: ({ children }: any) => <div data-testid="[new-component-part2]">{children}</div>,
// ... other parts
}));
```
- **Update test expectations:**
- Change test IDs from old component to new component
- Update any component-specific assertions
- Ensure all new component parts used in the component are mocked
#### 2.5 Run Tests and Optimize
- Execute `Node package manager test -- ComponentName.test.tsx`
- Fix any failing tests
- Optimize code quality (imports, formatting, etc.)
- Re-run tests until all pass
- **Maximum 3 iterations** - if still failing, ask user for guidance
#### 2.6 Wait for Final Approval
- Present test results and any optimizations made
- Wait for user approval of the complete migration
- If rejected, iterate based on feedback
#### 2.7 Git Commit
- Run: `git add .`
- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"`
- Confirm commit was successful
### Step 3: Final Report Generation
After all components are migrated, generate a comprehensive GitHub PR report:
#### PR Title
```
feat: migrate [DeprecatedComponent] components to [NewComponent] system
```
#### PR Description Template
```markdown
## 🔄 [DeprecatedComponent] to [NewComponent] Migration
### Overview
Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency.
### Components Migrated
[List each component with file path]
### Technical Changes
- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]`
- **Props:** [List prop transformations]
- **Structure:** Implemented proper [NewComponent] component hierarchy
- **Styling:** [Describe styling changes]
- **Tests:** Updated all test mocks and expectations
### Migration Pattern
```typescript
// Before
<[DeprecatedComponent] [oldProps]>
[oldStructure]
</[DeprecatedComponent]>
// After
<[NewComponent] [newProps]>
[newStructure]
</[NewComponent]>
```
### Testing
- ✅ All existing tests updated and passing
- ✅ Component functionality preserved
- ✅ UI/UX behavior maintained
### How to Test This PR
1. **Functional Testing:**
- Navigate to each migrated component's usage
- Verify [component] opens and closes correctly
- Test all interactive elements within [components]
- Confirm styling and layout are preserved
2. **Automated Testing:**
```bash
Node package manager test
```
3. **Visual Testing:**
- Check that all [components] maintain proper styling
- Verify responsive behavior
- Test keyboard navigation and accessibility
### Breaking Changes
[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."]
### Notes
- [Any excluded components] were preserved as they already use [NewComponent] internally
- All form validation and complex state management preserved
- Enhanced code quality with better imports and formatting
```
## Special Considerations
### Excluded Components
- **DO NOT MIGRATE** components specified by user as exclusions
- They may already use the new component internally or have other reasons
- Inform user these are skipped and why
### Complex Components
- Preserve all existing functionality (forms, validation, state management)
- Maintain prop interfaces
- Keep all event handlers and callbacks
- Preserve accessibility features
### Test Coverage
- Ensure all new component parts are mocked when used
- Mock all new component parts that appear in the component
- Update test IDs from old component to new component
- Maintain all existing test scenarios
### Error Handling
- If tests fail after 3 iterations, stop and ask user for guidance
- If component is too complex, ask user for specific guidance
- If unsure about functionality preservation, ask for clarification
### Migration Patterns
- Always ask user for specific migration patterns before starting
- Confirm import structures, prop mappings, and component hierarchies
- Adapt to different component architectures (simple replacements, complex restructuring, etc.)
## Success Criteria
- All deprecated components successfully migrated to new components
- All tests passing
- No functionality lost
- Code quality maintained or improved
- User approval on each component
- Successful git commits for each migration
- Comprehensive PR report generated
## Usage Examples
- "migrate Modal to Dialog"
- "migrate Button to NewButton"
- "migrate Card to ModernCard"
- "component migration" (will prompt for details)

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

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

@@ -80,8 +80,8 @@ S3_ENDPOINT_URL=
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled) # Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
S3_FORCE_PATH_STYLE=0 S3_FORCE_PATH_STYLE=0
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL) # Set this URL to add a public domain for all your client facing routes(default is WEBAPP_URL)
# SURVEY_URL=https://survey.example.com # PUBLIC_URL=https://survey.example.com
##################### #####################
# Disable Features # # Disable Features #
@@ -189,15 +189,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=
@@ -210,6 +206,8 @@ UNKEY_ROOT_KEY=
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin. # The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
# It's used automatically by Sentry during the build for authentication when uploading source maps. # It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN= # SENTRY_AUTH_TOKEN=
# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard
# SENTRY_ENVIRONMENT=
# Configure the minimum role for user management from UI(owner, manager, disabled) # Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager" # USER_MANAGEMENT_MINIMUM_ROLE="manager"
@@ -217,7 +215,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

@@ -1,6 +1,7 @@
name: Feature request name: Feature request
description: "Suggest an idea for this project \U0001F680" description: "Suggest an idea for this project \U0001F680"
type: feature type: feature
projects: "formbricks/21"
body: body:
- type: textarea - type: textarea
id: problem-description id: problem-description

View File

@@ -1,11 +0,0 @@
name: Task (internal)
description: "Template for creating a task. Used by the Formbricks Team only \U0001f4e5"
type: task
body:
- type: textarea
id: task-summary
attributes:
label: Task description
description: A clear detailed-rich description of the task.
validations:
required: true

View File

@@ -0,0 +1,125 @@
name: 'Upload Sentry Sourcemaps'
description: 'Extract sourcemaps from Docker image and upload to Sentry'
inputs:
docker_image:
description: 'Docker image to extract sourcemaps from'
required: true
release_version:
description: 'Sentry release version (e.g., v1.2.3)'
required: true
sentry_auth_token:
description: 'Sentry authentication token'
required: true
environment:
description: 'Sentry environment (e.g., production, staging)'
required: false
default: 'staging'
runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate Sentry auth token
shell: bash
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
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
fi
echo "✅ Sentry auth token validated successfully"
# 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
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
echo "Container created with ID: $CONTAINER_ID"
# Set up cleanup function to ensure container is removed on script exit
cleanup_container() {
# Capture the current exit code to preserve it
local original_exit_code=$?
echo "🧹 Cleaning up Docker container..."
# Remove the container if it exists (ignore errors if already removed)
if [ -n "$CONTAINER_ID" ]; then
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
echo "Container $CONTAINER_ID removed"
fi
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
# Extract .next directory containing sourcemaps
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
# Verify sourcemaps exist
if [ ! -d "./extracted-next/static/chunks" ]; then
echo "❌ Error: .next/static/chunks directory not found in Docker image"
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
exit 1
fi
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
echo "✅ Found $sourcemap_count sourcemap files"
if [ "$sourcemap_count" -eq 0 ]; then
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
exit 1
fi
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud
with:
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'
- name: Clean up extracted files
shell: bash
if: always()
run: |
set -euo pipefail
# Clean up extracted files
rm -rf ./extracted-next
echo "🧹 Cleaned up extracted files"

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:
@@ -52,6 +52,7 @@ jobs:
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
@@ -66,8 +67,8 @@ jobs:
AWS_REGION: eu-central-1 AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@v2 - uses: helmfile/helmfile-action@v2
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 }}
@@ -84,8 +85,8 @@ jobs:
helmfile-workdirectory: infra/formbricks-cloud-helm helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@v2 - uses: helmfile/helmfile-action@v2
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,13 +102,13 @@ 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 }}
run: | run: |
# Set hostname based on environment # Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then if [[ "${{ inputs.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"

View File

@@ -89,6 +89,7 @@ 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
shell: bash shell: bash
@@ -102,6 +103,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-)

View File

@@ -1,17 +1,22 @@
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
env:
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
jobs: jobs:
docker-build: docker-build:
name: Build & release stable docker image name: Build & release docker image
if: startsWith(github.ref, 'refs/tags/v')
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
@@ -31,4 +36,27 @@ 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: ${{ env.ENVIRONMENT }}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- docker-build
- deploy-formbricks-cloud
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: ${{ env.ENVIRONMENT }}

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
@@ -38,6 +42,53 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Generate SemVer version from branch or tag
id: generate_version
run: |
# Get reference name and type
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
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 +134,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 +163,25 @@ 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: Checkout
uses: actions/checkout@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
@@ -45,10 +51,12 @@ jobs:
- name: Get Release Tag - name: Get Release Tag
id: extract_release_tag id: extract_release_tag
run: | run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG=${{ github.ref }} TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v} TAG=${TAG#refs/tags/v}
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 +89,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 != 'true' }}
# 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

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

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

@@ -0,0 +1,46 @@
name: Upload Sentry Sourcemaps (Manual)
on:
workflow_dispatch:
inputs:
docker_image:
description: "Docker image to extract sourcemaps from"
required: true
type: string
release_version:
description: "Release version (e.g., v1.2.3)"
required: true
type: string
tag_version:
description: "Docker image tag (leave empty to use release_version)"
required: false
type: string
permissions:
contents: read
jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Set Docker Image
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

1
.gitignore vendored
View File

@@ -73,3 +73,4 @@ infra/terraform/.terraform/
/.idea/ /.idea/
/*.iml /*.iml
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
.cursorrules

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

@@ -25,21 +25,9 @@ RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers # Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3 RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
# BuildKit secret handling without hardcoded fallback values # Copy the secrets handling script
# This approach relies entirely on secrets passed from GitHub Actions COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \ RUN chmod +x /tmp/read-secrets.sh
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
# Increase Node.js memory limit as a regular build argument # Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096" ARG NODE_OPTIONS="--max_old_space_size=4096"
@@ -62,6 +50,9 @@ RUN touch apps/web/.env
# Install the dependencies # Install the dependencies
RUN pnpm install --ignore-scripts RUN pnpm install --ignore-scripts
# Build the database package first
RUN pnpm build --filter=@formbricks/database
# Build the project using our secret reader script # Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers # This mounts the secrets only during this build step without storing them in layers
RUN --mount=type=secret,id=database_url \ RUN --mount=type=secret,id=database_url \
@@ -106,20 +97,8 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
@@ -142,12 +121,14 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod RUN chmod -R 755 ./node_modules/zod
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
RUN npm install -g prisma RUN npm install -g prisma
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh
EXPOSE 3000 EXPOSE 3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENV NODE_ENV="production"
USER nextjs USER nextjs
# Prepare volume for uploads # Prepare volume for uploads
@@ -158,12 +139,4 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \ CMD ["/home/nextjs/start.sh"]
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js

View File

@@ -27,7 +27,7 @@ describe("ConnectWithFormbricks", () => {
render( render(
<ConnectWithFormbricks <ConnectWithFormbricks
environment={environment} environment={environment}
webAppUrl={webAppUrl} publicDomain={webAppUrl}
widgetSetupCompleted={false} widgetSetupCompleted={false}
channel={channel} channel={channel}
/> />
@@ -40,7 +40,7 @@ describe("ConnectWithFormbricks", () => {
render( render(
<ConnectWithFormbricks <ConnectWithFormbricks
environment={environment} environment={environment}
webAppUrl={webAppUrl} publicDomain={webAppUrl}
widgetSetupCompleted={true} widgetSetupCompleted={true}
channel={channel} channel={channel}
/> />
@@ -53,7 +53,7 @@ describe("ConnectWithFormbricks", () => {
render( render(
<ConnectWithFormbricks <ConnectWithFormbricks
environment={environment} environment={environment}
webAppUrl={webAppUrl} publicDomain={webAppUrl}
widgetSetupCompleted={true} widgetSetupCompleted={true}
channel={channel} channel={channel}
/> />
@@ -67,7 +67,7 @@ describe("ConnectWithFormbricks", () => {
render( render(
<ConnectWithFormbricks <ConnectWithFormbricks
environment={environment} environment={environment}
webAppUrl={webAppUrl} publicDomain={webAppUrl}
widgetSetupCompleted={false} widgetSetupCompleted={false}
channel={channel} channel={channel}
/> />

View File

@@ -12,14 +12,14 @@ import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
interface ConnectWithFormbricksProps { interface ConnectWithFormbricksProps {
environment: TEnvironment; environment: TEnvironment;
webAppUrl: string; publicDomain: string;
widgetSetupCompleted: boolean; widgetSetupCompleted: boolean;
channel: TProjectConfigChannel; channel: TProjectConfigChannel;
} }
export const ConnectWithFormbricks = ({ export const ConnectWithFormbricks = ({
environment, environment,
webAppUrl, publicDomain,
widgetSetupCompleted, widgetSetupCompleted,
channel, channel,
}: ConnectWithFormbricksProps) => { }: ConnectWithFormbricksProps) => {
@@ -49,7 +49,7 @@ export const ConnectWithFormbricks = ({
<div className="flex w-1/2 flex-col space-y-4"> <div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions <OnboardingSetupInstructions
environmentId={environment.id} environmentId={environment.id}
webAppUrl={webAppUrl} publicDomain={publicDomain}
channel={channel} channel={channel}
widgetSetupCompleted={widgetSetupCompleted} widgetSetupCompleted={widgetSetupCompleted}
/> />

View File

@@ -33,7 +33,7 @@ describe("OnboardingSetupInstructions", () => {
// Provide some default props for testing // Provide some default props for testing
const defaultProps = { const defaultProps = {
environmentId: "env-123", environmentId: "env-123",
webAppUrl: "https://example.com", publicDomain: "https://example.com",
channel: "app" as const, // Assuming channel is either "app" or "website" channel: "app" as const, // Assuming channel is either "app" or "website"
widgetSetupCompleted: false, widgetSetupCompleted: false,
}; };

View File

@@ -18,14 +18,14 @@ const tabs = [
interface OnboardingSetupInstructionsProps { interface OnboardingSetupInstructionsProps {
environmentId: string; environmentId: string;
webAppUrl: string; publicDomain: string;
channel: TProjectConfigChannel; channel: TProjectConfigChannel;
widgetSetupCompleted: boolean; widgetSetupCompleted: boolean;
} }
export const OnboardingSetupInstructions = ({ export const OnboardingSetupInstructions = ({
environmentId, environmentId,
webAppUrl, publicDomain,
channel, channel,
widgetSetupCompleted, widgetSetupCompleted,
}: OnboardingSetupInstructionsProps) => { }: OnboardingSetupInstructionsProps) => {
@@ -34,7 +34,7 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys --> const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript"> <script type="text/javascript">
!function(){ !function(){
var appUrl = "${webAppUrl}"; var appUrl = "${publicDomain}";
var environmentId = "${environmentId}"; var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}(); var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script> </script>
@@ -44,7 +44,7 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys --> const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript"> <script type="text/javascript">
!function(){ !function(){
var appUrl = "${webAppUrl}"; var appUrl = "${publicDomain}";
var environmentId = "${environmentId}"; var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}(); var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script> </script>
@@ -57,7 +57,7 @@ export const OnboardingSetupInstructions = ({
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
formbricks.setup({ formbricks.setup({
environmentId: "${environmentId}", environmentId: "${environmentId}",
appUrl: "${webAppUrl}", appUrl: "${publicDomain}",
}); });
} }
@@ -75,7 +75,7 @@ export const OnboardingSetupInstructions = ({
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
formbricks.setup({ formbricks.setup({
environmentId: "${environmentId}", environmentId: "${environmentId}",
appUrl: "${webAppUrl}", appUrl: "${publicDomain}",
}); });
} }

View File

@@ -1,6 +1,6 @@
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks"; import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { WEBAPP_URL } from "@/lib/constants";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
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";
@@ -30,6 +30,8 @@ const Page = async (props: ConnectPageProps) => {
const channel = project.config.channel || null; const channel = project.config.channel || null;
const publicDomain = getPublicDomain();
return ( return (
<div className="flex min-h-full flex-col items-center justify-center py-10"> <div className="flex min-h-full flex-col items-center justify-center py-10">
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} /> <Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
@@ -39,7 +41,7 @@ const Page = async (props: ConnectPageProps) => {
</div> </div>
<ConnectWithFormbricks <ConnectWithFormbricks
environment={environment} environment={environment}
webAppUrl={WEBAPP_URL} publicDomain={publicDomain}
widgetSetupCompleted={environment.appSetupCompleted} widgetSetupCompleted={environment.appSetupCompleted}
channel={channel} channel={channel}
/> />

View File

@@ -11,7 +11,7 @@ vi.mock("@/lib/constants", () => ({
IS_DEVELOPMENT: true, IS_DEVELOPMENT: true,
E2E_TESTING: false, E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000", WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey", PUBLIC_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key", ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret", CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b", DEFAULT_BRAND_COLOR: "#64748b",
@@ -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

@@ -94,6 +94,7 @@ describe("LandingSidebar component", () => {
organizationId: "o1", organizationId: "o1",
redirect: true, redirect: true,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}); });
}); });

View File

@@ -130,6 +130,7 @@ export const LandingSidebar = ({
organizationId: organization.id, organizationId: organization.id,
redirect: true, redirect: true,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}} }}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}> icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>

View File

@@ -14,7 +14,7 @@ vi.mock("@/lib/constants", () => ({
IS_DEVELOPMENT: true, IS_DEVELOPMENT: true,
E2E_TESTING: false, E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000", WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey", PUBLIC_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key", ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret", CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b", DEFAULT_BRAND_COLOR: "#64748b",
@@ -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

@@ -23,7 +23,6 @@ vi.mock("@/lib/constants", () => ({
IS_DEVELOPMENT: true, IS_DEVELOPMENT: true,
E2E_TESTING: false, E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000", WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key", ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret", CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b", DEFAULT_BRAND_COLOR: "#64748b",
@@ -98,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

@@ -27,7 +27,13 @@ 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", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
})); }));
describe("Contact Page Re-export", () => { describe("Contact Page Re-export", () => {

View File

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

@@ -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,6 +59,16 @@ 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
@@ -67,7 +77,7 @@ export const ActionDetailModal = ({
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={actionClass.description || ""} description={typeDescription()}
/> />
</> </>
); );

View File

@@ -11,22 +11,21 @@ export const ActionClassDataRow = ({
locale: TUserLocale; locale: TUserLocale;
}) => { }) => {
return ( return (
<div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100"> <div className="m-2 grid grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm"> <div className="col-span-4 flex items-start py-3 pl-6 text-sm">
<div className="flex items-center"> <div className="flex w-full items-center gap-4">
<div className="h-5 w-5 flex-shrink-0 text-slate-500"> <div className="mt-1 h-5 w-5 flex-shrink-0 text-slate-500">
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]} {ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
</div> </div>
<div className="ml-4 text-left"> <div className="text-left">
<div className="font-medium text-slate-900">{actionClass.name}</div> <div className="break-words font-medium text-slate-900">{actionClass.name}</div>
<div className="text-xs text-slate-400">{actionClass.description}</div> <div className="break-words text-xs text-slate-400">{actionClass.description}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500"> <div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)} {timeSince(actionClass.createdAt.toString(), locale)}
</div> </div>
<div className="text-center"></div>
</div> </div>
); );
}; };

View File

@@ -210,14 +210,13 @@ export const ActionSettingsTab = ({
)} )}
</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 {
getOrganizationProjectsLimit,
getRoleManagementPermission,
} 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(),
getRoleManagementPermission: 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, canDoRoleManagement }: any) => (
<div data-testid="main-navigation">
MainNavigation
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement?.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>,
@@ -104,7 +118,7 @@ const mockUser = {
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 +170,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 +201,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(getRoleManagementPermission).mockResolvedValue(true);
mockIsDevelopment = false; mockIsDevelopment = false;
mockIsFormbricksCloud = false; mockIsFormbricksCloud = false;
}); });
@@ -288,6 +315,110 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument(); expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
}); });
test("passes canDoRoleManagement 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("can-do-role-management")).toHaveTextContent("true");
expect(vi.mocked(getRoleManagementPermission)).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 canDoRoleManagement false", async () => {
vi.mocked(getRoleManagementPermission).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("can-do-role-management")).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 {
getOrganizationProjectsLimit,
getRoleManagementPermission,
} 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, canDoRoleManagement] = await Promise.all([
getUserProjects(user.id, organization.id), getUserProjects(user.id, organization.id),
getEnvironments(environment.projectId), getEnvironments(environment.projectId),
getRoleManagementPermission(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,6 +121,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
membershipRole={membershipRole} membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active} isLicenseActive={active}
canDoRoleManagement={canDoRoleManagement}
/> />
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50"> <div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
<TopControlBar <TopControlBar

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,
canDoRoleManagement,
}: {
isCollapsed: boolean;
organizationTeams: TOrganizationTeam[];
canDoRoleManagement: 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="can-do-role-management">{canDoRoleManagement.toString()}</div>
</div> </div>
), ),
})); }));
@@ -106,7 +117,7 @@ const mockUser = {
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 +157,7 @@ const defaultProps = {
membershipRole: "owner" as const, membershipRole: "owner" as const,
organizationProjectsLimit: 5, organizationProjectsLimit: 5,
isLicenseActive: true, isLicenseActive: true,
canDoRoleManagement: true,
}; };
describe("MainNavigation", () => { describe("MainNavigation", () => {
@@ -220,6 +232,8 @@ describe("MainNavigation", () => {
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" }); const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut }); vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
// Set up localStorage spy on the mocked localStorage
render(<MainNavigation {...defaultProps} />); render(<MainNavigation {...defaultProps} />);
// Find the avatar and get its parent div which acts as the trigger // Find the avatar and get its parent div which acts as the trigger
@@ -246,7 +260,9 @@ describe("MainNavigation", () => {
organizationId: "org1", organizationId: "org1",
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
await waitFor(() => { await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
}); });
@@ -330,4 +346,23 @@ describe("MainNavigation", () => {
}); });
expect(screen.queryByText("common.license")).not.toBeInTheDocument(); expect(screen.queryByText("common.license")).not.toBeInTheDocument();
}); });
test("passes canDoRoleManagement props to ProjectSwitcher", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
});
test("handles no organizationTeams", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
});
test("handles canDoRoleManagement false", () => {
render(<MainNavigation {...defaultProps} canDoRoleManagement={false} />);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
});
}); });

View File

@@ -66,6 +66,7 @@ interface NavigationProps {
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
organizationProjectsLimit: number; organizationProjectsLimit: number;
isLicenseActive: boolean; isLicenseActive: boolean;
canDoRoleManagement: boolean;
} }
export const MainNavigation = ({ export const MainNavigation = ({
@@ -80,6 +81,7 @@ export const MainNavigation = ({
organizationProjectsLimit, organizationProjectsLimit,
isLicenseActive, isLicenseActive,
isDevelopment, isDevelopment,
canDoRoleManagement,
}: 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}
canDoRoleManagement={canDoRoleManagement}
/> />
)} )}
@@ -396,6 +399,7 @@ export const MainNavigation = ({
organizationId: organization.id, organizationId: organization.id,
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
}} }}

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

@@ -25,10 +25,16 @@ 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,
})); }));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://example.com",
},
}));
describe("AppConnectionPage Re-export", () => { describe("AppConnectionPage Re-export", () => {
test("should re-export AppConnectionPage correctly", () => { test("should re-export AppConnectionPage correctly", () => {
expect(AppConnectionPage).toBe(OriginalAppConnectionPage); expect(AppConnectionPage).toBe(OriginalAppConnectionPage);

View File

@@ -25,10 +25,16 @@ 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,
})); }));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("GeneralSettingsPage re-export", () => { describe("GeneralSettingsPage re-export", () => {
test("should re-export GeneralSettingsPage component", () => { test("should re-export GeneralSettingsPage component", () => {
expect(Page).toBe(GeneralSettingsPage); expect(Page).toBe(GeneralSettingsPage);

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,10 +25,16 @@ 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,
})); }));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("ProjectLookSettingsPage re-export", () => { describe("ProjectLookSettingsPage re-export", () => {
test("should re-export ProjectLookSettingsPage component", () => { test("should re-export ProjectLookSettingsPage component", () => {
expect(Page).toBe(ProjectLookSettingsPage); expect(Page).toBe(ProjectLookSettingsPage);

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

@@ -20,7 +20,7 @@ vi.mock("@/modules/ui/components/switch", () => ({
})); }));
vi.mock("../actions", () => ({ vi.mock("../actions", () => ({
updateNotificationSettingsAction: vi.fn(() => Promise.resolve()), updateNotificationSettingsAction: vi.fn(() => Promise.resolve({ data: true })),
})); }));
const surveyId = "survey1"; const surveyId = "survey1";
@@ -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({
@@ -246,4 +232,179 @@ describe("NotificationSwitch", () => {
}); });
expect(updateNotificationSettingsAction).not.toHaveBeenCalled(); expect(updateNotificationSettingsAction).not.toHaveBeenCalled();
}); });
test("shows error toast when updateNotificationSettingsAction fails for 'alert' type", async () => {
const mockErrorResponse = { serverError: "Failed to update notification settings" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Failed to update notification settings", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
const mockErrorResponse = { serverError: "Permission denied" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
renderSwitch({
surveyOrProjectOrOrganizationId: organizationId,
notificationSettings: initialSettings,
notificationType: "unsubscribedOrganizationIds",
});
const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [organizationId] },
});
expect(toast.error).toHaveBeenCalledWith("Permission denied", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns null", async () => {
const mockErrorResponse = { serverError: "An error occurred" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns undefined", async () => {
const mockErrorResponse = { serverError: "An error occurred" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns response without data property", async () => {
const mockErrorResponse = { validationErrors: { _errors: ["Invalid input"] } };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Invalid input", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction throws an exception", async () => {
const mockErrorResponse = { serverError: "Network error" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Network error", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("switch remains enabled after error occurs", async () => {
const mockErrorResponse = { serverError: "Failed to update" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(toast.error).toHaveBeenCalledWith("Failed to update", {
id: "notification-switch",
});
expect(switchInput).toBeEnabled(); // Switch should be re-enabled after error
});
test("shows error toast with validation errors for specific fields", async () => {
const mockErrorResponse = {
validationErrors: {
notificationSettings: {
_errors: ["Invalid notification settings"],
},
},
};
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("notificationSettingsInvalid notification settings", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
}); });

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Switch } from "@/modules/ui/components/switch"; import { Switch } from "@/modules/ui/components/switch";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { TUserNotificationSettings } from "@formbricks/types/user"; import { TUserNotificationSettings } from "@formbricks/types/user";
@@ -10,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;
} }
@@ -24,6 +26,7 @@ export const NotificationSwitch = ({
}: NotificationSwitchProps) => { }: NotificationSwitchProps) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslate(); const { t } = useTranslate();
const router = useRouter();
const isChecked = const isChecked =
notificationType === "unsubscribedOrganizationIds" notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId) ? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
@@ -50,7 +53,20 @@ export const NotificationSwitch = ({
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId]; !updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
} }
await updateNotificationSettingsAction({ notificationSettings: updatedNotificationSettings }); const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
notificationSettings: updatedNotificationSettings,
});
if (updatedNotificationSettingsActionResponse?.data) {
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
id: "notification-switch",
});
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedNotificationSettingsActionResponse);
toast.error(errorMessage, {
id: "notification-switch",
});
}
setIsLoading(false); setIsLoading(false);
}; };
@@ -104,9 +120,6 @@ export const NotificationSwitch = ({
disabled={isLoading} disabled={isLoading}
onCheckedChange={async () => { onCheckedChange={async () => {
await handleSwitchChange(); await handleSwitchChange();
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
id: "notification-switch",
});
}} }}
/> />
); );

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();
}); });
}); });

View File

@@ -14,11 +14,6 @@ const Loading = () => {
description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"), description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }], skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
}, },
{
title: t("environments.settings.notifications.weekly_summary_projects"),
description: t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
},
]; ];
return ( return (

View File

@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts"; import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary";
import Page from "./page"; import Page from "./page";
import { Membership } from "./types"; import { Membership } from "./types";
@@ -58,9 +57,7 @@ vi.mock("@formbricks/database", () => ({
vi.mock("./components/EditAlerts", () => ({ vi.mock("./components/EditAlerts", () => ({
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>), EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
})); }));
vi.mock("./components/EditWeeklySummary", () => ({
EditWeeklySummary: vi.fn(() => <div>EditWeeklySummaryComponent</div>),
}));
vi.mock("./components/IntegrationsTip", () => ({ vi.mock("./components/IntegrationsTip", () => ({
IntegrationsTip: () => <div>IntegrationsTipComponent</div>, IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
})); }));
@@ -71,7 +68,6 @@ const mockUser: Partial<TUser> = {
email: "test@example.com", email: "test@example.com",
notificationSettings: { notificationSettings: {
alert: { "survey-old": true }, alert: { "survey-old": true },
weeklySummary: { "project-old": true },
unsubscribedOrganizationIds: ["org-unsubscribed"], unsubscribedOrganizationIds: ["org-unsubscribed"],
}, },
}; };
@@ -137,13 +133,6 @@ describe("NotificationsPage", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument(); expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument(); expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument();
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();
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
// The actual `user.notificationSettings` passed to EditAlerts will be a new object // The actual `user.notificationSettings` passed to EditAlerts will be a new object
// after `setCompleteNotificationSettings` processes it. // after `setCompleteNotificationSettings` processes it.
@@ -157,16 +146,12 @@ describe("NotificationsPage", () => {
// It iterates memberships, then projects, then environments, then surveys. // It iterates memberships, then projects, then environments, then surveys.
// `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;` // `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;`
// This means only survey IDs found in memberships will be in the new `alert` object. // This means only survey IDs found in memberships will be in the new `alert` object.
// `newNotificationSettings.weeklySummary[project.id]` also only adds project IDs from memberships.
const finalExpectedSettings = { const finalExpectedSettings = {
alert: { alert: {
"survey-1": false, "survey-1": false,
"survey-2": false, "survey-2": false,
}, },
weeklySummary: {
"project-1": false,
},
unsubscribedOrganizationIds: ["org-unsubscribed"], unsubscribedOrganizationIds: ["org-unsubscribed"],
}; };
@@ -175,11 +160,6 @@ describe("NotificationsPage", () => {
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId); expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type); expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId); expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId);
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(finalExpectedSettings);
expect(editWeeklySummaryCall.memberships).toEqual(mockMemberships);
expect(editWeeklySummaryCall.environmentId).toBe(mockParams.environmentId);
}); });
test("throws error if session is not found", async () => { test("throws error if session is not found", async () => {
@@ -207,21 +187,15 @@ describe("NotificationsPage", () => {
render(PageComponent); render(PageComponent);
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument(); expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
const expectedEmptySettings = { const expectedEmptySettings = {
alert: {}, alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [], unsubscribedOrganizationIds: [],
}; };
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0]; const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings); expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
expect(editAlertsCall.memberships).toEqual([]); expect(editAlertsCall.memberships).toEqual([]);
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(expectedEmptySettings);
expect(editWeeklySummaryCall.memberships).toEqual([]);
}); });
test("handles legacy notification settings correctly", async () => { test("handles legacy notification settings correctly", async () => {
@@ -229,7 +203,6 @@ describe("NotificationsPage", () => {
id: "user-legacy", id: "user-legacy",
notificationSettings: { notificationSettings: {
"survey-1": { responseFinished: true }, // Legacy alert for survey-1 "survey-1": { responseFinished: true }, // Legacy alert for survey-1
weeklySummary: { "project-1": true },
unsubscribedOrganizationIds: [], unsubscribedOrganizationIds: [],
} as any, // To allow legacy structure } as any, // To allow legacy structure
}; };
@@ -246,9 +219,6 @@ describe("NotificationsPage", () => {
"survey-1": true, // Should be true due to legacy setting "survey-1": true, // Should be true due to legacy setting
"survey-2": false, // Default for other surveys in membership "survey-2": false, // Default for other surveys in membership
}, },
weeklySummary: {
"project-1": true, // From user's weeklySummary
},
unsubscribedOrganizationIds: [], unsubscribedOrganizationIds: [],
}; };

View File

@@ -9,7 +9,6 @@ import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TUserNotificationSettings } from "@formbricks/types/user"; import { TUserNotificationSettings } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts"; import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary";
import { IntegrationsTip } from "./components/IntegrationsTip"; import { IntegrationsTip } from "./components/IntegrationsTip";
import type { Membership } from "./types"; import type { Membership } from "./types";
@@ -19,14 +18,10 @@ const setCompleteNotificationSettings = (
): TUserNotificationSettings => { ): TUserNotificationSettings => {
const newNotificationSettings = { const newNotificationSettings = {
alert: {}, alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [], unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
}; };
for (const membership of memberships) { for (const membership of memberships) {
for (const project of membership.organization.projects) { for (const project of membership.organization.projects) {
// set default values for weekly summary
newNotificationSettings.weeklySummary[project.id] =
(notificationSettings.weeklySummary && notificationSettings.weeklySummary[project.id]) || false;
// set default values for alerts // set default values for alerts
for (const environment of project.environments) { for (const environment of project.environments) {
for (const survey of environment.surveys) { for (const survey of environment.surveys) {
@@ -183,11 +178,6 @@ const Page = async (props) => {
/> />
</SettingsCard> </SettingsCard>
<IntegrationsTip environmentId={params.environmentId} /> <IntegrationsTip environmentId={params.environmentId} />
<SettingsCard
title={t("environments.settings.notifications.weekly_summary_projects")}
description={t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")}>
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
</SettingsCard>
</PageContentWrapper> </PageContentWrapper>
); );
}; };

View File

@@ -10,24 +10,19 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationNewEmail } from "@/modules/email"; import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { import {
AuthenticationError, TUserPersonalInfoUpdateInput,
AuthorizationError, TUserUpdateInput,
OperationNotAllowedError, ZUserPersonalInfoUpdateInput,
TooManyRequestsError, } from "@formbricks/types/user";
} from "@formbricks/types/errors";
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
const limiter = rateLimit({
interval: 60 * 60, // 1 hour
allowedPerInterval: 3, // max 3 calls for email verification per hour
});
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput { function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return { return {
@@ -41,18 +36,15 @@ async function handleEmailUpdate({
parsedInput, parsedInput,
payload, payload,
}: { }: {
ctx: any; ctx: AuthenticatedActionClientCtx;
parsedInput: any; parsedInput: TUserPersonalInfoUpdateInput;
payload: TUserUpdateInput; payload: TUserUpdateInput;
}) { }) {
const inputEmail = parsedInput.email?.trim().toLowerCase(); const inputEmail = parsedInput.email?.trim().toLowerCase();
if (!inputEmail || ctx.user.email === inputEmail) return payload; if (!inputEmail || ctx.user.email === inputEmail) return payload;
try { await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
if (ctx.user.identityProvider !== "email") { if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users."); throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
} }
@@ -75,41 +67,35 @@ async function handleEmailUpdate({
return payload; return payload;
} }
export const updateUserAction = authenticatedActionClient export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
.schema( withAuditLogging(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({ "updated",
password: ZUserPassword.optional(), "user",
}) async ({
) ctx,
.action( parsedInput,
withAuditLogging( }: {
"updated", ctx: AuthenticatedActionClientCtx;
"user", parsedInput: TUserPersonalInfoUpdateInput;
async ({ }) => {
ctx, const oldObject = await getUser(ctx.user.id);
parsedInput, let payload = buildUserUpdatePayload(parsedInput);
}: { payload = await handleEmailUpdate({ ctx, parsedInput, payload });
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make // Only proceed with updateUser if we have actual changes to make
let newObject = oldObject; let newObject = oldObject;
if (Object.keys(payload).length > 0) { if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload); newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
} }
)
); ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
}
)
);
const ZUpdateAvatarAction = z.object({ const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(), avatarUrl: z.string(),
@@ -162,3 +148,21 @@ export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatar
} }
) )
); );
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging(
"passwordReset",
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
}
)
);

View File

@@ -20,7 +20,7 @@ const mockUser = {
email: "test@example.com", email: "test@example.com",
notificationSettings: { notificationSettings: {
alert: {}, alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [], unsubscribedOrganizationIds: [],
}, },
twoFactorEnabled: false, twoFactorEnabled: false,

View File

@@ -15,7 +15,7 @@ const mockUser = {
id: "user1", id: "user1",
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] }, notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),

View File

@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { updateUserAction } from "../actions"; import { resetPasswordAction, updateUserAction } from "../actions";
import { EditProfileDetailsForm } from "./EditProfileDetailsForm"; import { EditProfileDetailsForm } from "./EditProfileDetailsForm";
const mockUser = { const mockUser = {
@@ -13,7 +13,7 @@ const mockUser = {
locale: "en-US", locale: "en-US",
notificationSettings: { notificationSettings: {
alert: {}, alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [], unsubscribedOrganizationIds: [],
}, },
twoFactorEnabled: false, twoFactorEnabled: false,
@@ -24,6 +24,8 @@ const mockUser = {
objective: "other", objective: "other",
} as unknown as TUser; } as unknown as TUser;
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
// Mock window.location.reload // Mock window.location.reload
const originalLocation = window.location; const originalLocation = window.location;
beforeEach(() => { beforeEach(() => {
@@ -35,6 +37,11 @@ beforeEach(() => {
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({ vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
updateUserAction: vi.fn(), updateUserAction: vi.fn(),
resetPasswordAction: vi.fn(),
}));
vi.mock("@/modules/auth/forgot-password/actions", () => ({
forgotPasswordAction: vi.fn(),
})); }));
afterEach(() => { afterEach(() => {
@@ -50,7 +57,13 @@ describe("EditProfileDetailsForm", () => {
test("renders with initial user data and updates successfully", async () => { test("renders with initial user data and updates successfully", async () => {
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any); vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />); render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={true}
isPasswordResetEnabled={false}
/>
);
const nameInput = screen.getByPlaceholderText("common.full_name"); const nameInput = screen.getByPlaceholderText("common.full_name");
expect(nameInput).toHaveValue(mockUser.name); expect(nameInput).toHaveValue(mockUser.name);
@@ -91,7 +104,13 @@ describe("EditProfileDetailsForm", () => {
const errorMessage = "Update failed"; const errorMessage = "Update failed";
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage)); vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />); render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={false}
/>
);
const nameInput = screen.getByPlaceholderText("common.full_name"); const nameInput = screen.getByPlaceholderText("common.full_name");
await userEvent.clear(nameInput); await userEvent.clear(nameInput);
@@ -109,7 +128,13 @@ describe("EditProfileDetailsForm", () => {
}); });
test("update button is disabled initially and enables on change", async () => { test("update button is disabled initially and enables on change", async () => {
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />); render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={false}
/>
);
const updateButton = screen.getByText("common.update"); const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled(); expect(updateButton).toBeDisabled();
@@ -117,4 +142,68 @@ describe("EditProfileDetailsForm", () => {
await userEvent.type(nameInput, " updated"); await userEvent.type(nameInput, " updated");
expect(updateButton).toBeEnabled(); expect(updateButton).toBeEnabled();
}); });
test("reset password button works", async () => {
vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } });
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
await waitFor(() => {
expect(resetPasswordAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading");
});
});
test("reset password button handles error correctly", async () => {
const errorMessage = "Reset failed";
vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage });
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
await waitFor(() => {
expect(resetPasswordAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(errorMessage);
});
});
test("reset password button shows loading state", async () => {
vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
expect(resetButton).toBeDisabled();
});
}); });

View File

@@ -14,6 +14,7 @@ import {
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
@@ -22,7 +23,7 @@ import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { z } from "zod"; import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { updateUserAction } from "../actions"; import { resetPasswordAction, updateUserAction } from "../actions";
// Schema & types // Schema & types
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
@@ -30,13 +31,17 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email:
}); });
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>; type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
interface IEditProfileDetailsFormProps {
user: TUser;
isPasswordResetEnabled?: boolean;
emailVerificationDisabled: boolean;
}
export const EditProfileDetailsForm = ({ export const EditProfileDetailsForm = ({
user, user,
isPasswordResetEnabled,
emailVerificationDisabled, emailVerificationDisabled,
}: { }: IEditProfileDetailsFormProps) => {
user: TUser;
emailVerificationDisabled: boolean;
}) => {
const { t } = useTranslate(); const { t } = useTranslate();
const form = useForm<TEditProfileNameForm>({ const form = useForm<TEditProfileNameForm>({
@@ -50,6 +55,8 @@ export const EditProfileDetailsForm = ({
}); });
const { isSubmitting, isDirty } = form.formState; const { isSubmitting, isDirty } = form.formState;
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
@@ -90,6 +97,7 @@ export const EditProfileDetailsForm = ({
redirectUrl: "/email-change-without-verification-success", redirectUrl: "/email-change-without-verification-success",
redirect: true, redirect: true,
callbackUrl: "/email-change-without-verification-success", callbackUrl: "/email-change-without-verification-success",
clearEnvironmentId: true,
}); });
return; return;
} }
@@ -121,6 +129,28 @@ export const EditProfileDetailsForm = ({
} }
}; };
const handleResetPassword = async () => {
setIsResettingPassword(true);
const result = await resetPasswordAction();
if (result?.data) {
toast.success(t("auth.forgot-password.email-sent.heading"));
await signOutWithAudit({
reason: "password_reset",
redirectUrl: "/auth/login",
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
setIsResettingPassword(false);
};
return ( return (
<> <>
<FormProvider {...form}> <FormProvider {...form}>
@@ -205,6 +235,26 @@ export const EditProfileDetailsForm = ({
)} )}
/> />
{isPasswordResetEnabled && (
<div className="mt-4 space-y-2">
<Label htmlFor="reset-password">{t("auth.forgot-password.reset_password")}</Label>
<p className="mt-1 text-sm text-slate-500">
{t("auth.forgot-password.reset_password_description")}
</p>
<div className="flex items-center justify-between gap-2">
<Input type="email" id="reset-password" defaultValue={user.email} disabled />
<Button
onClick={handleResetPassword}
loading={isResettingPassword}
disabled={isResettingPassword}
size="default"
variant="secondary">
{t("auth.forgot-password.reset_password")}
</Button>
</div>
</div>
)}
<Button <Button
type="submit" type="submit"
className="mt-4" className="mt-4"

View File

@@ -4,18 +4,27 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { PasswordConfirmationModal } from "./password-confirmation-modal"; import { PasswordConfirmationModal } from "./password-confirmation-modal";
// Mock the Modal component // Mock the Dialog component
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ children, open, setOpen, title }: any) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? ( open ? (
<div data-testid="modal"> <div data-testid="dialog" role="dialog">
<div data-testid="modal-title">{title}</div>
{children} {children}
<button data-testid="modal-close" onClick={() => setOpen(false)}> <button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close Close
</button> </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>,
})); }));
// Mock the PasswordInput component // Mock the PasswordInput component
@@ -54,13 +63,13 @@ describe("PasswordConfirmationModal", () => {
test("renders nothing when open is false", () => { test("renders nothing when open is false", () => {
render(<PasswordConfirmationModal {...defaultProps} open={false} />); render(<PasswordConfirmationModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("renders modal content when open is true", () => { test("renders dialog content when open is true", () => {
render(<PasswordConfirmationModal {...defaultProps} />); render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("modal-title")).toBeInTheDocument(); expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
}); });
test("displays old and new email addresses", () => { test("displays old and new email addresses", () => {

View File

@@ -1,8 +1,16 @@
"use client"; "use client";
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 { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Modal } from "@/modules/ui/components/modal";
import { PasswordInput } from "@/modules/ui/components/password-input"; import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
@@ -54,64 +62,69 @@ export const PasswordConfirmationModal = ({
}; };
return ( return (
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}> <Dialog open={open} onOpenChange={setOpen}>
<FormProvider {...form}> <DialogContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <DialogHeader>
<p className="text-muted-foreground text-sm"> <DialogTitle>{t("auth.forgot-password.reset.confirm_password")}</DialogTitle>
{t("auth.email-change.confirm_password_description")} <DialogDescription>{t("auth.email-change.confirm_password_description")}</DialogDescription>
</p> </DialogHeader>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<DialogBody>
<div className="space-y-4">
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4"> <FormField
<p> control={form.control}
<strong>{t("auth.email-change.old_email")}:</strong> name="password"
<br /> {oldEmail.toLowerCase()} render={({ field, fieldState: { error } }) => (
</p> <FormItem className="w-full">
<p> <FormControl>
<strong>{t("auth.email-change.new_email")}:</strong> <div>
<br /> {newEmail.toLowerCase()} <PasswordInput
</p> id="password"
</div> autoComplete="current-password"
placeholder="*******"
<FormField aria-placeholder="password"
control={form.control} aria-label="password"
name="password" aria-required="true"
render={({ field, fieldState: { error } }) => ( required
<FormItem className="w-full"> className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
<FormControl> value={field.value}
<div> onChange={(password) => field.onChange(password)}
<PasswordInput />
id="password" {error?.message && <FormError className="text-left">{error.message}</FormError>}
autoComplete="current-password" </div>
placeholder="*******" </FormControl>
aria-placeholder="password" </FormItem>
aria-label="password" )}
aria-required="true" />
required </div>
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm" </DialogBody>
value={field.value} <DialogFooter>
onChange={(password) => field.onChange(password)} <Button type="button" variant="secondary" onClick={handleCancel}>
/> {t("common.cancel")}
{error?.message && <FormError className="text-left">{error.message}</FormError>} </Button>
</div> <Button
</FormControl> type="submit"
</FormItem> variant="default"
)} loading={isSubmitting}
/> disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
<div className="mt-4 space-x-2 text-right"> </Button>
<Button type="button" variant="secondary" onClick={handleCancel}> </DialogFooter>
{t("common.cancel")} </form>
</Button> </FormProvider>
<Button </DialogContent>
type="submit" </Dialog>
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</div>
</form>
</FormProvider>
</Modal>
); );
}; };

View File

@@ -12,7 +12,8 @@ import Page from "./page";
// Mock services and utils // Mock services and utils
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true, IS_FORMBRICKS_CLOUD: 1,
PASSWORD_RESET_DISABLED: 1,
EMAIL_VERIFICATION_DISABLED: true, EMAIL_VERIFICATION_DISABLED: true,
})); }));
vi.mock("@/lib/organization/service", () => ({ vi.mock("@/lib/organization/service", () => ({
@@ -75,7 +76,7 @@ const mockUser = {
imageUrl: "http://example.com/avatar.png", imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] }, notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
role: "project_manager", role: "project_manager",

View File

@@ -1,6 +1,6 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -32,6 +32,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
return ( return (
<PageContentWrapper> <PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")}> <PageHeader pageTitle={t("common.account_settings")}>
@@ -42,7 +44,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
<SettingsCard <SettingsCard
title={t("environments.settings.profile.personal_information")} title={t("environments.settings.profile.personal_information")}
description={t("environments.settings.profile.update_personal_info")}> description={t("environments.settings.profile.update_personal_info")}>
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} /> <EditProfileDetailsForm
user={user}
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
isPasswordResetEnabled={isPasswordResetEnabled}
/>
</SettingsCard> </SettingsCard>
<SettingsCard <SettingsCard
title={t("common.avatar")} title={t("common.avatar")}

View File

@@ -129,7 +129,7 @@ const mockUser = {
imageUrl: "", imageUrl: "",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {} }, notificationSettings: { alert: {} },
role: "project_manager", role: "project_manager",
objective: "other", objective: "other",
} as unknown as TUser; } as unknown as TUser;

View File

@@ -30,10 +30,16 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user", SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password", SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1, AUDIT_LOG_ENABLED: 1,
})); }));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("TeamsPage re-export", () => { describe("TeamsPage re-export", () => {
test("should re-export TeamsPage component", () => { test("should re-export TeamsPage component", () => {
expect(Page).toBe(TeamsPage); expect(Page).toBe(TeamsPage);

View File

@@ -2,6 +2,7 @@
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { H3, Small } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
export const SettingsCard = ({ export const SettingsCard = ({
@@ -31,7 +32,7 @@ export const SettingsCard = ({
id={title}> id={title}>
<div className="border-b border-slate-200 px-4 pb-4"> <div className="border-b border-slate-200 px-4 pb-4">
<div className="flex"> <div className="flex">
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3> <H3 className="capitalize">{title}</H3>
<div className="ml-2"> <div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />} {beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && ( {soon && (
@@ -39,7 +40,9 @@ export const SettingsCard = ({
)} )}
</div> </div>
</div> </div>
<p className="mt-1 text-sm text-slate-500">{description}</p> <Small color="muted" margin="headerDescription">
{description}
</Small>
</div> </div>
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div> <div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
</div> </div>

View File

@@ -45,14 +45,19 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user", SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password", SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url", REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"); vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"); vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
vi.mock("@/app/lib/surveys/surveys"); vi.mock("@/app/lib/surveys/surveys");
vi.mock("@/app/share/[sharingKey]/actions");
vi.mock("@/modules/ui/components/secondary-navigation", () => ({ vi.mock("@/modules/ui/components/secondary-navigation", () => ({
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />), SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
})); }));
@@ -106,7 +111,6 @@ const mockSurvey = {
surveyClosedMessage: null, surveyClosedMessage: null,
welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"],
segment: null, segment: null,
resultShareKey: null,
closeOnDate: null, closeOnDate: null,
delay: 0, delay: 0,
autoComplete: null, autoComplete: null,
@@ -165,22 +169,6 @@ describe("SurveyAnalysisNavigation", () => {
); );
}); });
test("renders navigation correctly for sharing page", () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
);
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
mockGetFormattedFilters.mockReturnValue([] as any);
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(MockSecondaryNavigation).toHaveBeenCalled();
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
});
test("displays correct response count string in label for various scenarios", async () => { test("displays correct response count string in label for various scenarios", async () => {
mockUsePathname.mockReturnValue( mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses` `/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`

View File

@@ -4,7 +4,7 @@ import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { InboxIcon, PresentationIcon } from "lucide-react"; import { InboxIcon, PresentationIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyAnalysisNavigationProps { interface SurveyAnalysisNavigationProps {
@@ -20,11 +20,8 @@ export const SurveyAnalysisNavigation = ({
}: SurveyAnalysisNavigationProps) => { }: SurveyAnalysisNavigationProps) => {
const pathname = usePathname(); const pathname = usePathname();
const { t } = useTranslate(); const { t } = useTranslate();
const params = useParams();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`; const url = `/environments/${environmentId}/surveys/${survey.id}`;
const navigation = [ const navigation = [
{ {

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