Compare commits

...

51 Commits

Author SHA1 Message Date
Matti Nannt
0cb3881cd0 fix: return weekly-summary cron early when SMTP not configured (#6344) 2025-07-31 22:03:35 +02:00
Piyush Gupta
ec78038caa fix: survey preview not working for suid enabled link surveys backport (#6259) 2025-07-18 14:11:14 +02:00
Anshuman Pandey
908614b4e2 fix: backports removal of suid UI from survey editor (#6258) 2025-07-18 15:45:12 +05:30
pandeymangg
c584901337 removes the suid UI from the survey editor 2025-07-18 14:34:42 +05:30
Anshuman Pandey
087699fb57 fix: backports read only survey url change (#6256) 2025-07-18 10:38:54 +02: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
460 changed files with 27111 additions and 11999 deletions

View File

@@ -189,7 +189,6 @@ ENTERPRISE_LICENSE_KEY=
UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
@@ -210,6 +209,8 @@ UNKEY_ROOT_KEY=
# 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.
# 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)
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
@@ -217,7 +218,7 @@ UNKEY_ROOT_KEY=
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
# Audit logs options. Default 0.
# AUDIT_LOG_ENABLED=0
# If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0

View File

@@ -0,0 +1,121 @@
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
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: production
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

@@ -89,6 +89,7 @@ jobs:
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env
echo "E2E_TESTING=1" >> .env
shell: bash
@@ -102,6 +103,12 @@ jobs:
# pnpm prisma migrate deploy
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
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)

View File

@@ -32,3 +32,25 @@ jobs:
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod"
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 }}

View File

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

View File

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

View File

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

View File

@@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
## 🛠 Crafting Pull Requests
Ready to dive into the code and make a real impact? Here's your path:
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!
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.
## 🚀 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.
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

View File

@@ -14,10 +14,9 @@ const config: StorybookConfig = {
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("@storybook/addon-a11y"),
getAbsolutePath("@storybook/addon-docs"),
],
framework: {
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 { 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 = {
parameters: {
@@ -10,6 +26,7 @@ const preview: Preview = {
},
},
},
decorators: [withTolgee],
};
export default preview;

View File

@@ -14,23 +14,19 @@
"eslint-plugin-react-refresh": "0.4.20"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.12",
"@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",
"@chromatic-com/storybook": "^4.0.1",
"@storybook/addon-a11y": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/react-vite": "9.0.15",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "0.12.0",
"eslint-plugin-storybook": "9.0.15",
"prop-types": "15.8.1",
"storybook": "8.6.12",
"vite": "6.3.5"
"storybook": "9.0.15",
"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 AddonLibrary from "./assets/addon-library.png";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 (
<>
<ModalWithTabs
@@ -67,7 +77,7 @@ export const ActionDetailModal = ({
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={actionClass.description || ""}
description={typeDescription()}
/>
</>
);

View File

@@ -210,14 +210,13 @@ export const ActionSettingsTab = ({
)}
</div>
<div className="flex justify-between border-t border-slate-200 py-6">
<div>
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div className="flex items-center gap-x-2">
{!isReadOnly ? (
<Button
type="button"
variant="destructive"
onClick={() => setOpenDeleteDialog(true)}
className="mr-3"
id="deleteActionModalTrigger">
<TrashIcon />
{t("common.delete")}

View File

@@ -22,14 +22,29 @@ vi.mock("@/modules/ui/components/button", () => ({
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, ...props }: any) =>
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="modal" {...props}>
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
<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, 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", () => ({
@@ -70,17 +85,21 @@ describe("AddActionModal", () => {
);
expect(screen.getByRole("button", { name: "common.add_action" })).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(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("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.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
expect(
@@ -108,35 +127,35 @@ describe("AddActionModal", () => {
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(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked Modal's close button
const closeModalButton = screen.getByText("Close Modal");
await userEvent.click(closeModalButton);
// Simulate closing via the mocked Dialog's close button
const closeDialogButton = screen.getByText("Close Dialog");
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(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked CreateNewActionTab's button
const closeFromTabButton = screen.getByText("Close from Tab");
await userEvent.click(closeFromTabButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
});

View File

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

View File

@@ -101,6 +101,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
isPendingDowngrade={isPendingDowngrade ?? false}
active={active}
environmentId={environment.id}
locale={user.locale}
/>
<div className="flex h-full">

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,45 @@
"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;
}
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,
}),
[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>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen }) =>
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="modal">
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
<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("@/modules/ui/components/alert", () => ({
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 { Button } from "@/modules/ui/components/button";
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 { Modal } from "@/modules/ui/components/modal";
import {
Select,
SelectContent,
@@ -19,11 +27,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import { TFnType, useTranslate } from "@tolgee/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
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 { TIntegrationItem } from "@formbricks/types/integration";
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 = ({
open,
setOpenWithStates,
@@ -210,182 +292,148 @@ export const AddIntegrationModal = ({
};
return (
<Modal open={open} setOpen={handleClose} noPadding>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent className="overflow-visible md:overflow-visible">
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" />
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={AirtableLogo}
alt={t("environments.integrations.airtable.airtable_logo")}
/>
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.airtable.link_airtable_table")}
</div>
<div className="text-sm text-slate-500">
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.airtable.sync_responses_with_airtable")}
</div>
</DialogDescription>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitHandler)}>
<div className="flex rounded-lg p-6">
<div className="flex w-full flex-col gap-y-4 pt-5">
{airtableArray.length ? (
<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
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
<DialogBody className="overflow-visible">
<div className="flex w-full flex-col gap-y-4">
{airtableArray.length ? (
<BaseSelectDropdown
control={control}
name="table"
render={({ field }) => (
<Select
required
disabled={!tables.length}
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>
)}
isLoading={isLoading}
fetchTable={fetchTable}
airtableArray={airtableArray}
setValue={setValue}
defaultValue={defaultData?.base}
/>
</div>
</div>
) : (
<NoBaseFoundError />
)}
{surveys.length ? (
<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">
<Controller
control={control}
name="survey"
name="table"
render={({ field }) => (
<Select
required
disabled={!tables.length}
onValueChange={(val) => {
field.onChange(val);
setValue("questions", []);
}}
defaultValue={defaultData?.survey}>
defaultValue={defaultData?.table}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{surveys.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
)}
/>
</div>
</div>
) : null}
{!surveys.length ? (
<p className="m-1 text-xs text-slate-500">
{t("environments.integrations.create_survey_warning")}
</p>
) : null}
{survey && selectedSurvey && (
<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>
{surveys.length ? (
<div className="flex w-full flex-col">
<Label htmlFor="survey">{t("common.select_survey")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="survey"
render={({ field }) => (
<Select
required
onValueChange={(val) => {
field.onChange(val);
setValue("questions", []);
}}
defaultValue={defaultData?.survey}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{surveys.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</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}>
{t("common.cancel")}
</Button>
<p className="m-1 text-xs text-slate-500">
{t("environments.integrations.create_survey_warning")}
</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>
</form>
</Modal>
</DialogBody>
<DialogFooter>
{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}>
{t("common.cancel")}
</Button>
)}
<Button type="submit">{t("common.save")}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

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

View File

@@ -88,9 +88,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
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", () => ({
// eslint-disable-next-line @next/next/no-img-element
@@ -304,10 +319,9 @@ describe("AddIntegrationModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -332,10 +346,9 @@ describe("AddIntegrationModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
// Use getByPlaceholderText for the input
expect(
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 { Button } from "@/modules/ui/components/button";
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 { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import Image from "next/image";
import { useEffect, useState } from "react";
@@ -202,31 +210,28 @@ export const AddIntegrationModal = ({
};
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image
className="w-12"
src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/>
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.google_sheets.link_google_sheet")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</div>
</div>
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</DialogDescription>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkSheet)}>
<div className="flex justify-between rounded-lg p-6">
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
</div>
)}
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{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")}
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</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>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};

View File

@@ -74,13 +74,41 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
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", () => ({
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", () => ({
// eslint-disable-next-line @next/next/no-img-element
@@ -334,7 +362,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.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
@@ -359,7 +387,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-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
@@ -381,7 +409,7 @@ describe("AddIntegrationModal (Notion)", () => {
expect(columnDropdowns[1]).toHaveValue("p2");
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();
@@ -445,8 +473,8 @@ describe("AddIntegrationModal (Notion)", () => {
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
await userEvent.click(xButton);
const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button
await userEvent.click(trashButton);
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 { getQuestionTypes } from "@/modules/survey/lib/questions";
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 { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { PlusIcon, XIcon } from "lucide-react";
import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -336,9 +344,9 @@ export const AddIntegrationModal = ({
col={mapping[idx].column}
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="w-[340px] max-w-full">
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")}
items={filteredQuestionItems}
@@ -384,7 +392,7 @@ export const AddIntegrationModal = ({
/>
</div>
<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
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()}
@@ -430,53 +438,45 @@ export const AddIntegrationModal = ({
/>
</div>
</div>
<button
type="button"
className={`rounded-md p-1 hover:bg-slate-300 ${
idx === mapping.length - 1 ? "visible" : "invisible"
}`}
onClick={addRow}>
<PlusIcon className="h-5 w-5 font-bold text-slate-500" />
</button>
<button
type="button"
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 className="flex space-x-2">
{mapping.length > 1 && (
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
<TrashIcon />
</Button>
)}
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
<PlusIcon />
</Button>
</div>
</div>
</div>
);
};
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg">
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image
className="w-12"
src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")}
/>
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.notion.link_notion_database")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<div className="mb-4 flex items-start space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.notion.notion_integration_description")}
</DialogDescription>
</div>
</div>
</div>
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
<div className="flex justify-between rounded-lg p-6">
</DialogHeader>
<form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4">
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
<Label>
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
</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) => (
<MappingRow idx={idx} key={idx} />
))}
@@ -530,43 +530,40 @@ export const AddIntegrationModal = ({
)}
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{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>
)}
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.notion.link_database")}
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</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>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};

View File

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

View File

@@ -83,9 +83,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
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", () => ({
// 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.selected_questions") return "Selected questions";
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.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
@@ -312,10 +329,9 @@ describe("AddChannelMappingModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
@@ -339,10 +355,9 @@ describe("AddChannelMappingModal", () => {
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
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 { Button } from "@/modules/ui/components/button";
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 { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { CircleHelpIcon } from "lucide-react";
import Image from "next/image";
@@ -189,24 +197,28 @@ export const AddChannelMappingModal = ({
);
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={SlackLogo} alt="Slack logo" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.slack.link_slack_channel")}
</div>
</div>
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={SlackLogo}
alt={t("environments.integrations.slack.slack_logo")}
/>
</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>
<form onSubmit={handleSubmit(linkChannel)}>
<div className="flex justify-between rounded-lg p-6">
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(linkChannel)}>
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -289,31 +301,29 @@ export const AddChannelMappingModal = ({
</div>
)}
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
{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")}
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
{t("common.delete")}
</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>
</div>
</Modal>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,3 +1,4 @@
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
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 { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
@@ -13,12 +15,20 @@ import EnvLayout from "./layout";
// Mock sub-components to render identifiable elements
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", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
<div data-testid="EnvironmentIdBaseLayout">
{environmentId}
EnvironmentIdBaseLayout: ({ children, environmentId, session, user, organization }: any) => (
<div
data-testid="EnvironmentIdBaseLayout"
data-environment-id={environmentId}
data-session={session?.user?.id}
data-user={user?.id}
data-organization={organization?.id}>
{children}
</div>
),
@@ -27,7 +37,24 @@ vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="ToasterClient" />,
}));
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
@@ -37,26 +64,43 @@ vi.mock("@/modules/environments/lib/utils", () => ({
vi.mock("@/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
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(() => {
cleanup();
vi.clearAllMocks();
});
test("renders successfully when all dependencies return valid data", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
@@ -64,56 +108,43 @@ describe("EnvLayout", () => {
});
render(result);
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
// Verify main layout structure
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toBeInTheDocument();
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");
// 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({
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(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,
t: mockTranslation,
session: null as unknown as Session,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
@@ -125,18 +156,16 @@ describe("EnvLayout", () => {
children: <div>Content</div>,
})
).rejects.toThrow("Redirect called");
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
t: mockTranslation,
session: mockSession,
user: null as unknown as TUser,
organization: mockOrganization,
});
await expect(
@@ -145,5 +174,154 @@ describe("EnvLayout", () => {
children: <div>Content</div>,
})
).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 { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
@@ -11,7 +13,6 @@ const EnvLayout = async (props: {
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
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"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
const [project, environment] = await Promise.all([
getProjectByEnvironmentId(params.environmentId),
getEnvironment(params.environmentId),
]);
if (!project) {
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);
if (!membership) {
@@ -42,9 +51,11 @@ const EnvLayout = async (props: {
user={user}
organization={organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
<EnvironmentContextWrapper environment={environment} project={project}>
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
</EnvironmentContextWrapper>
</EnvironmentIdBaseLayout>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -169,7 +169,7 @@ export const resetPasswordAction = authenticatedActionClient.action(
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await sendForgotPasswordEmail(ctx.user);

View File

@@ -145,7 +145,7 @@ export const EditProfileDetailsForm = ({
});
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(t(errorMessage));
toast.error(errorMessage);
}
setIsResettingPassword(false);

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1,
}));

View File

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

View File

@@ -45,7 +45,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -26,8 +26,26 @@ vi.mock("@/modules/ui/components/button", () => ({
)),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, hideCloseButton, width, className }) => (
<div
data-testid="dialog-content"
data-hide-close-button={hideCloseButton}
data-width={width}
className={className}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
DialogFooter: vi.fn(({ children }) => <div data-testid="dialog-footer">{children}</div>),
}));
const mockResponses = [
@@ -163,12 +181,12 @@ describe("ResponseCardModal", () => {
test("should not render if selectedResponseId is null", () => {
const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />);
expect(container.firstChild).toBeNull();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("should render the modal when a response is selected", () => {
test("should render the dialog when a response is selected", () => {
render(<ResponseCardModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("single-response-card")).toBeInTheDocument();
});
@@ -204,14 +222,6 @@ describe("ResponseCardModal", () => {
expect(nextButton).toBeDisabled();
});
test("should call setSelectedResponseId with null when close button is clicked", async () => {
render(<ResponseCardModal {...defaultProps} />);
const buttons = screen.getAllByTestId("mock-button");
const closeButton = buttons.find((button) => button.querySelector("svg.lucide-x"));
if (closeButton) await userEvent.click(closeButton);
expect(mockSetSelectedResponseId).toHaveBeenCalledWith(null);
});
test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => {
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />);
expect(mockSetOpen).toHaveBeenCalledWith(true);
@@ -229,11 +239,10 @@ describe("ResponseCardModal", () => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("should render ChevronLeft, ChevronRight, and XIcon", () => {
test("should render ChevronLeft and ChevronRight icons", () => {
render(<ResponseCardModal {...defaultProps} />);
expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument();
expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument();
expect(document.querySelector(".lucide-x")).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react";
import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
@@ -64,42 +64,20 @@ export const ResponseCardModal = ({
}
};
const handleClose = () => {
setSelectedResponseId(null);
const handleClose = (open: boolean) => {
setOpen(open);
if (!open) {
setSelectedResponseId(null);
}
};
// If no response is selected or currentIndex is null, do not render the modal
if (selectedResponseId === null || currentIndex === null) return null;
return (
<Modal
hideCloseButton
open={open}
setOpen={setOpen}
size="xxl"
className="max-h-[80vh] overflow-auto"
noPadding>
<div className="h-full rounded-lg">
<div className="relative h-full w-full overflow-auto p-4">
<div className="mb-4 flex items-center justify-end space-x-2">
<Button
onClick={handleBack}
disabled={currentIndex === 0}
variant="ghost"
className="border bg-white p-2">
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="ghost"
className="border bg-white p-2">
<ChevronRight className="h-5 w-5" />
</Button>
<Button className="border bg-white p-2" onClick={handleClose} variant="ghost">
<XIcon className="h-5 w-5" />
</Button>
</div>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent width="wide">
<DialogBody>
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
@@ -113,8 +91,20 @@ export const ResponseCardModal = ({
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
</div>
</div>
</Modal>
</DialogBody>
<DialogFooter>
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
<ChevronLeft />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="outline"
size="icon">
<ChevronRight />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,13 +1,15 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -33,6 +35,9 @@ const Page = async (props) => {
const tags = await getTagsByEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
@@ -51,6 +56,9 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />

View File

@@ -1,18 +1,23 @@
"use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { WEBAPP_URL } from "@/lib/constants";
import { putFile } from "@/lib/storage/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
@@ -222,3 +227,128 @@ export const getEmailHtmlAction = authenticatedActionClient
return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
});
const ZGeneratePersonalLinksAction = z.object({
surveyId: ZId,
segmentId: ZId,
environmentId: ZId,
expirationDays: z.number().optional(),
});
export const generatePersonalLinksAction = authenticatedActionClient
.schema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
// Get contacts and generate personal links
const contactsResult = await generatePersonalLinks(
parsedInput.surveyId,
parsedInput.segmentId,
parsedInput.expirationDays
);
if (!contactsResult || contactsResult.length === 0) {
throw new UnknownError("No contacts found for the selected segment");
}
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
"User ID",
"First Name",
"Last Name",
"Email",
"Personal Link",
];
const csvData = contactsResult
.map((contact) => {
if (!contact) {
return null;
}
const attributes = contact.attributes ?? {};
return {
"Formbricks Contact ID": contact.contactId,
"User ID": attributes.userId ?? "",
"First Name": attributes.firstName ?? "",
"Last Name": attributes.lastName ?? "",
Email: attributes.email ?? "",
"Personal Link": contact.surveyUrl,
};
})
.filter((contact) => contact !== null);
// Convert to CSV using the file conversion utility
const csvContent = await convertToCsv(csvHeaders, csvData);
const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`;
// Store file temporarily and return download URL
const fileBuffer = Buffer.from(csvContent);
await putFile(fileName, fileBuffer, "private", parsedInput.environmentId);
const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`;
return {
downloadUrl,
fileName,
count: csvData.length,
};
});
const ZUpdateSingleUseLinksAction = z.object({
surveyId: ZId,
environmentId: ZId,
isSingleUse: z.boolean(),
isSingleUseEncryption: z.boolean(),
});
export const updateSingleUseLinksAction = authenticatedActionClient
.schema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const updatedSurvey = await updateSurvey({
...survey,
singleUse: { enabled: parsedInput.isSingleUse, isEncrypted: parsedInput.isSingleUseEncryption },
});
return updatedSurvey;
});

View File

@@ -1,341 +0,0 @@
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LucideIcon } from "lucide-react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
TSurvey,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveySingleUse,
} from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
// Mock data
const mockSurveyWeb = {
id: "survey1",
name: "Web Survey",
environmentId: "env1",
type: "app",
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: true,
} as unknown as TSurveyQuestion,
],
displayOption: "displayOnce",
recontactDays: 0,
autoClose: null,
delay: 0,
autoComplete: null,
runOnDate: null,
closeOnDate: null,
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
triggers: [],
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
styling: null,
} as unknown as TSurvey;
vi.mock("@/lib/constants", () => ({
INTERCOM_SECRET_KEY: "test-secret-key",
IS_INTERCOM_CONFIGURED: true,
INTERCOM_APP_ID: "test-app-id",
ENCRYPTION_KEY: "test-encryption-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
GITHUB_ID: "test-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
IS_FORMBRICKS_CLOUD: false,
}));
const mockSurveyLink = {
...mockSurveyWeb,
id: "survey2",
name: "Link Survey",
type: "link",
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
} as unknown as TSurvey;
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
role: "project_manager",
objective: "other",
createdAt: new Date(),
updatedAt: new Date(),
locale: "en-US",
} as unknown as TUser;
// Mocks
const mockRouterRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: mockRouterRefresh,
}),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (str: string) => str,
}),
}));
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: vi.fn(() => <div>ShareSurveyLinkMock</div>),
}));
vi.mock("@/modules/ui/components/badge", () => ({
Badge: vi.fn(({ text }) => <span data-testid="badge-mock">{text}</span>),
}));
const mockEmbedViewComponent = vi.fn();
vi.mock("./shareEmbedModal/EmbedView", () => ({
EmbedView: (props: any) => mockEmbedViewComponent(props),
}));
const mockPanelInfoViewComponent = vi.fn();
vi.mock("./shareEmbedModal/PanelInfoView", () => ({
PanelInfoView: (props: any) => mockPanelInfoViewComponent(props),
}));
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
vi.mock("@/modules/ui/components/dialog", async () => {
const actual = await vi.importActual<typeof import("@/modules/ui/components/dialog")>(
"@/modules/ui/components/dialog"
);
return {
...actual,
Dialog: (props: React.ComponentProps<typeof actual.Dialog>) => {
capturedDialogOnOpenChange = props.onOpenChange;
return <actual.Dialog {...props} />;
},
// DialogTitle, DialogContent, DialogDescription will be the actual components
// due to ...actual spread and no specific mock for them here.
};
});
describe("ShareEmbedSurvey", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
capturedDialogOnOpenChange = undefined;
});
const mockSetOpen = vi.fn();
const defaultProps = {
survey: mockSurveyWeb,
publicDomain: "https://public-domain.com",
open: true,
modalView: "start" as "start" | "embed" | "panel",
setOpen: mockSetOpen,
user: mockUser,
};
beforeEach(() => {
mockEmbedViewComponent.mockImplementation(
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
<div>
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
<div data-testid="embedview-activeid">{activeId}</div>
<div data-testid="embedview-survey-id">{survey.id}</div>
<div data-testid="embedview-email">{email}</div>
<div data-testid="embedview-surveyUrl">{surveyUrl}</div>
<div data-testid="embedview-publicDomain">{publicDomain}</div>
<div data-testid="embedview-locale">{locale}</div>
</div>
)
);
mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => (
<button onClick={() => handleInitialPageButton()}>PanelInfoViewMockContent</button>
));
});
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} />);
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("renders initial 'start' view correctly when open and modalView is 'start' for app survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} />);
// For app surveys, ShareSurveyLink should not be rendered
expect(screen.queryByText("ShareSurveyLinkMock")).not.toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />);
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
await userEvent.click(embedButton);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
});
test("switches to 'panel' view when 'Send to panel' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />);
const panelButton = screen.getByText("environments.surveys.summary.send_to_panel");
await userEvent.click(panelButton);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
render(<ShareEmbedSurvey {...defaultProps} open={true} survey={mockSurveyWeb} />);
expect(capturedDialogOnOpenChange).toBeDefined();
// Simulate Dialog closing
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(false);
expect(mockSetOpen).toHaveBeenCalledWith(false);
expect(mockRouterRefresh).toHaveBeenCalledTimes(1);
// Simulate Dialog opening
mockRouterRefresh.mockClear();
mockSetOpen.mockClear();
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(true);
expect(mockSetOpen).toHaveBeenCalledWith(true);
expect(mockRouterRefresh).toHaveBeenCalledTimes(1);
});
test("correctly configures for 'link' survey type in embed view", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string;
};
expect(embedViewProps.tabs.length).toBe(3);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs[0].id).toBe("link");
expect(embedViewProps.activeId).toBe("link");
});
test("correctly configures for 'web' survey type in embed view", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />);
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string;
};
expect(embedViewProps.tabs.length).toBe(1);
expect(embedViewProps.tabs[0].id).toBe("app");
expect(embedViewProps.activeId).toBe("app");
});
test("useEffect does not change activeId if survey.type changes from web to link (while in embed view)", () => {
const { rerender } = render(
<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />
);
expect(vi.mocked(mockEmbedViewComponent).mock.calls[0][0].activeId).toBe("app");
rerender(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(vi.mocked(mockEmbedViewComponent).mock.calls[1][0].activeId).toBe("app"); // Current behavior
});
test("initial showView is set by modalView prop when open is true", () => {
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
cleanup();
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("useEffect sets showView to 'start' when open becomes false", () => {
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
// To verify showView is 'start', we'd need to inspect internal state or render start view elements
// For now, we trust the useEffect sets showView, and if it were to re-open in 'start' mode, it would show.
// The main check is that the previous view ('embed') is gone.
});
test("renders correct label for link tab based on singleUse survey property", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
let embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
let linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
expect(linkTab?.label).toBe("environments.surveys.summary.share_the_link");
cleanup();
vi.mocked(mockEmbedViewComponent).mockClear();
const mockSurveyLinkSingleUse: TSurvey = {
...mockSurveyLink,
singleUse: { enabled: true, isEncrypted: true },
};
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLinkSingleUse} modalView="embed" />);
embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
expect(linkTab?.label).toBe("environments.surveys.summary.single_use_links");
});
});

View File

@@ -1,189 +0,0 @@
"use client";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Badge } from "@/modules/ui/components/badge";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import {
BellRing,
BlocksIcon,
Code2Icon,
LinkIcon,
MailIcon,
SmartphoneIcon,
UsersRound,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { EmbedView } from "./shareEmbedModal/EmbedView";
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps {
survey: TSurvey;
publicDomain: string;
open: boolean;
modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
user: TUser;
}
export const ShareEmbedSurvey = ({
survey,
publicDomain,
open,
modalView,
setOpen,
user,
}: ShareEmbedSurveyProps) => {
const router = useRouter();
const environmentId = survey.environmentId;
const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false;
const { email } = user;
const { t } = useTranslate();
const tabs = useMemo(
() =>
[
{
id: "link",
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
icon: LinkIcon,
},
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
{ id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon },
].filter((tab) => !(survey.type === "link" && tab.id === "app")),
[t, isSingleUseLinkSurvey, survey.type]
);
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
const url = await getSurveyUrl(survey, publicDomain, "default");
setSurveyUrl(url);
} catch (error) {
console.error("Failed to fetch survey URL:", error);
// Fallback to a default URL if fetching fails
setSurveyUrl(`${publicDomain}/s/${survey.id}`);
}
};
fetchSurveyUrl();
}, [survey, publicDomain]);
useEffect(() => {
if (survey.type !== "link") {
setActiveId(tabs[3].id);
}
}, [survey.type, tabs]);
useEffect(() => {
if (open) {
setShowView(modalView);
} else {
setShowView("start");
}
}, [open, modalView]);
const handleOpenChange = (open: boolean) => {
setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id);
setOpen(open);
if (!open) {
setShowView("start");
}
router.refresh();
};
const handleInitialPageButton = () => {
setShowView("start");
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
{showView === "start" ? (
<div className="flex h-full max-w-full flex-col overflow-hidden">
{survey.type === "link" && (
<div className="flex h-2/5 w-full flex-col items-center justify-center space-y-6 p-8 text-center">
<DialogTitle>
<p className="pt-2 text-xl font-semibold text-slate-800">
{t("environments.surveys.summary.your_survey_is_public")} 🎉
</p>
</DialogTitle>
<DialogDescription className="hidden" />
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
</div>
)}
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-b-lg bg-slate-50 px-8">
<p className="text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
<div className="grid grid-cols-4 gap-2">
<button
type="button"
onClick={() => setShowView("embed")}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<Code2Icon className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.embed_survey")}
</button>
<Link
href={`/environments/${environmentId}/settings/notifications`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<BellRing className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.configure_alerts")}
</Link>
<Link
href={`/environments/${environmentId}/integrations`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<BlocksIcon className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.setup_integrations")}
</Link>
<button
type="button"
onClick={() => setShowView("panel")}
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<UsersRound className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.send_to_panel")}
<Badge
size="tiny"
type="success"
className="absolute right-3 top-3"
text={t("common.new")}
/>
</button>
</div>
</div>
</div>
) : showView === "embed" ? (
<EmbedView
handleInitialPageButton={handleInitialPageButton}
tabs={survey.type === "link" ? tabs : [tabs[3]]}
disableBack={false}
activeId={activeId}
environmentId={environmentId}
setActiveId={setActiveId}
survey={survey}
email={email}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
) : showView === "panel" ? (
<PanelInfoView handleInitialPageButton={handleInitialPageButton} disableBack={false} />
) : null}
</DialogContent>
</Dialog>
);
};

View File

@@ -20,9 +20,22 @@ vi.mock("@/modules/ui/components/button", () => ({
}),
}));
// Mock Modal
vi.mock("@/modules/ui/components/modal", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
// Mock Dialog
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, ...props }) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
}));
// Mock useTranslate
@@ -120,7 +133,7 @@ describe("ShareSurveyResults", () => {
test("does not render content when modal is closed (open is false)", () => {
render(<ShareSurveyResults {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
expect(
screen.queryByText("environments.surveys.summary.survey_results_are_public")

View File

@@ -1,7 +1,7 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
import { Clipboard } from "lucide-react";
@@ -26,70 +26,72 @@ export const ShareSurveyResults = ({
}: ShareEmbedSurveyProps) => {
const { t } = useTranslate();
return (
<Modal open={open} setOpen={setOpen} size="lg">
{showPublishModal && surveyUrl ? (
<div className="flex flex-col rounded-2xl bg-white px-12 py-6">
<div className="flex flex-col items-center gap-y-6 text-center">
<CheckCircle2Icon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.survey_results_are_public")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
</p>
</div>
<div className="flex gap-2">
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span>{surveyUrl}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogBody>
{showPublishModal && surveyUrl ? (
<div className="flex flex-col items-center gap-y-6 text-center">
<CheckCircle2Icon className="text-primary h-20 w-20" />
<div>
<p className="text-primary text-lg font-medium">
{t("environments.surveys.summary.survey_results_are_public")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
</p>
</div>
<div className="flex gap-2">
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span>{surveyUrl}</span>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="secondary"
className="text-center"
onClick={() => handleUnpublish()}>
{t("environments.surveys.summary.unpublish_from_web")}
</Button>
<Button className="text-center" asChild>
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
{t("environments.surveys.summary.view_site")}
</Link>
</Button>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="secondary"
className="text-center"
onClick={() => handleUnpublish()}>
{t("environments.surveys.summary.unpublish_from_web")}
</Button>
<Button className="text-center" asChild>
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
{t("environments.surveys.summary.view_site")}
</Link>
</Button>
) : (
<div className="flex flex-col rounded-2xl bg-white p-8">
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.publish_to_web_warning")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.publish_to_web_warning_description")}
</p>
</div>
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-col rounded-2xl bg-white p-8">
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.publish_to_web_warning")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.publish_to_web_warning_description")}
</p>
</div>
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div>
)}
</Modal>
)}
</DialogBody>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,6 +1,5 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
@@ -118,13 +117,13 @@ export const SummaryMetadata = ({
)}
</span>
{!isLoading && (
<Button variant="secondary" className="h-6 w-6">
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</Button>
</div>
)}
</div>
</div>

View File

@@ -1,411 +1,437 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { SurveyAnalysisCTA } from "./SurveyAnalysisCTA";
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/utils", () => ({
withAuditLogging: vi.fn((...args: any[]) => {
// Check if the last argument is a function and return it directly
if (typeof args[args.length - 1] === "function") {
return args[args.length - 1];
}
// Otherwise, return a new function that takes a function as an argument and returns it
return (fn: any) => fn;
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
if (key === "environments.surveys.summary.configure_alerts") {
return "Configure alerts";
}
if (key === "common.preview") {
return "Preview";
}
if (key === "common.edit") {
return "Edit";
}
if (key === "environments.surveys.summary.share_survey") {
return "Share survey";
}
if (key === "environments.surveys.summary.results_are_public") {
return "Results are public";
}
if (key === "environments.surveys.survey_duplicated_successfully") {
return "Survey duplicated successfully";
}
if (key === "environments.surveys.edit.caution_edit_duplicate") {
return "Duplicate & Edit";
}
return key;
},
}),
}));
const mockPublicDomain = "https://public-domain.com";
// Mock Next.js hooks
const mockPush = vi.fn();
const mockPathname = "/environments/env-id/surveys/survey-id/summary";
const mockSearchParams = new URLSearchParams();
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
ENCRYPTION_KEY: "test",
ENTERPRISE_LICENSE_KEY: "test",
GITHUB_ID: "test",
GITHUB_SECRET: "test",
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
WEBAPP_URL: "mock-webapp-url",
IS_PRODUCTION: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
AUDIT_LOG_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-url",
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mockPush,
}),
usePathname: () => mockPathname,
useSearchParams: () => mockSearchParams,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Create a spy for refreshSingleUseId so we can override it in tests
const refreshSingleUseIdSpy = vi.fn(() => Promise.resolve("newSingleUseId"));
// Mock useSingleUseId hook
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
useSingleUseId: () => ({
refreshSingleUseId: refreshSingleUseIdSpy,
}),
}));
const mockSearchParams = new URLSearchParams();
const mockPush = vi.fn();
const mockReplace = vi.fn();
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush, replace: mockReplace }),
useSearchParams: () => mockSearchParams,
usePathname: () => "/current-path",
}));
// Mock copySurveyLink to return a predictable string
vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn((url: string, suId: string) => `${url}?suId=${suId}`),
}));
// Mock the copy survey action
const mockCopySurveyToOtherEnvironmentAction = vi.fn();
vi.mock("@/modules/survey/list/actions", () => ({
copySurveyToOtherEnvironmentAction: (args: any) => mockCopySurveyToOtherEnvironmentAction(args),
}));
// Mock getFormattedErrorMessage function
// Mock helper functions
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((response) => response?.error || "Unknown error"),
getFormattedErrorMessage: vi.fn(() => "Error message"),
}));
// Mock ResponseCountProvider dependencies
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: vi.fn(() => ({ selectedFilter: "all", dateRange: {} })),
// Mock actions
vi.mock("@/modules/survey/list/actions", () => ({
copySurveyToOtherEnvironmentAction: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({
getResponseCountAction: vi.fn(() => Promise.resolve({ data: 5 })),
// Mock child components
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage",
() => ({
SuccessMessage: ({ environment, survey }: any) => (
<div data-testid="success-message">
Success Message for {environment.id} - {survey.id}
</div>
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal",
() => ({
ShareSurveyModal: ({ survey, open, setOpen, modalView, user }: any) => (
<div data-testid="share-survey-modal" data-open={open} data-modal-view={modalView}>
Share Survey Modal for {survey.id} - User: {user.id}
<button type="button" onClick={() => setOpen(false)}>
Close Modal
</button>
</div>
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown",
() => ({
SurveyStatusDropdown: ({ environment, survey }: any) => (
<div data-testid="survey-status-dropdown">
Status Dropdown for {environment.id} - {survey.id}
</div>
),
})
);
vi.mock("@/modules/survey/components/edit-public-survey-alert-dialog", () => ({
EditPublicSurveyAlertDialog: ({
open,
setOpen,
isLoading,
primaryButtonAction,
primaryButtonText,
secondaryButtonAction,
secondaryButtonText,
}: any) => (
<div data-testid="edit-public-survey-alert-dialog" data-open={open} data-loading={isLoading}>
<button type="button" onClick={primaryButtonAction} data-testid="primary-button">
{primaryButtonText}
</button>
<button type="button" onClick={secondaryButtonAction} data-testid="secondary-button">
{secondaryButtonText}
</button>
<button type="button" onClick={() => setOpen(false)}>
Close Dialog
</button>
</div>
),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
getFormattedFilters: vi.fn(() => []),
// Mock UI components
vi.mock("@/modules/ui/components/badge", () => ({
Badge: ({ type, size, className, text }: any) => (
<div data-testid="badge" data-type={type} data-size={size} className={className}>
{text}
</div>
),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getResponseCountBySurveySharingKeyAction: vi.fn(() => Promise.resolve({ data: 5 })),
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, className }: any) => (
<button type="button" data-testid="button" onClick={onClick} className={className}>
{children}
</button>
),
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(() => mockPublicDomain),
vi.mock("@/modules/ui/components/iconbar", () => ({
IconBar: ({ actions }: any) => (
<div data-testid="icon-bar">
{actions
.filter((action: any) => action.isVisible)
.map((action: any, index: number) => (
<button
type="button"
key={index} // NOSONAR // We don't need to check this in the test
onClick={action.onClick}
title={action.tooltip}
data-testid={`icon-bar-action-${index}`}>
<action.icon />
</button>
))}
</div>
),
}));
vi.spyOn(toast, "success");
vi.spyOn(toast, "error");
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
BellRing: () => <svg data-testid="bell-ring-icon" />,
Eye: () => <svg data-testid="eye-icon" />,
SquarePenIcon: () => <svg data-testid="square-pen-icon" />,
}));
// Mock clipboard API
const writeTextMock = vi.fn().mockImplementation(() => Promise.resolve());
// Mock data
const mockEnvironment: TEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "test-project-id",
appSetupCompleted: true,
};
// Define it at the global level
Object.defineProperty(navigator, "clipboard", {
value: { writeText: writeTextMock },
configurable: true,
});
const dummySurvey = {
id: "survey123",
type: "link",
environmentId: "env123",
status: "inProgress",
resultShareKey: null,
} as unknown as TSurvey;
const dummyAppSurvey = {
id: "survey123",
const mockSurvey: TSurvey = {
id: "test-survey-id",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "app",
environmentId: "env123",
environmentId: "test-env-id",
status: "inProgress",
} as unknown as TSurvey;
displayOption: "displayOnce",
autoClose: null,
triggers: [],
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
const dummyUser = { id: "user123", name: "Test User" } as TUser;
recontactDays: null,
displayLimit: null,
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
questions: [],
endings: [],
hiddenFields: { enabled: false },
displayPercentage: null,
autoComplete: null,
segment: null,
languages: [],
showLanguageSwitch: false,
singleUse: { enabled: false, isEncrypted: false },
projectOverwrites: null,
surveyClosedMessage: null,
delay: 0,
isVerifyEmailEnabled: false,
createdBy: null,
variables: [],
followUps: [],
runOnDate: null,
closeOnDate: null,
styling: null,
pin: null,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
resultShareKey: null,
};
const mockUser: TUser = {
id: "test-user-id",
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "https://example.com/avatar.jpg",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "other",
objective: "other",
locale: "en-US",
lastLoginAt: new Date(),
isActive: true,
notificationSettings: {
alert: {
weeklySummary: true,
responseFinished: true,
},
weeklySummary: {
test: true,
},
unsubscribedOrganizationIds: [],
},
};
const mockSegments: TSegment[] = [];
const defaultProps = {
survey: mockSurvey,
environment: mockEnvironment,
isReadOnly: false,
user: mockUser,
publicDomain: "https://example.com",
responseCount: 0,
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
};
describe("SurveyAnalysisCTA", () => {
beforeEach(() => {
vi.resetAllMocks();
mockSearchParams.delete("share"); // reset params
vi.clearAllMocks();
mockSearchParams.delete("share");
});
afterEach(() => {
cleanup();
});
describe("Edit functionality", () => {
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
test("renders share survey button", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Check if dialog is shown
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
expect(dialogTitle).toBeInTheDocument();
});
test("navigates directly to edit page when response count = 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={0}
/>
);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Should navigate directly to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
});
test("doesn't show edit button when isReadOnly is true", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const editButton = screen.queryByRole("button", { name: "common.edit" });
expect(editButton).not.toBeInTheDocument();
});
expect(screen.getByText("Share survey")).toBeInTheDocument();
});
describe("Duplicate functionality", () => {
test("duplicates survey and redirects on primary button click", async () => {
mockCopySurveyToOtherEnvironmentAction.mockResolvedValue({
data: { id: "newSurvey456" },
});
test("renders success message component", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const editButton = screen.getByRole("button", { name: "common.edit" });
fireEvent.click(editButton);
const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
environmentId: "env123",
surveyId: "survey123",
targetEnvironmentId: "env123",
});
expect(mockPush).toHaveBeenCalledWith("/environments/env123/surveys/newSurvey456/edit");
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
});
});
test("shows error toast on duplication failure", async () => {
const error = { error: "Duplication failed" };
mockCopySurveyToOtherEnvironmentAction.mockResolvedValue(error);
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const editButton = screen.getByRole("button", { name: "common.edit" });
fireEvent.click(editButton);
const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Duplication failed");
});
});
expect(screen.getByTestId("success-message")).toBeInTheDocument();
});
describe("Share button and modal", () => {
test("opens share modal when 'Share survey' button is clicked", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
test("renders survey status dropdown when app setup is completed", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
const shareButton = screen.getByText("environments.surveys.summary.share_survey");
fireEvent.click(shareButton);
// The share button opens the embed modal, not a URL
// We can verify this by checking that the ShareEmbedSurvey component is rendered
// with the embed modal open
expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument();
});
test("renders ShareEmbedSurvey component when share modal is open", async () => {
mockSearchParams.set("share", "true");
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Assuming ShareEmbedSurvey renders a dialog with a specific title when open
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeInTheDocument();
});
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
});
describe("General UI and visibility", () => {
test("shows public results badge when resultShareKey is present", () => {
const surveyWithShareKey = { ...dummySurvey, resultShareKey: "someKey" } as TSurvey;
render(
<SurveyAnalysisCTA
survey={surveyWithShareKey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
test("does not render survey status dropdown when read-only", () => {
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} />);
expect(screen.getByText("environments.surveys.summary.results_are_public")).toBeInTheDocument();
});
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
});
test("shows SurveyStatusDropdown for non-draft surveys", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
test("renders icon bar with correct actions", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
expect(screen.getByTestId("icon-bar-action-0")).toBeInTheDocument(); // Bell ring
expect(screen.getByTestId("icon-bar-action-1")).toBeInTheDocument(); // Square pen
});
test("does not show SurveyStatusDropdown for draft surveys", () => {
const draftSurvey = { ...dummySurvey, status: "draft" } as TSurvey;
render(
<SurveyAnalysisCTA
survey={draftSurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
test("shows preview icon for link surveys", () => {
const linkSurvey = { ...mockSurvey, type: "link" as const };
render(<SurveyAnalysisCTA {...defaultProps} survey={linkSurvey} />);
test("hides status dropdown and edit actions when isReadOnly is true", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
});
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.edit" })).not.toBeInTheDocument();
});
test("shows public results badge when resultShareKey exists", () => {
const surveyWithShareKey = { ...mockSurvey, resultShareKey: "share-key" };
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithShareKey} />);
test("shows preview button for link surveys", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.getByRole("button", { name: "common.preview" })).toBeInTheDocument();
});
expect(screen.getByTestId("badge")).toBeInTheDocument();
expect(screen.getByText("Results are public")).toBeInTheDocument();
});
test("hides preview button for app surveys", () => {
render(
<SurveyAnalysisCTA
survey={dummyAppSurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.queryByRole("button", { name: "common.preview" })).not.toBeInTheDocument();
});
test("opens share modal when share button is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
await user.click(screen.getByText("Share survey"));
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
});
test("opens share modal when share param is true", () => {
mockSearchParams.set("share", "true");
render(<SurveyAnalysisCTA {...defaultProps} />);
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "start");
});
test("navigates to edit when edit button is clicked and no responses", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
await user.click(screen.getByTestId("icon-bar-action-1"));
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/test-survey-id/edit");
});
test("shows caution dialog when edit button is clicked and has responses", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
await user.click(screen.getByTestId("icon-bar-action-1"));
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
});
test("navigates to notifications when bell icon is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
await user.click(screen.getByTestId("icon-bar-action-0"));
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/settings/notifications");
});
test("opens preview window when preview icon is clicked", async () => {
const user = userEvent.setup();
const linkSurvey = { ...mockSurvey, type: "link" as const };
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
render(<SurveyAnalysisCTA {...defaultProps} survey={linkSurvey} />);
await user.click(screen.getByTestId("icon-bar-action-1"));
expect(windowOpenSpy).toHaveBeenCalledWith("https://example.com/s/test-survey-id?preview=true", "_blank");
windowOpenSpy.mockRestore();
});
test("does not show icon bar actions when read-only", () => {
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} />);
const iconBar = screen.getByTestId("icon-bar");
expect(iconBar).toBeInTheDocument();
// Should only show preview icon for link surveys, but this is app survey
expect(screen.queryByTestId("icon-bar-action-0")).not.toBeInTheDocument();
});
test("handles modal close correctly", async () => {
mockSearchParams.set("share", "true");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
// Verify modal is open initially
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
await user.click(screen.getByText("Close Modal"));
// Verify modal is closed
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false");
});
test("shows status dropdown for link surveys", () => {
const linkSurvey = { ...mockSurvey, type: "link" as const };
render(<SurveyAnalysisCTA {...defaultProps} survey={linkSurvey} />);
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
});
test("does not show status dropdown for draft surveys", () => {
const draftSurvey = { ...mockSurvey, status: "draft" as const };
render(<SurveyAnalysisCTA {...defaultProps} survey={draftSurvey} />);
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
});
test("does not show status dropdown when app setup is not completed", () => {
const environmentWithoutAppSetup = { ...mockEnvironment, appSetupCompleted: false };
render(<SurveyAnalysisCTA {...defaultProps} environment={environmentWithoutAppSetup} />);
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
});
test("renders correctly with all props", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
expect(screen.getByText("Share survey")).toBeInTheDocument();
expect(screen.getByTestId("success-message")).toBeInTheDocument();
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
});
});

View File

@@ -1,10 +1,11 @@
"use client";
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
@@ -12,9 +13,10 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
@@ -25,13 +27,14 @@ interface SurveyAnalysisCTAProps {
user: TUser;
publicDomain: string;
responseCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
interface ModalState {
start: boolean;
share: boolean;
embed: boolean;
panel: boolean;
dropdown: boolean;
}
export const SurveyAnalysisCTA = ({
@@ -41,40 +44,44 @@ export const SurveyAnalysisCTA = ({
user,
publicDomain,
responseCount,
segments,
isContactsEnabled,
isFormbricksCloud,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [loading, setLoading] = useState(false);
const [modalState, setModalState] = useState<ModalState>({
share: searchParams.get("share") === "true",
embed: false,
panel: false,
dropdown: false,
start: searchParams.get("share") === "true",
share: false,
});
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
useEffect(() => {
setModalState((prev) => ({
...prev,
share: searchParams.get("share") === "true",
start: searchParams.get("share") === "true",
}));
}, [searchParams]);
const handleShareModalToggle = (open: boolean) => {
const params = new URLSearchParams(window.location.search);
if (open) {
const currentShareParam = params.get("share") === "true";
if (open && !currentShareParam) {
params.set("share", "true");
} else {
router.push(`${pathname}?${params.toString()}`);
} else if (!open && currentShareParam) {
params.delete("share");
router.push(`${pathname}?${params.toString()}`);
}
router.push(`${pathname}?${params.toString()}`);
setModalState((prev) => ({ ...prev, share: open }));
setModalState((prev) => ({ ...prev, start: open }));
};
const duplicateSurveyAndRoute = async (surveyId: string) => {
@@ -95,23 +102,19 @@ export const SurveyAnalysisCTA = ({
setLoading(false);
};
const getPreviewUrl = () => {
const separator = surveyUrl.includes("?") ? "&" : "?";
return `${surveyUrl}${separator}preview=true`;
};
const getPreviewUrl = async () => {
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
const handleModalState = (modalView: keyof Omit<ModalState, "dropdown">) => {
return (open: boolean | ((prevState: boolean) => boolean)) => {
const newValue = typeof open === "function" ? open(modalState[modalView]) : open;
setModalState((prev) => ({ ...prev, [modalView]: newValue }));
};
};
if (survey.singleUse?.enabled) {
const newId = await refreshSingleUseId();
if (newId) {
surveyUrl.searchParams.set("suId", newId);
}
}
const shareEmbedViews = [
{ key: "share", modalView: "start" as const, setOpen: handleShareModalToggle },
{ key: "embed", modalView: "embed" as const, setOpen: handleModalState("embed") },
{ key: "panel", modalView: "panel" as const, setOpen: handleModalState("panel") },
];
surveyUrl.searchParams.set("preview", "true");
return surveyUrl.toString();
};
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
@@ -125,7 +128,10 @@ export const SurveyAnalysisCTA = ({
{
icon: Eye,
tooltip: t("common.preview"),
onClick: () => window.open(getPreviewUrl(), "_blank"),
onClick: async () => {
const previewUrl = await getPreviewUrl();
window.open(previewUrl, "_blank");
},
isVisible: survey.type === "link",
},
{
@@ -157,29 +163,31 @@ export const SurveyAnalysisCTA = ({
<IconBar actions={iconActions} />
<Button
className="h-10"
onClick={() => {
setModalState((prev) => ({ ...prev, embed: true }));
setModalState((prev) => ({ ...prev, share: true }));
}}>
{t("environments.surveys.summary.share_survey")}
</Button>
{user && (
<>
{shareEmbedViews.map(({ key, modalView, setOpen }) => (
<ShareEmbedSurvey
key={key}
survey={survey}
publicDomain={publicDomain}
open={modalState[key as keyof ModalState]}
setOpen={setOpen}
user={user}
modalView={modalView}
/>
))}
<SuccessMessage environment={environment} survey={survey} />
</>
<ShareSurveyModal
survey={survey}
publicDomain={publicDomain}
open={modalState.start || modalState.share}
setOpen={(open) => {
if (!open) {
handleShareModalToggle(false);
setModalState((prev) => ({ ...prev, share: false }));
}
}}
user={user}
modalView={modalState.start ? "start" : "share"}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog

View File

@@ -0,0 +1,473 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { ShareSurveyModal } from "./share-survey-modal";
// Mock getPublicDomain - must be first to prevent server-side env access
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn().mockReturnValue("https://example.com"),
}));
// Mock env to prevent server-side env access
vi.mock("@/lib/env", () => ({
env: {
IS_FORMBRICKS_CLOUD: "0",
NODE_ENV: "test",
E2E_TESTING: "0",
ENCRYPTION_KEY: "test-encryption-key-32-characters",
WEBAPP_URL: "https://example.com",
CRON_SECRET: "test-cron-secret",
PUBLIC_URL: "https://example.com",
VERCEL_URL: "",
},
}));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"environments.surveys.summary.single_use_links": "Single-use links",
"environments.surveys.summary.share_the_link": "Share the link",
"environments.surveys.summary.qr_code": "QR Code",
"environments.surveys.summary.personal_links": "Personal links",
"environments.surveys.summary.embed_in_an_email": "Embed in email",
"environments.surveys.summary.embed_on_website": "Embed on website",
"environments.surveys.summary.dynamic_popup": "Dynamic popup",
"environments.surveys.summary.in_app.title": "In-app survey",
"environments.surveys.summary.in_app.description": "Display survey in your app",
"environments.surveys.share.anonymous_links.nav_title": "Share the link",
"environments.surveys.share.single_use_links.nav_title": "Single-use links",
"environments.surveys.share.personal_links.nav_title": "Personal links",
"environments.surveys.share.embed_on_website.nav_title": "Embed on website",
"environments.surveys.share.send_email.nav_title": "Embed in email",
"environments.surveys.share.social_media.title": "Social media",
"environments.surveys.share.dynamic_popup.nav_title": "Dynamic popup",
};
return translations[key] || key;
},
}),
}));
// Mock analysis utils
vi.mock("@/modules/analysis/utils", () => ({
getSurveyUrl: vi.fn().mockResolvedValue("https://example.com/s/test-survey-id"),
}));
// Mock logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
log: vi.fn(),
},
}));
// Mock dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ open, onOpenChange, children }: any) => (
<div data-testid="dialog" data-open={open} onClick={() => onOpenChange(false)}>
{children}
</div>
),
DialogContent: ({ children, width }: any) => (
<div data-testid="dialog-content" data-width={width}>
{children}
</div>
),
DialogTitle: ({ children }: any) => <div data-testid="dialog-title">{children}</div>,
}));
// Mock VisuallyHidden
vi.mock("@radix-ui/react-visually-hidden", () => ({
VisuallyHidden: ({ asChild, children }: any) => (
<div data-testid="visually-hidden">{asChild ? children : <span>{children}</span>}</div>
),
}));
// Mock child components
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab",
() => ({
AppTab: () => <div data-testid="app-tab">App Tab Content</div>,
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container",
() => ({
TabContainer: ({ title, description, children }: any) => (
<div data-testid="tab-container">
<h3>{title}</h3>
<p>{description}</p>
{children}
</div>
),
})
);
vi.mock("./shareEmbedModal/share-view", () => ({
ShareView: ({ tabs, activeId, setActiveId }: any) => (
<div data-testid="share-view" data-active-id={activeId}>
<h3>Share View</h3>
<div data-testid="share-view-data">
<div>Active Tab: {activeId}</div>
</div>
<div data-testid="tabs">
{tabs.map((tab: any) => (
<button key={tab.id} onClick={() => setActiveId(tab.id)} data-testid={`tab-${tab.id}`}>
{tab.label}
</button>
))}
</div>
</div>
),
}));
vi.mock("./shareEmbedModal/success-view", () => ({
SuccessView: ({
survey,
surveyUrl,
publicDomain,
user,
tabs,
handleViewChange,
handleEmbedViewWithTab,
}: any) => (
<div data-testid="success-view">
<h3>Success View</h3>
<div data-testid="success-view-data">
<div>Survey: {survey?.id}</div>
<div>URL: {surveyUrl}</div>
<div>Domain: {publicDomain}</div>
<div>User: {user?.id}</div>
</div>
<div data-testid="success-tabs">
{tabs.map((tab: any) => {
// Handle single-use links case
let displayLabel = tab.label;
if (tab.id === "anon-links" && survey?.singleUse?.enabled) {
displayLabel = "Single-use links";
}
return (
<button
key={tab.id}
onClick={() => handleEmbedViewWithTab(tab.id)}
data-testid={`success-tab-${tab.id}`}>
{displayLabel}
</button>
);
})}
</div>
<button onClick={() => handleViewChange("share")} data-testid="go-to-share-view">
Go to Share View
</button>
</div>
),
}));
// Mock lucide-react icons
vi.mock("lucide-react", async (importOriginal) => {
const actual = (await importOriginal()) as any;
return {
...actual,
Code2Icon: () => <svg data-testid="code2-icon" />,
LinkIcon: () => <svg data-testid="link-icon" />,
MailIcon: () => <svg data-testid="mail-icon" />,
QrCodeIcon: () => <svg data-testid="qrcode-icon" />,
SmartphoneIcon: () => <svg data-testid="smartphone-icon" />,
SquareStack: () => <svg data-testid="square-stack-icon" />,
UserIcon: () => <svg data-testid="user-icon" />,
};
});
// Mock data
const mockSurvey: TSurvey = {
id: "test-survey-id",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "link",
environmentId: "test-env-id",
status: "inProgress",
displayOption: "displayOnce",
autoClose: null,
triggers: [],
recontactDays: null,
displayLimit: null,
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
questions: [],
endings: [],
hiddenFields: { enabled: false },
displayPercentage: null,
autoComplete: null,
segment: null,
languages: [],
showLanguageSwitch: false,
singleUse: { enabled: false, isEncrypted: false },
projectOverwrites: null,
surveyClosedMessage: null,
delay: 0,
isVerifyEmailEnabled: false,
createdBy: null,
variables: [],
followUps: [],
runOnDate: null,
closeOnDate: null,
styling: null,
pin: null,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
resultShareKey: null,
};
const mockAppSurvey: TSurvey = {
...mockSurvey,
type: "app",
};
const mockUser: TUser = {
id: "test-user-id",
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "https://example.com/avatar.jpg",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "other",
objective: "other",
locale: "en-US",
lastLoginAt: new Date(),
isActive: true,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
};
const mockSegments: TSegment[] = [];
const mockSetOpen = vi.fn();
const defaultProps = {
survey: mockSurvey,
publicDomain: "https://example.com",
open: true,
modalView: "start" as const,
setOpen: mockSetOpen,
user: mockUser,
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
};
describe("ShareSurveyModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders dialog when open is true", () => {
render(<ShareSurveyModal {...defaultProps} />);
expect(screen.getByTestId("dialog")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
});
test("renders success view when modalView is start", () => {
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
expect(screen.getByTestId("success-view")).toBeInTheDocument();
expect(screen.getByText("Success View")).toBeInTheDocument();
});
test("renders share view when modalView is share and survey is link type", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
expect(screen.getByText("Share View")).toBeInTheDocument();
});
test("renders app tab when survey is app type and modalView is share", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
expect(screen.getByText("In-app survey")).toBeInTheDocument();
expect(screen.getByText("Display survey in your app")).toBeInTheDocument();
});
test("renders success view when survey is app type and modalView is start", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="start" />);
expect(screen.getByTestId("success-view")).toBeInTheDocument();
expect(screen.queryByTestId("tab-container")).not.toBeInTheDocument();
});
test("sets correct width for dialog content based on survey type", () => {
const { rerender } = render(<ShareSurveyModal {...defaultProps} survey={mockSurvey} />);
expect(screen.getByTestId("dialog-content")).toHaveAttribute("data-width", "wide");
rerender(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} />);
expect(screen.getByTestId("dialog-content")).toHaveAttribute("data-width", "default");
});
test("generates correct tabs for link survey", () => {
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
expect(screen.getByTestId("success-tab-anon-links")).toHaveTextContent("Share the link");
expect(screen.getByTestId("success-tab-qr-code")).toHaveTextContent("QR Code");
expect(screen.getByTestId("success-tab-personal-links")).toHaveTextContent("Personal links");
expect(screen.getByTestId("success-tab-email")).toHaveTextContent("Embed in email");
expect(screen.getByTestId("success-tab-website-embed")).toHaveTextContent("Embed on website");
expect(screen.getByTestId("success-tab-dynamic-popup")).toHaveTextContent("Dynamic popup");
});
test("shows single-use links label when singleUse is enabled", () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
render(<ShareSurveyModal {...defaultProps} survey={singleUseSurvey} modalView="start" />);
expect(screen.getByTestId("success-tab-anon-links")).toHaveTextContent("Single-use links");
});
test("calls setOpen when dialog is closed", async () => {
const user = userEvent.setup();
render(<ShareSurveyModal {...defaultProps} />);
await user.click(screen.getByTestId("dialog"));
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("fetches survey URL on mount", async () => {
const { getSurveyUrl } = await import("@/modules/analysis/utils");
render(<ShareSurveyModal {...defaultProps} />);
await waitFor(() => {
expect(getSurveyUrl).toHaveBeenCalledWith(mockSurvey, "https://example.com", "default");
});
});
test("handles getSurveyUrl failure gracefully", async () => {
const { getSurveyUrl } = await import("@/modules/analysis/utils");
vi.mocked(getSurveyUrl).mockRejectedValue(new Error("Failed to fetch"));
// Render and verify it doesn't crash, even if nothing renders due to the error
expect(() => {
render(<ShareSurveyModal {...defaultProps} />);
}).not.toThrow();
});
test("renders ShareView with correct active tab", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
const shareViewData = screen.getByTestId("share-view-data");
expect(shareViewData).toHaveTextContent("Active Tab: anon-links");
});
test("passes correct props to SuccessView", () => {
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
const successViewData = screen.getByTestId("success-view-data");
expect(successViewData).toHaveTextContent("Survey: test-survey-id");
expect(successViewData).toHaveTextContent("Domain: https://example.com");
expect(successViewData).toHaveTextContent("User: test-user-id");
});
test("resets to start view when modal is closed and reopened", async () => {
const user = userEvent.setup();
const { rerender } = render(<ShareSurveyModal {...defaultProps} modalView="share" />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
rerender(<ShareSurveyModal {...defaultProps} modalView="share" open={false} />);
rerender(<ShareSurveyModal {...defaultProps} modalView="share" open={true} />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
});
test("sets correct active tab for link survey", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
expect(screen.getByTestId("share-view")).toHaveAttribute("data-active-id", "anon-links");
});
test("renders tab container for app survey in share mode", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
});
test("renders with contacts disabled", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" isContactsEnabled={false} />);
// Just verify the ShareView renders correctly regardless of isContactsEnabled prop
expect(screen.getByTestId("share-view")).toBeInTheDocument();
expect(screen.getByTestId("share-view")).toHaveAttribute("data-active-id", "anon-links");
});
test("renders with formbricks cloud enabled", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" isFormbricksCloud={true} />);
// Just verify the ShareView renders correctly regardless of isFormbricksCloud prop
expect(screen.getByTestId("share-view")).toBeInTheDocument();
});
test("correctly handles direct navigation to share view", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
expect(screen.queryByTestId("success-view")).not.toBeInTheDocument();
});
test("handler functions are passed to child components", () => {
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
// Verify SuccessView receives the handler functions by checking buttons exist
expect(screen.getByTestId("go-to-share-view")).toBeInTheDocument();
expect(screen.getByTestId("success-tab-anon-links")).toBeInTheDocument();
expect(screen.getByTestId("success-tab-qr-code")).toBeInTheDocument();
});
test("tab switching functionality is available in ShareView", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
// Verify ShareView has tab switching buttons
expect(screen.getByTestId("tab-anon-links")).toBeInTheDocument();
expect(screen.getByTestId("tab-qr-code")).toBeInTheDocument();
expect(screen.getByTestId("tab-personal-links")).toBeInTheDocument();
});
test("renders different content based on survey type", () => {
// Link survey renders ShareView
const { rerender } = render(<ShareSurveyModal {...defaultProps} survey={mockSurvey} modalView="share" />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
// App survey renders TabContainer with AppTab
rerender(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,228 @@
"use client";
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { WebsiteEmbedTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useTranslate } from "@tolgee/react";
import { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { ShareView } from "./shareEmbedModal/share-view";
import { SuccessView } from "./shareEmbedModal/success-view";
type ModalView = "start" | "share";
interface ShareSurveyModalProps {
survey: TSurvey;
publicDomain: string;
open: boolean;
modalView: ModalView;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
user: TUser;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
export const ShareSurveyModal = ({
survey,
publicDomain,
open,
modalView,
setOpen,
user,
segments,
isContactsEnabled,
isFormbricksCloud,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user;
const { t } = useTranslate();
const linkTabs: {
id: ShareViewType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<any>;
componentProps: any;
}[] = useMemo(
() => [
{
id: ShareViewType.ANON_LINKS,
label: t("environments.surveys.share.anonymous_links.nav_title"),
icon: LinkIcon,
title: t("environments.surveys.share.anonymous_links.nav_title"),
description: t("environments.surveys.share.anonymous_links.description"),
componentType: AnonymousLinksTab,
componentProps: {
survey,
publicDomain,
setSurveyUrl,
locale: user.locale,
surveyUrl,
},
},
{
id: ShareViewType.PERSONAL_LINKS,
label: t("environments.surveys.share.personal_links.nav_title"),
icon: UserIcon,
title: t("environments.surveys.share.personal_links.nav_title"),
description: t("environments.surveys.share.personal_links.description"),
componentType: PersonalLinksTab,
componentProps: {
environmentId,
surveyId: survey.id,
segments,
isContactsEnabled,
isFormbricksCloud,
},
},
{
id: ShareViewType.WEBSITE_EMBED,
label: t("environments.surveys.share.embed_on_website.nav_title"),
icon: Code2Icon,
title: t("environments.surveys.share.embed_on_website.nav_title"),
description: t("environments.surveys.share.embed_on_website.description"),
componentType: WebsiteEmbedTab,
componentProps: { surveyUrl },
},
{
id: ShareViewType.EMAIL,
label: t("environments.surveys.share.send_email.nav_title"),
icon: MailIcon,
title: t("environments.surveys.share.send_email.nav_title"),
description: t("environments.surveys.share.send_email.description"),
componentType: EmailTab,
componentProps: { surveyId: survey.id, email },
},
{
id: ShareViewType.SOCIAL_MEDIA,
label: t("environments.surveys.share.social_media.title"),
icon: Share2Icon,
title: t("environments.surveys.share.social_media.title"),
description: t("environments.surveys.share.social_media.description"),
componentType: SocialMediaTab,
componentProps: { surveyUrl, surveyTitle: survey.name },
},
{
id: ShareViewType.QR_CODE,
label: t("environments.surveys.summary.qr_code"),
icon: QrCodeIcon,
title: t("environments.surveys.summary.qr_code"),
description: t("environments.surveys.summary.qr_code_description"),
componentType: QRCodeTab,
componentProps: { surveyUrl },
},
{
id: ShareViewType.DYNAMIC_POPUP,
label: t("environments.surveys.share.dynamic_popup.nav_title"),
icon: SquareStack,
title: t("environments.surveys.share.dynamic_popup.nav_title"),
description: t("environments.surveys.share.dynamic_popup.description"),
componentType: DynamicPopupTab,
componentProps: { environmentId, surveyId: survey.id },
},
],
[
t,
survey,
publicDomain,
setSurveyUrl,
user.locale,
surveyUrl,
environmentId,
segments,
isContactsEnabled,
isFormbricksCloud,
email,
]
);
const [activeId, setActiveId] = useState(
survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP
);
useEffect(() => {
if (open) {
setShowView(modalView);
}
}, [open, modalView]);
const handleOpenChange = (open: boolean) => {
setOpen(open);
if (!open) {
setShowView("start");
setActiveId(ShareViewType.ANON_LINKS);
}
};
const handleViewChange = (view: ModalView) => {
setShowView(view);
};
const handleEmbedViewWithTab = (tabId: ShareViewType) => {
setShowView("share");
setActiveId(tabId);
};
const renderContent = () => {
if (showView === "start") {
return (
<SuccessView
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
user={user}
tabs={linkTabs}
handleViewChange={handleViewChange}
handleEmbedViewWithTab={handleEmbedViewWithTab}
/>
);
}
if (survey.type === "link") {
return <ShareView tabs={linkTabs} activeId={activeId} setActiveId={setActiveId} />;
}
return (
<div className={`h-full w-full rounded-lg bg-slate-50 p-6`}>
<TabContainer
title={t("environments.surveys.summary.in_app.title")}
description={t("environments.surveys.summary.in_app.description")}>
<AppTab />
</TabContainer>
</div>
);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<VisuallyHidden asChild>
<DialogTitle />
</VisuallyHidden>
<DialogContent
className="w-full bg-white p-0 lg:h-[700px]"
width={survey.type === "link" ? "wide" : "default"}
aria-describedby={undefined}
unconstrained>
{renderContent()}
</DialogContent>
</Dialog>
);
};

View File

@@ -1,63 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AppTab } from "./AppTab";
vi.mock("@/modules/ui/components/options-switch", () => ({
OptionsSwitch: (props: {
options: Array<{ value: string; label: string }>;
handleOptionChange: (value: string) => void;
}) => (
<div data-testid="options-switch">
{props.options.map((option) => (
<button
key={option.value}
data-testid={`option-${option.value}`}
onClick={() => props.handleOptionChange(option.value)}>
{option.label}
</button>
))}
</div>
),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab",
() => ({
MobileAppTab: () => <div data-testid="mobile-app-tab">MobileAppTab</div>,
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab",
() => ({
WebAppTab: () => <div data-testid="web-app-tab">WebAppTab</div>,
})
);
describe("AppTab", () => {
afterEach(() => {
cleanup();
});
test("renders correctly by default with WebAppTab visible", () => {
render(<AppTab />);
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
expect(screen.getByTestId("option-webapp")).toBeInTheDocument();
expect(screen.getByTestId("option-mobile")).toBeInTheDocument();
expect(screen.getByTestId("web-app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("mobile-app-tab")).not.toBeInTheDocument();
});
test("switches to MobileAppTab when mobile option is selected", async () => {
const user = userEvent.setup();
render(<AppTab />);
const mobileOptionButton = screen.getByTestId("option-mobile");
await user.click(mobileOptionButton);
expect(screen.getByTestId("mobile-app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("web-app-tab")).not.toBeInTheDocument();
});
});

View File

@@ -1,27 +0,0 @@
"use client";
import { MobileAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab";
import { WebAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
import { useTranslate } from "@tolgee/react";
import { useState } from "react";
export const AppTab = () => {
const { t } = useTranslate();
const [selectedTab, setSelectedTab] = useState("webapp");
return (
<div className="flex h-full grow flex-col">
<OptionsSwitch
options={[
{ value: "webapp", label: t("environments.surveys.summary.web_app") },
{ value: "mobile", label: t("environments.surveys.summary.mobile_app") },
]}
currentOption={selectedTab}
handleOptionChange={(value) => setSelectedTab(value)}
/>
<div className="mt-4">{selectedTab === "webapp" ? <WebAppTab /> : <MobileAppTab />}</div>
</div>
);
};

View File

@@ -1,133 +0,0 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { useTranslate } from "@tolgee/react";
import { Code2Icon, CopyIcon, MailIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { AuthenticationError } from "@formbricks/types/errors";
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
interface EmailTabProps {
surveyId: string;
email: string;
}
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [showEmbed, setShowEmbed] = useState(false);
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const { t } = useTranslate();
const emailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return emailHtmlPreview
.replaceAll("?preview=true&amp;", "?")
.replaceAll("?preview=true&;", "?")
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
useEffect(() => {
const getData = async () => {
const emailHtml = await getEmailHtmlAction({ surveyId });
setEmailHtmlPreview(emailHtml?.data || "");
};
getData();
}, [surveyId]);
const sendPreviewEmail = async () => {
try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
if (val?.data) {
toast.success(t("environments.surveys.summary.email_sent"));
} else {
const errorMessage = getFormattedErrorMessage(val);
toast.error(errorMessage);
}
} catch (err) {
if (err instanceof AuthenticationError) {
toast.error(t("common.not_authenticated"));
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
return (
<div className="flex flex-col gap-5">
<div className="flex items-center justify-end gap-4">
{showEmbed ? (
<Button
variant="secondary"
title="Embed survey in your website"
aria-label="Embed survey in your website"
onClick={() => {
toast.success(t("environments.surveys.summary.embed_code_copied_to_clipboard"));
navigator.clipboard.writeText(emailHtml);
}}
className="shrink-0">
{t("common.copy_code")}
<CopyIcon />
</Button>
) : (
<>
<Button
variant="secondary"
title="send preview email"
aria-label="send preview email"
onClick={() => sendPreviewEmail()}
className="shrink-0">
{t("environments.surveys.summary.send_preview")}
<MailIcon />
</Button>
</>
)}
<Button
title={t("environments.surveys.summary.view_embed_code_for_email")}
aria-label={t("environments.surveys.summary.view_embed_code_for_email")}
onClick={() => {
setShowEmbed(!showEmbed);
}}
className="shrink-0">
{showEmbed
? t("environments.surveys.summary.hide_embed_code")
: t("environments.surveys.summary.view_embed_code")}
<Code2Icon />
</Button>
</div>
{showEmbed ? (
<div className="prose prose-slate -mt-4 max-w-full">
<CodeBlock
customCodeClass="text-sm h-48 overflow-y-scroll"
language="html"
showCopyToClipboard={false}>
{emailHtml}
</CodeBlock>
</div>
) : (
<div className="mb-12 grow overflow-y-auto rounded-xl border border-slate-200 bg-white p-4">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div>
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">To : {email || "user@mail.com"}</div>
<div className="border-b border-slate-200 pb-2 text-sm">
Subject : {t("environments.surveys.summary.formbricks_email_survey_preview")}
</div>
<div className="p-4">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: emailHtmlPreview }}></div>
) : (
<LoadingSpinner />
)}
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,154 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EmbedView } from "./EmbedView";
// Mock child components
vi.mock("./AppTab", () => ({
AppTab: () => <div data-testid="app-tab">AppTab Content</div>,
}));
vi.mock("./EmailTab", () => ({
EmailTab: (props: { surveyId: string; email: string }) => (
<div data-testid="email-tab">
EmailTab Content for {props.surveyId} with {props.email}
</div>
),
}));
vi.mock("./LinkTab", () => ({
LinkTab: (props: { survey: any; surveyUrl: string }) => (
<div data-testid="link-tab">
LinkTab Content for {props.survey.id} at {props.surveyUrl}
</div>
),
}));
vi.mock("./WebsiteTab", () => ({
WebsiteTab: (props: { surveyUrl: string; environmentId: string }) => (
<div data-testid="website-tab">
WebsiteTab Content for {props.surveyUrl} in {props.environmentId}
</div>
),
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: () => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>,
MailIcon: () => <div data-testid="mail-icon">MailIcon</div>,
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
}));
const mockTabs = [
{ id: "email", label: "Email", icon: () => <div data-testid="email-tab-icon" /> },
{ id: "webpage", label: "Web Page", icon: () => <div data-testid="webpage-tab-icon" /> },
{ id: "link", label: "Link", icon: () => <div data-testid="link-tab-icon" /> },
{ id: "app", label: "App", icon: () => <div data-testid="app-tab-icon" /> },
];
const mockSurveyLink = { id: "survey1", type: "link" };
const mockSurveyWeb = { id: "survey2", type: "web" };
const defaultProps = {
handleInitialPageButton: vi.fn(),
tabs: mockTabs,
activeId: "email",
setActiveId: vi.fn(),
environmentId: "env1",
survey: mockSurveyLink,
email: "test@example.com",
surveyUrl: "http://example.com/survey1",
publicDomain: "http://example.com",
setSurveyUrl: vi.fn(),
locale: "en" as any,
disableBack: false,
};
describe("EmbedView", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("does not render back button when disableBack is true", () => {
render(<EmbedView {...defaultProps} disableBack={true} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
});
test("does not render desktop tabs for non-link survey type", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />);
// Desktop tabs container should not be present or not have lg:flex if it's a common parent
const desktopTabsButtons = screen.queryAllByRole("button", { name: /Email|Web Page|Link|App/i });
// Check if any of these buttons are part of a container that is only visible on large screens
const desktopTabContainer = desktopTabsButtons[0]?.closest("div.lg\\:flex");
expect(desktopTabContainer).toBeNull();
});
test("calls setActiveId when a tab is clicked (desktop)", async () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
const webpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[0]; // First one is desktop
await userEvent.click(webpageTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage");
});
test("renders EmailTab when activeId is 'email'", () => {
render(<EmbedView {...defaultProps} activeId="email" />);
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
expect(
screen.getByText(`EmailTab Content for ${defaultProps.survey.id} with ${defaultProps.email}`)
).toBeInTheDocument();
});
test("renders WebsiteTab when activeId is 'webpage'", () => {
render(<EmbedView {...defaultProps} activeId="webpage" />);
expect(screen.getByTestId("website-tab")).toBeInTheDocument();
expect(
screen.getByText(`WebsiteTab Content for ${defaultProps.surveyUrl} in ${defaultProps.environmentId}`)
).toBeInTheDocument();
});
test("renders LinkTab when activeId is 'link'", () => {
render(<EmbedView {...defaultProps} activeId="link" />);
expect(screen.getByTestId("link-tab")).toBeInTheDocument();
expect(
screen.getByText(`LinkTab Content for ${defaultProps.survey.id} at ${defaultProps.surveyUrl}`)
).toBeInTheDocument();
});
test("renders AppTab when activeId is 'app'", () => {
render(<EmbedView {...defaultProps} activeId="app" />);
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
});
test("calls setActiveId when a responsive tab is clicked", async () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
// Get the responsive tab button (second instance of the button with this name)
const responsiveWebpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[1];
await userEvent.click(responsiveWebpageTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage");
});
test("applies active styles to the active tab (desktop)", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
const emailTabButton = screen.getAllByRole("button", { name: "Email" })[0];
expect(emailTabButton).toHaveClass("border-slate-200 bg-slate-100 font-semibold text-slate-900");
const webpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[0];
expect(webpageTabButton).toHaveClass("border-transparent text-slate-500 hover:text-slate-700");
});
test("applies active styles to the active tab (responsive)", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
const responsiveEmailTabButton = screen.getAllByRole("button", { name: "Email" })[1];
expect(responsiveEmailTabButton).toHaveClass("bg-white text-slate-900 shadow-sm");
const responsiveWebpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[1];
expect(responsiveWebpageTabButton).toHaveClass("border-transparent text-slate-700 hover:text-slate-900");
});
});

View File

@@ -1,113 +0,0 @@
"use client";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import { TUserLocale } from "@formbricks/types/user";
import { AppTab } from "./AppTab";
import { EmailTab } from "./EmailTab";
import { LinkTab } from "./LinkTab";
import { WebsiteTab } from "./WebsiteTab";
interface EmbedViewProps {
handleInitialPageButton: () => void;
tabs: Array<{ id: string; label: string; icon: any }>;
activeId: string;
setActiveId: React.Dispatch<React.SetStateAction<string>>;
environmentId: string;
disableBack: boolean;
survey: any;
email: string;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
locale: TUserLocale;
}
export const EmbedView = ({
handleInitialPageButton,
tabs,
disableBack,
activeId,
setActiveId,
environmentId,
survey,
email,
surveyUrl,
publicDomain,
setSurveyUrl,
locale,
}: EmbedViewProps) => {
const { t } = useTranslate();
return (
<div className="h-full overflow-hidden">
{!disableBack && (
<div className="border-b border-slate-200 py-2 pl-2">
<Button variant="ghost" className="focus:ring-0" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-4">
{survey.type === "link" && (
<div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}>
{tabs.map((tab) => (
<Button
variant="ghost"
key={tab.id}
onClick={() => setActiveId(tab.id)}
autoFocus={tab.id === activeId}
className={cn(
"flex justify-start rounded-md border px-4 py-2 text-slate-600",
// "focus:ring-0 focus:ring-offset-0", // enable these classes to remove the focus rings on buttons
tab.id === activeId
? "border-slate-200 bg-slate-100 font-semibold text-slate-900"
: "border-transparent text-slate-500 hover:text-slate-700"
)}
aria-current={tab.id === activeId ? "page" : undefined}>
<tab.icon />
{tab.label}
</Button>
))}
</div>
)}
<div
className={`col-span-4 h-full overflow-y-auto bg-slate-50 px-4 py-6 ${survey.type === "link" ? "lg:col-span-3" : ""} lg:p-6`}>
{activeId === "email" ? (
<EmailTab surveyId={survey.id} email={email} />
) : activeId === "webpage" ? (
<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />
) : activeId === "link" ? (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
) : activeId === "app" ? (
<AppTab />
) : null}
<div className="mt-2 rounded-md p-3 text-center lg:hidden">
{tabs.slice(0, 2).map((tab) => (
<Button
variant="ghost"
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={cn(
"rounded-md px-4 py-2",
tab.id === activeId
? "bg-white text-slate-900 shadow-sm"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
{tab.label}
</Button>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,155 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { LinkTab } from "./LinkTab";
// Mock ShareSurveyLink
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: vi.fn(({ survey, surveyUrl, publicDomain, locale }) => (
<div data-testid="share-survey-link">
Mocked ShareSurveyLink
<span data-testid="survey-id">{survey.id}</span>
<span data-testid="survey-url">{surveyUrl}</span>
<span data-testid="public-domain">{publicDomain}</span>
<span data-testid="locale">{locale}</span>
</div>
)),
}));
// Mock useTranslate
const mockTranslate = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: mockTranslate,
}),
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
const mockSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
status: "inProgress",
questions: [],
thankYouCard: { enabled: false },
endings: [],
autoClose: null,
triggers: [],
languages: [],
styling: null,
} as unknown as TSurvey;
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
const mockPublicDomain = "https://app.formbricks.com";
const mockSetSurveyUrl = vi.fn();
const mockLocale: TUserLocale = "en-US";
const docsLinksExpected = [
{
titleKey: "environments.surveys.summary.data_prefilling",
descriptionKey: "environments.surveys.summary.data_prefilling_description",
link: "https://formbricks.com/docs/link-surveys/data-prefilling",
},
{
titleKey: "environments.surveys.summary.source_tracking",
descriptionKey: "environments.surveys.summary.source_tracking_description",
link: "https://formbricks.com/docs/link-surveys/source-tracking",
},
{
titleKey: "environments.surveys.summary.create_single_use_links",
descriptionKey: "environments.surveys.summary.create_single_use_links_description",
link: "https://formbricks.com/docs/link-surveys/single-use-links",
},
];
describe("LinkTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders the main title", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(
screen.getByText("environments.surveys.summary.share_the_link_to_get_responses")
).toBeInTheDocument();
});
test("renders ShareSurveyLink with correct props", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
expect(screen.getByTestId("public-domain")).toHaveTextContent(mockPublicDomain);
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
});
test("renders the promotional text for link surveys", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(
screen.getByText("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys 💡")
).toBeInTheDocument();
});
test("renders all documentation links correctly", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
docsLinksExpected.forEach((doc) => {
const linkElement = screen.getByText(doc.titleKey).closest("a");
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute("href", doc.link);
expect(linkElement).toHaveAttribute("target", "_blank");
expect(screen.getByText(doc.descriptionKey)).toBeInTheDocument();
});
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling_description");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking_description");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.create_single_use_links");
expect(mockTranslate).toHaveBeenCalledWith(
"environments.surveys.summary.create_single_use_links_description"
);
});
});

View File

@@ -1,72 +0,0 @@
"use client";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface LinkTabProps {
survey: TSurvey;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}
export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate();
const docsLinks = [
{
title: t("environments.surveys.summary.data_prefilling"),
description: t("environments.surveys.summary.data_prefilling_description"),
link: "https://formbricks.com/docs/link-surveys/data-prefilling",
},
{
title: t("environments.surveys.summary.source_tracking"),
description: t("environments.surveys.summary.source_tracking_description"),
link: "https://formbricks.com/docs/link-surveys/source-tracking",
},
{
title: t("environments.surveys.summary.create_single_use_links"),
description: t("environments.surveys.summary.create_single_use_links_description"),
link: "https://formbricks.com/docs/link-surveys/single-use-links",
},
];
return (
<div className="flex h-full grow flex-col gap-6">
<div>
<p className="text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.share_the_link_to_get_responses")}
</p>
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
</div>
<div className="flex flex-wrap justify-between gap-2">
<p className="pt-2 font-semibold text-slate-700">
{t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡
</p>
<div className="grid grid-cols-2 gap-2">
{docsLinks.map((tip) => (
<Link
key={tip.title}
target="_blank"
href={tip.link}
className="relative w-full rounded-md border border-slate-100 bg-white px-6 py-4 text-sm text-slate-600 hover:bg-slate-50 hover:text-slate-800">
<p className="mb-1 font-semibold">{tip.title}</p>
<p className="text-slate-500 hover:text-slate-700">{tip.description}</p>
</Link>
))}
</div>
</div>
</div>
);
};

View File

@@ -1,69 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { MobileAppTab } from "./MobileAppTab";
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key, // Return the key itself for easy assertion
}),
}));
// Mock UI components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: { children: React.ReactNode }) => <div data-testid="alert">{children}</div>,
AlertTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-title">{children}</div>
),
AlertDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-description">{children}</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, asChild, ...props }: { children: React.ReactNode; asChild?: boolean }) =>
asChild ? <div {...props}>{children}</div> : <button {...props}>{children}</button>,
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ children, href, target, ...props }: any) => (
<a href={href} target={target} {...props}>
{children}
</a>
),
}));
describe("MobileAppTab", () => {
afterEach(() => {
cleanup();
});
test("renders correctly with title, description, and learn more link", () => {
render(<MobileAppTab />);
// Check for Alert component
expect(screen.getByTestId("alert")).toBeInTheDocument();
// Check for AlertTitle with correct Tolgee key
const alertTitle = screen.getByTestId("alert-title");
expect(alertTitle).toBeInTheDocument();
expect(alertTitle).toHaveTextContent("environments.surveys.summary.quickstart_mobile_apps");
// Check for AlertDescription with correct Tolgee key
const alertDescription = screen.getByTestId("alert-description");
expect(alertDescription).toBeInTheDocument();
expect(alertDescription).toHaveTextContent(
"environments.surveys.summary.quickstart_mobile_apps_description"
);
// Check for the "Learn more" link
const learnMoreLink = screen.getByRole("link", { name: "common.learn_more" });
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides"
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
});
});

View File

@@ -1,25 +0,0 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
export const MobileAppTab = () => {
const { t } = useTranslate();
return (
<Alert>
<AlertTitle>{t("environments.surveys.summary.quickstart_mobile_apps")}</AlertTitle>
<AlertDescription>
{t("environments.surveys.summary.quickstart_mobile_apps_description")}
<Button asChild className="w-fit" size="sm" variant="link">
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides"
target="_blank">
{t("common.learn_more")}
</Link>
</Button>
</AlertDescription>
</Alert>
);
};

View File

@@ -1,108 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PanelInfoView } from "./PanelInfoView";
// Mock next/image
vi.mock("next/image", () => ({
default: ({ src, alt, className }: { src: any; alt: string; className?: string }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src.src} alt={alt} className={className} />
),
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target}>
{children}
</a>
),
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, asChild }: any) => {
if (asChild) {
return <div onClick={onClick}>{children}</div>; // NOSONAR
}
return (
<button onClick={onClick} data-variant={variant}>
{children}
</button>
);
},
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: vi.fn(() => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>),
}));
const mockHandleInitialPageButton = vi.fn();
describe("PanelInfoView", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with back button and all sections", async () => {
render(<PanelInfoView disableBack={false} handleInitialPageButton={mockHandleInitialPageButton} />);
// Check for back button
const backButton = screen.getByText("common.back");
expect(backButton).toBeInTheDocument();
expect(screen.getByTestId("arrow-left-icon")).toBeInTheDocument();
// Check images
expect(screen.getAllByAltText("Prolific panel selection UI")[0]).toBeInTheDocument();
expect(screen.getAllByAltText("Prolific panel selection UI")[1]).toBeInTheDocument();
// Check text content (Tolgee keys)
expect(screen.getByText("environments.surveys.summary.what_is_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_a_panel_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4_description")
).toBeInTheDocument();
// Check "Learn more" link
const learnMoreLink = screen.getByRole("link", { name: "common.learn_more" });
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
// Click back button
await userEvent.click(backButton);
expect(mockHandleInitialPageButton).toHaveBeenCalledTimes(1);
});
test("renders correctly without back button when disableBack is true", () => {
render(<PanelInfoView disableBack={true} handleInitialPageButton={mockHandleInitialPageButton} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
expect(screen.queryByTestId("arrow-left-icon")).not.toBeInTheDocument();
});
});

View File

@@ -1,98 +0,0 @@
"use client";
import ProlificLogo from "@/images/prolific-logo.webp";
import ProlificUI from "@/images/prolific-screenshot.webp";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
interface PanelInfoViewProps {
disableBack: boolean;
handleInitialPageButton: () => void;
}
export const PanelInfoView = ({ disableBack, handleInitialPageButton }: PanelInfoViewProps) => {
const { t } = useTranslate();
return (
<div className="h-full overflow-hidden text-slate-900">
{!disableBack && (
<div className="border-b border-slate-200 py-2">
<Button variant="ghost" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-2">
<div className="flex flex-col gap-y-6 border-r border-slate-200 p-8">
<Image src={ProlificUI} alt="Prolific panel selection UI" className="rounded-lg shadow-lg" />
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_a_panel")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_a_panel_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.when_do_i_need_it")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.when_do_i_need_it_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_prolific")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_prolific_answer")}</p>
</div>
</div>
<div className="relative flex flex-col gap-y-6 bg-slate-50 p-8">
<Image
src={ProlificLogo}
alt="Prolific panel selection UI"
className="absolute right-8 top-8 w-32"
/>
<div>
<h3 className="text-xl font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel")}
</h3>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_1")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_1_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_2")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_2_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_3")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_3_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_4")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_4_description")}
</p>
</div>
<Button className="justify-center" asChild>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
target="_blank">
{t("common.learn_more")}
</Link>
</Button>
</div>
</div>
</div>
);
};

View File

@@ -1,53 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebAppTab } from "./WebAppTab";
vi.mock("@/modules/ui/components/button/Button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
vi.mock("lucide-react", () => ({
CopyIcon: () => <div data-testid="copy-icon" />,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: { children: React.ReactNode }) => <div data-testid="alert">{children}</div>,
AlertTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-title">{children}</div>
),
AlertDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-description">{children}</div>
),
}));
// Mock navigator.clipboard.writeText
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
configurable: true,
});
const surveyUrl = "https://app.formbricks.com/s/test-survey-id";
const surveyId = "test-survey-id";
describe("WebAppTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with surveyUrl and surveyId", () => {
render(<WebAppTab />);
expect(screen.getByText("environments.surveys.summary.quickstart_web_apps")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.learn_more" })).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/quickstart"
);
});
});

View File

@@ -1,25 +0,0 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
export const WebAppTab = () => {
const { t } = useTranslate();
return (
<Alert>
<AlertTitle>{t("environments.surveys.summary.quickstart_web_apps")}</AlertTitle>
<AlertDescription>
{t("environments.surveys.summary.quickstart_web_apps_description")}
<Button asChild className="w-fit" size="sm" variant="link">
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/quickstart"
target="_blank">
{t("common.learn_more")}
</Link>
</Button>
</AlertDescription>
</Alert>
);
};

View File

@@ -1,254 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { WebsiteTab } from "./WebsiteTab";
// Mock child components and hooks
const mockAdvancedOptionToggle = vi.fn();
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
AdvancedOptionToggle: (props: any) => {
mockAdvancedOptionToggle(props);
return (
<div data-testid="advanced-option-toggle">
<span>{props.title}</span>
<input type="checkbox" checked={props.isChecked} onChange={() => props.onToggle(!props.isChecked)} />
</div>
);
},
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
const mockCodeBlock = vi.fn();
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: (props: any) => {
mockCodeBlock(props);
return (
<div data-testid="code-block" data-language={props.language}>
{props.children}
</div>
);
},
}));
const mockOptionsSwitch = vi.fn();
vi.mock("@/modules/ui/components/options-switch", () => ({
OptionsSwitch: (props: any) => {
mockOptionsSwitch(props);
return (
<div data-testid="options-switch">
{props.options.map((opt: { value: string; label: string }) => (
<button key={opt.value} onClick={() => props.handleOptionChange(opt.value)}>
{opt.label}
</button>
))}
</div>
);
},
}));
vi.mock("lucide-react", () => ({
CopyIcon: () => <div data-testid="copy-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target} data-testid="next-link">
{children}
</a>
),
}));
const mockWriteText = vi.fn();
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
},
configurable: true,
});
const surveyUrl = "https://app.formbricks.com/s/survey123";
const environmentId = "env456";
describe("WebsiteTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders OptionsSwitch and StaticTab by default", () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
expect(mockOptionsSwitch).toHaveBeenCalledWith(
expect.objectContaining({
currentOption: "static",
options: [
{ value: "static", label: "environments.surveys.summary.static_iframe" },
{ value: "popup", label: "environments.surveys.summary.dynamic_popup" },
],
})
);
// StaticTab content checks
expect(screen.getByText("common.copy_code")).toBeInTheDocument();
expect(screen.getByTestId("code-block")).toBeInTheDocument();
expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.static_iframe")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.dynamic_popup")).toBeInTheDocument();
});
test("switches to PopupTab when 'Dynamic Popup' option is clicked", async () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const popupButton = screen.getByRole("button", {
name: "environments.surveys.summary.dynamic_popup",
});
await userEvent.click(popupButton);
expect(mockOptionsSwitch.mock.calls.some((call) => call[0].currentOption === "popup")).toBe(true);
// PopupTab content checks
expect(screen.getByText("environments.surveys.summary.embed_pop_up_survey_title")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_instructions")).toBeInTheDocument();
expect(screen.getByRole("list")).toBeInTheDocument(); // Check for the ol element
const listItems = screen.getAllByRole("listitem");
expect(listItems[0]).toHaveTextContent(
"common.follow_these environments.surveys.summary.setup_instructions environments.surveys.summary.to_connect_your_website_with_formbricks"
);
expect(listItems[1]).toHaveTextContent(
"environments.surveys.summary.make_sure_the_survey_type_is_set_to common.website_survey"
);
expect(listItems[2]).toHaveTextContent(
"environments.surveys.summary.define_when_and_where_the_survey_should_pop_up"
);
expect(
screen.getByRole("link", { name: "environments.surveys.summary.setup_instructions" })
).toHaveAttribute("href", `/environments/${environmentId}/project/website-connection`);
expect(
screen.getByText("environments.surveys.summary.unsupported_video_tag_warning").closest("video")
).toBeInTheDocument();
});
describe("StaticTab", () => {
const formattedBaseCode = `<div style="position: relative; height:80dvh; overflow:auto;"> \n <iframe \n src="${surveyUrl}" \n frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">\n </iframe>\n</div>`;
const normalizedBaseCode = `<div style="position: relative; height:80dvh; overflow:auto;"> <iframe src="${surveyUrl}" frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;"> </iframe> </div>`;
const formattedEmbedCode = `<div style="position: relative; height:80dvh; overflow:auto;"> \n <iframe \n src="${surveyUrl}?embed=true" \n frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">\n </iframe>\n</div>`;
const normalizedEmbedCode = `<div style="position: relative; height:80dvh; overflow:auto;"> <iframe src="${surveyUrl}?embed=true" frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;"> </iframe> </div>`;
test("renders correctly with initial iframe code and embed mode toggle", () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />); // Defaults to StaticTab
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedBaseCode);
expect(mockCodeBlock).toHaveBeenCalledWith(
expect.objectContaining({ children: formattedBaseCode, language: "html" })
);
expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument();
expect(mockAdvancedOptionToggle).toHaveBeenCalledWith(
expect.objectContaining({
isChecked: false,
title: "environments.surveys.summary.embed_mode",
description: "environments.surveys.summary.embed_mode_description",
})
);
expect(screen.getByText("environments.surveys.summary.embed_mode")).toBeInTheDocument();
});
test("copies iframe code to clipboard when 'Copy Code' is clicked", async () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const copyButton = screen.getByRole("button", { name: "Embed survey in your website" });
await userEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledWith(formattedBaseCode);
expect(toast.success).toHaveBeenCalledWith(
"environments.surveys.summary.embed_code_copied_to_clipboard"
);
expect(screen.getByText("common.copy_code")).toBeInTheDocument();
});
test("updates iframe code when 'Embed Mode' is toggled", async () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const embedToggle = screen
.getByTestId("advanced-option-toggle")
.querySelector('input[type="checkbox"]');
expect(embedToggle).not.toBeNull();
await userEvent.click(embedToggle!);
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedEmbedCode);
expect(mockCodeBlock.mock.calls.find((call) => call[0].children === formattedEmbedCode)).toBeTruthy();
expect(mockAdvancedOptionToggle.mock.calls.some((call) => call[0].isChecked === true)).toBe(true);
// Toggle back
await userEvent.click(embedToggle!);
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedBaseCode);
expect(mockCodeBlock.mock.calls.find((call) => call[0].children === formattedBaseCode)).toBeTruthy();
expect(mockAdvancedOptionToggle.mock.calls.some((call) => call[0].isChecked === false)).toBe(true);
});
});
describe("PopupTab", () => {
beforeEach(async () => {
// Ensure PopupTab is active
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const popupButton = screen.getByRole("button", {
name: "environments.surveys.summary.dynamic_popup",
});
await userEvent.click(popupButton);
});
test("renders title and instructions", () => {
expect(screen.getByText("environments.surveys.summary.embed_pop_up_survey_title")).toBeInTheDocument();
const listItems = screen.getAllByRole("listitem");
expect(listItems).toHaveLength(3);
expect(listItems[0]).toHaveTextContent(
"common.follow_these environments.surveys.summary.setup_instructions environments.surveys.summary.to_connect_your_website_with_formbricks"
);
expect(listItems[1]).toHaveTextContent(
"environments.surveys.summary.make_sure_the_survey_type_is_set_to common.website_survey"
);
expect(listItems[2]).toHaveTextContent(
"environments.surveys.summary.define_when_and_where_the_survey_should_pop_up"
);
// Specific checks for elements or distinct text content
expect(screen.getByText("environments.surveys.summary.setup_instructions")).toBeInTheDocument(); // Checks the link text
expect(screen.getByText("common.website_survey")).toBeInTheDocument(); // Checks the bold text
// The text for the last list item is its sole content, so getByText works here.
expect(
screen.getByText("environments.surveys.summary.define_when_and_where_the_survey_should_pop_up")
).toBeInTheDocument();
});
test("renders the setup instructions link with correct href", () => {
const link = screen.getByRole("link", { name: "environments.surveys.summary.setup_instructions" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", `/environments/${environmentId}/project/website-connection`);
expect(link).toHaveAttribute("target", "_blank");
});
test("renders the video", () => {
const videoElement = screen
.getByText("environments.surveys.summary.unsupported_video_tag_warning")
.closest("video");
expect(videoElement).toBeInTheDocument();
expect(videoElement).toHaveAttribute("autoPlay");
expect(videoElement).toHaveAttribute("loop");
const sourceElement = videoElement?.querySelector("source");
expect(sourceElement).toHaveAttribute("src", "/video/tooltips/change-survey-type.mp4");
expect(sourceElement).toHaveAttribute("type", "video/mp4");
expect(
screen.getByText("environments.surveys.summary.unsupported_video_tag_warning")
).toBeInTheDocument();
});
});
});

View File

@@ -1,118 +0,0 @@
"use client";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
import { useTranslate } from "@tolgee/react";
import { CopyIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
export const WebsiteTab = ({ surveyUrl, environmentId }) => {
const [selectedTab, setSelectedTab] = useState("static");
const { t } = useTranslate();
return (
<div className="flex h-full grow flex-col">
<OptionsSwitch
options={[
{ value: "static", label: t("environments.surveys.summary.static_iframe") },
{ value: "popup", label: t("environments.surveys.summary.dynamic_popup") },
]}
currentOption={selectedTab}
handleOptionChange={(value) => setSelectedTab(value)}
/>
<div className="mt-4">
{selectedTab === "static" ? (
<StaticTab surveyUrl={surveyUrl} />
) : (
<PopupTab environmentId={environmentId} />
)}
</div>
</div>
);
};
const StaticTab = ({ surveyUrl }) => {
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
const { t } = useTranslate();
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
</iframe>
</div>`;
return (
<div className="flex h-full grow flex-col">
<div className="flex justify-between">
<div></div>
<Button
title="Embed survey in your website"
aria-label="Embed survey in your website"
onClick={() => {
navigator.clipboard.writeText(iframeCode);
toast.success(t("environments.surveys.summary.embed_code_copied_to_clipboard"));
}}>
{t("common.copy_code")}
<CopyIcon />
</Button>
</div>
<div className="prose prose-slate max-w-full">
<CodeBlock
customCodeClass="text-sm h-48 overflow-y-scroll text-sm"
language="html"
showCopyToClipboard={false}>
{iframeCode}
</CodeBlock>
</div>
<div className="mt-2 rounded-md border bg-white p-4">
<AdvancedOptionToggle
htmlId="enableEmbedMode"
isChecked={embedModeEnabled}
onToggle={setEmbedModeEnabled}
title={t("environments.surveys.summary.embed_mode")}
description={t("environments.surveys.summary.embed_mode_description")}
childBorder={true}
/>
</div>
</div>
);
};
const PopupTab = ({ environmentId }) => {
const { t } = useTranslate();
return (
<div>
<p className="text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.embed_pop_up_survey_title")}
</p>
<ol className="mt-4 list-decimal space-y-2 pl-5 text-sm text-slate-700">
<li>
{t("common.follow_these")}{" "}
<Link
href={`/environments/${environmentId}/project/website-connection`}
target="_blank"
className="decoration-brand-dark font-medium underline underline-offset-2">
{t("environments.surveys.summary.setup_instructions")}
</Link>{" "}
{t("environments.surveys.summary.to_connect_your_website_with_formbricks")}
</li>
<li>
{t("environments.surveys.summary.make_sure_the_survey_type_is_set_to")}{" "}
<b>{t("common.website_survey")}</b>
</li>
<li>{t("environments.surveys.summary.define_when_and_where_the_survey_should_pop_up")}</li>
</ol>
<div className="mt-4">
<video autoPlay loop muted className="w-full rounded-xl border border-slate-200">
<source src="/video/tooltips/change-survey-type.mp4" type="video/mp4" />
{t("environments.surveys.summary.unsupported_video_tag_warning")}
</video>
</div>
</div>
);
};

View File

@@ -0,0 +1,381 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AnonymousLinksTab } from "./anonymous-links-tab";
// Mock actions
vi.mock("../../actions", () => ({
updateSingleUseLinksAction: vi.fn(),
}));
vi.mock("@/modules/survey/list/actions", () => ({
generateSingleUseIdsAction: vi.fn(),
}));
// Mock components
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: ({ surveyUrl, publicDomain }: any) => (
<div data-testid="share-survey-link">
<p>Survey URL: {surveyUrl}</p>
<p>Public Domain: {publicDomain}</p>
</div>
),
}));
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
AdvancedOptionToggle: ({ children, htmlId, isChecked, onToggle, title }: any) => (
<div data-testid={`toggle-${htmlId}`} data-checked={isChecked}>
<button data-testid={`toggle-button-${htmlId}`} onClick={() => onToggle(!isChecked)}>
{title}
</button>
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant, size }: any) => (
<div data-testid={`alert-${variant}`} data-size={size}>
{children}
</div>
),
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, disabled, variant }: any) => (
<button onClick={onClick} disabled={disabled} data-variant={variant}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/input", () => ({
Input: ({ value, onChange, type, max, min, className }: any) => (
<input
type={type}
max={max}
min={min}
className={className}
value={value}
onChange={onChange}
data-testid="number-input"
/>
),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container",
() => ({
TabContainer: ({ children, title }: any) => (
<div data-testid="tab-container">
<h2>{title}</h2>
{children}
</div>
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal",
() => ({
DisableLinkModal: ({ open, type, onDisable }: any) => (
<div data-testid="disable-link-modal" data-open={open} data-type={type}>
<button onClick={() => onDisable()}>Confirm</button>
<button>Close</button>
</div>
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links",
() => ({
DocumentationLinks: ({ links }: any) => (
<div data-testid="documentation-links">
{links.map((link: any, index: number) => (
<a key={index} href={link.href}>
{link.title}
</a>
))}
</div>
),
})
);
// Mock translations
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock Next.js router
const mockRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: mockRefresh,
}),
}));
// Mock toast
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
// Mock URL and Blob for download functionality
global.URL.createObjectURL = vi.fn(() => "mock-url");
global.URL.revokeObjectURL = vi.fn();
global.Blob = vi.fn(() => ({}) as any);
describe("AnonymousLinksTab", () => {
const mockSurvey = {
id: "test-survey-id",
environmentId: "test-env-id",
type: "link" as const,
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
createdBy: null,
status: "draft" as const,
questions: [],
thankYouCard: { enabled: false },
welcomeCard: { enabled: false },
hiddenFields: { enabled: false },
singleUse: {
enabled: false,
isEncrypted: false,
},
} as unknown as TSurvey;
const surveyWithSingleUse = {
...mockSurvey,
singleUse: {
enabled: true,
isEncrypted: false,
},
} as TSurvey;
const surveyWithEncryption = {
...mockSurvey,
singleUse: {
enabled: true,
isEncrypted: true,
},
} as TSurvey;
const defaultProps = {
survey: mockSurvey,
surveyUrl: "https://example.com/survey",
publicDomain: "https://example.com",
setSurveyUrl: vi.fn(),
locale: "en-US" as TUserLocale,
};
beforeEach(async () => {
vi.clearAllMocks();
const { updateSingleUseLinksAction } = await import("../../actions");
const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions");
vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: mockSurvey });
vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: ["link1", "link2"] });
});
afterEach(() => {
cleanup();
});
test("renders with single-use link enabled when survey has singleUse enabled", () => {
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
expect(screen.getByTestId("toggle-multi-use-link-switch")).toHaveAttribute("data-checked", "false");
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
});
test("handles multi-use toggle when single-use is disabled", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
render(<AnonymousLinksTab {...defaultProps} />);
// When multi-use is enabled and we click it, it should show a modal to turn it off
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
// Should show confirmation modal
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use");
// Confirm the modal action
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
await waitFor(() => {
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
environmentId: "test-env-id",
isSingleUse: true,
isSingleUseEncryption: true,
});
});
expect(mockRefresh).toHaveBeenCalled();
});
test("shows confirmation modal when toggling from single-use to multi-use", async () => {
const user = userEvent.setup();
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "single-use");
});
test("shows confirmation modal when toggling from multi-use to single-use", async () => {
const user = userEvent.setup();
render(<AnonymousLinksTab {...defaultProps} />);
const singleUseToggle = screen.getByTestId("toggle-button-single-use-link-switch");
await user.click(singleUseToggle);
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use");
});
test("handles single-use encryption toggle", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const encryptionToggle = screen.getByTestId("toggle-button-single-use-encryption-switch");
await user.click(encryptionToggle);
await waitFor(() => {
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
environmentId: "test-env-id",
isSingleUse: true,
isSingleUseEncryption: true,
});
});
});
test("shows encryption info alert when encryption is disabled", () => {
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const alerts = screen.getAllByTestId("alert-info");
const encryptionAlert = alerts.find(
(alert) =>
alert.querySelector('[data-testid="alert-title"]')?.textContent ===
"environments.surveys.share.anonymous_links.custom_single_use_id_title"
);
expect(encryptionAlert).toBeInTheDocument();
expect(encryptionAlert?.querySelector('[data-testid="alert-title"]')).toHaveTextContent(
"environments.surveys.share.anonymous_links.custom_single_use_id_title"
);
});
test("shows link generation section when encryption is enabled", () => {
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
expect(screen.getByTestId("number-input")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.generate_and_download_links")
).toBeInTheDocument();
});
test("handles number of links input change", async () => {
const user = userEvent.setup();
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
const input = screen.getByTestId("number-input");
await user.clear(input);
await user.type(input, "5");
expect(input).toHaveValue(5);
});
test("handles link generation error", async () => {
const user = userEvent.setup();
const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions");
vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: undefined });
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
const generateButton = screen.getByText(
"environments.surveys.share.anonymous_links.generate_and_download_links"
);
await user.click(generateButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.share.anonymous_links.generate_links_error"
);
});
});
test("handles action error with generic message", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: undefined });
render(<AnonymousLinksTab {...defaultProps} />);
// Click multi-use toggle to show modal
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
// Confirm the modal action
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
});
});
test("confirms modal action when disable link modal is confirmed", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
await waitFor(() => {
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
environmentId: "test-env-id",
isSingleUse: false,
isSingleUseEncryption: false,
});
});
});
test("renders documentation links", () => {
render(<AnonymousLinksTab {...defaultProps} />);
expect(screen.getByTestId("documentation-links")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.single_use_links")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.data_prefilling")
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,364 @@
"use client";
import { updateSingleUseLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
import { DisableLinkModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal";
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { CirclePlayIcon, CopyIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface AnonymousLinksTabProps {
survey: TSurvey;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}
export const AnonymousLinksTab = ({
survey,
surveyUrl,
publicDomain,
setSurveyUrl,
locale,
}: AnonymousLinksTabProps) => {
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
const router = useRouter();
const { t } = useTranslate();
const [isMultiUseLink, setIsMultiUseLink] = useState(!survey.singleUse?.enabled);
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
const [disableLinkModal, setDisableLinkModal] = useState<{
open: boolean;
type: "multi-use" | "single-use";
pendingAction: () => Promise<void> | void;
} | null>(null);
const resetState = () => {
const { singleUse } = survey;
const { enabled, isEncrypted } = singleUse ?? {};
setIsMultiUseLink(!enabled);
setIsSingleUseLink(enabled ?? false);
setSingleUseEncryption(isEncrypted ?? false);
};
const updateSingleUseSettings = async (
isSingleUse: boolean,
isSingleUseEncryption: boolean
): Promise<void> => {
try {
const updatedSurveyResponse = await updateSingleUseLinksAction({
surveyId: survey.id,
environmentId: survey.environmentId,
isSingleUse,
isSingleUseEncryption,
});
if (updatedSurveyResponse?.data) {
router.refresh();
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
resetState();
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
resetState();
}
};
const handleMultiUseToggle = async (newValue: boolean) => {
if (newValue) {
// Turning multi-use on - show confirmation modal if single-use is currently enabled
if (isSingleUseLink) {
setDisableLinkModal({
open: true,
type: "single-use",
pendingAction: async () => {
setIsMultiUseLink(true);
setIsSingleUseLink(false);
setSingleUseEncryption(false);
await updateSingleUseSettings(false, false);
},
});
} else {
// Single-use is already off, just enable multi-use
setIsMultiUseLink(true);
setIsSingleUseLink(false);
setSingleUseEncryption(false);
await updateSingleUseSettings(false, false);
}
} else {
// Turning multi-use off - need confirmation and turn single-use on
setDisableLinkModal({
open: true,
type: "multi-use",
pendingAction: async () => {
setIsMultiUseLink(false);
setIsSingleUseLink(true);
setSingleUseEncryption(true);
await updateSingleUseSettings(true, true);
},
});
}
};
const handleSingleUseToggle = async (newValue: boolean) => {
if (newValue) {
// Turning single-use on - turn multi-use off
setDisableLinkModal({
open: true,
type: "multi-use",
pendingAction: async () => {
setIsMultiUseLink(false);
setIsSingleUseLink(true);
setSingleUseEncryption(true);
await updateSingleUseSettings(true, true);
},
});
} else {
// Turning single-use off - show confirmation modal and then turn multi-use on
setDisableLinkModal({
open: true,
type: "single-use",
pendingAction: async () => {
setIsMultiUseLink(true);
setIsSingleUseLink(false);
setSingleUseEncryption(false);
await updateSingleUseSettings(false, false);
},
});
}
};
const handleSingleUseEncryptionToggle = async (newValue: boolean) => {
setSingleUseEncryption(newValue);
await updateSingleUseSettings(true, newValue);
};
const handleNumberOfLinksChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
if (inputValue === "") {
setNumberOfLinks("");
return;
}
const value = Number(inputValue);
if (!isNaN(value)) {
setNumberOfLinks(value);
}
};
const handleGenerateLinks = async (count: number) => {
try {
const response = await generateSingleUseIdsAction({
surveyId: survey.id,
isEncrypted: singleUseEncryption,
count,
});
if (!!response?.data?.length) {
const singleUseIds = response.data;
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
// Create content with just the links
const csvContent = surveyLinks.join("\n");
// Create and download the file
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `single-use-links-${survey.id}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return;
}
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
} catch (error) {
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
}
};
return (
<>
<div className="flex h-full flex-col justify-between space-y-4">
<div className="flex w-full grow flex-col gap-6">
<AdvancedOptionToggle
htmlId="multi-use-link-switch"
isChecked={isMultiUseLink}
onToggle={handleMultiUseToggle}
title={t("environments.surveys.share.anonymous_links.multi_use_link")}
description={t("environments.surveys.share.anonymous_links.multi_use_link_description")}
customContainerClass="pl-1 pr-0 py-0"
childBorder>
<div className="flex w-full flex-col gap-4 overflow-hidden bg-white p-4">
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
<div className="w-full">
<Alert variant="info" size="default">
<AlertTitle>
{t("environments.surveys.share.anonymous_links.multi_use_powers_other_channels_title")}
</AlertTitle>
<AlertDescription>
{t(
"environments.surveys.share.anonymous_links.multi_use_powers_other_channels_description"
)}
</AlertDescription>
</Alert>
</div>
</div>
</AdvancedOptionToggle>
<AdvancedOptionToggle
htmlId="single-use-link-switch"
isChecked={isSingleUseLink}
onToggle={handleSingleUseToggle}
title={t("environments.surveys.share.anonymous_links.single_use_link")}
description={t("environments.surveys.share.anonymous_links.single_use_link_description")}
customContainerClass="pl-1 pr-0 py-0"
childBorder>
<div className="flex w-full flex-col gap-4 bg-white p-4">
<AdvancedOptionToggle
htmlId="single-use-encryption-switch"
isChecked={singleUseEncryption}
onToggle={handleSingleUseEncryptionToggle}
title={t("environments.surveys.share.anonymous_links.url_encryption_label")}
description={t("environments.surveys.share.anonymous_links.url_encryption_description")}
customContainerClass="pl-1 pr-0 py-0"
/>
{!singleUseEncryption ? (
<div className="flex w-full flex-col gap-4">
<Alert variant="info" size="default">
<AlertTitle>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
</AlertTitle>
<AlertDescription>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
</AlertDescription>
</Alert>
<div className="grid w-full grid-cols-6 items-center gap-2">
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
</div>
<Button
variant="secondary"
onClick={() => {
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
toast.success(t("common.copied_to_clipboard"));
}}
className="col-span-1 gap-1 text-sm">
{t("common.copy")}
<CopyIcon />
</Button>
</div>
</div>
) : null}
{singleUseEncryption && (
<div className="flex w-full flex-col gap-2">
<h3 className="text-sm font-medium text-slate-900">
{t("environments.surveys.share.anonymous_links.number_of_links_label")}
</h3>
<div className="flex w-full flex-col gap-2">
<div className="flex w-full items-center gap-2">
<div className="w-32">
<Input
type="number"
max={5000}
min={1}
className="bg-white focus:border focus:border-slate-900"
value={numberOfLinks}
onChange={handleNumberOfLinksChange}
/>
</div>
<Button
variant="default"
onClick={() => handleGenerateLinks(Number(numberOfLinks) || 1)}
disabled={Number(numberOfLinks) < 1 || Number(numberOfLinks) > 5000}>
<div className="flex items-center gap-2">
<CirclePlayIcon className="h-3.5 w-3.5 shrink-0 text-slate-50" />
</div>
<span className="text-sm text-slate-50">
{t("environments.surveys.share.anonymous_links.generate_and_download_links")}
</span>
</Button>
</div>
</div>
</div>
)}
</div>
</AdvancedOptionToggle>
</div>
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.anonymous_links.single_use_links"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/single-use-links",
},
{
title: t("environments.surveys.share.anonymous_links.data_prefilling"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
},
{
title: t("environments.surveys.share.anonymous_links.source_tracking"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/source-tracking",
},
{
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
},
]}
/>
</div>
{disableLinkModal && (
<DisableLinkModal
open={disableLinkModal.open}
onOpenChange={() => setDisableLinkModal(null)}
type={disableLinkModal.type}
onDisable={() => {
disableLinkModal.pendingAction();
setDisableLinkModal(null);
}}
/>
)}
</>
);
};

View File

@@ -0,0 +1,383 @@
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { SurveyContextWrapper } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass, TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
import { TBaseFilter, TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
import { AppTab } from "./app-tab";
// Mock Next.js Link component
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// Mock DocumentationLinksSection
vi.mock("./documentation-links-section", () => ({
DocumentationLinksSection: ({ title, links }: { title: string; links: any[] }) => (
<div data-testid="documentation-links">
<h4>{title}</h4>
{links.map((link) => (
<div key={link.href} data-testid="documentation-link">
<a href={link.href}>{link.title}</a>
</div>
))}
</div>
),
}));
// Mock segment
const mockSegment: TSegment = {
id: "test-segment-id",
title: "Test Segment",
description: "Test segment description",
environmentId: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
isPrivate: false,
filters: [
{
id: "test-filter-id",
connector: "and",
resource: "contact",
attributeKey: "test-attribute-key",
attributeType: "string",
condition: "equals",
value: "test",
} as unknown as TBaseFilter,
],
surveys: ["test-survey-id"],
};
// Mock action class
const mockActionClass: TActionClass = {
id: "test-action-id",
name: "Test Action",
type: "code",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
description: "Test action description",
noCodeConfig: null,
key: "test-action-key",
};
const mockNoCodeActionClass: TActionClass = {
id: "test-no-code-action-id",
name: "Test No Code Action",
type: "noCode",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
description: "Test no code action description",
noCodeConfig: {
type: "click",
elementSelector: {
cssSelector: ".test-button",
innerHtml: "Click me",
},
} as TActionClassNoCodeConfig,
key: "test-no-code-action-key",
};
// 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",
recontactDays: 7,
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" },
highlightBorderColor: { light: "#007bff", dark: "#0056b3" },
isDarkModeEnabled: false,
isLogoHidden: false,
hideProgressBar: false,
roundness: 8,
cardArrangement: { linkSurveys: "casual", appSurveys: "casual" },
},
inAppSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
logo: { url: "test-logo.png", bgColor: "#ffffff" },
} as TProject;
// Mock survey data
const mockSurvey: TSurvey = {
id: "test-survey-id",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "app",
environmentId: "test-env-id",
status: "inProgress",
displayOption: "displayOnce",
autoClose: null,
triggers: [{ actionClass: mockActionClass }],
recontactDays: null,
displayLimit: null,
welcomeCard: { enabled: false } as unknown as TSurveyWelcomeCard,
questions: [],
endings: [],
hiddenFields: { enabled: false },
displayPercentage: null,
autoComplete: null,
segment: null,
languages: [],
showLanguageSwitch: false,
singleUse: { enabled: false, isEncrypted: false },
projectOverwrites: null,
surveyClosedMessage: null,
delay: 0,
isVerifyEmailEnabled: false,
inlineTriggers: {},
} as unknown as TSurvey;
describe("AppTab", () => {
afterEach(() => {
cleanup();
});
const renderWithProviders = (appSetupCompleted = true, surveyOverrides = {}, projectOverrides = {}) => {
const environmentWithSetup = {
...mockEnvironment,
appSetupCompleted,
};
const surveyWithOverrides = {
...mockSurvey,
...surveyOverrides,
};
const projectWithOverrides = {
...mockProject,
...projectOverrides,
};
return render(
<EnvironmentContextWrapper environment={environmentWithSetup} project={projectWithOverrides}>
<SurveyContextWrapper survey={surveyWithOverrides}>
<AppTab />
</SurveyContextWrapper>
</EnvironmentContextWrapper>
);
};
test("renders setup completed content when app setup is completed", () => {
renderWithProviders(true);
expect(screen.getByText("environments.surveys.summary.in_app.connection_title")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.in_app.connection_description")
).toBeInTheDocument();
});
test("renders setup required content when app setup is not completed", () => {
renderWithProviders(false);
expect(screen.getByText("environments.surveys.summary.in_app.no_connection_title")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.in_app.no_connection_description")
).toBeInTheDocument();
expect(screen.getByText("common.connect_formbricks")).toBeInTheDocument();
});
test("displays correct wait time when survey has recontact days", () => {
renderWithProviders(true, { recontactDays: 5 });
expect(
screen.getByText("5 environments.surveys.summary.in_app.display_criteria.time_based_days")
).toBeInTheDocument();
expect(
screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)")
).toBeInTheDocument();
});
test("displays correct wait time when survey has 1 recontact day", () => {
renderWithProviders(true, { recontactDays: 1 });
expect(
screen.getByText("1 environments.surveys.summary.in_app.display_criteria.time_based_day")
).toBeInTheDocument();
expect(
screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)")
).toBeInTheDocument();
});
test("displays correct wait time when survey has 0 recontact days", () => {
renderWithProviders(true, { recontactDays: 0 });
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always")
).toBeInTheDocument();
expect(
screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)")
).toBeInTheDocument();
});
test("displays project recontact days when survey has no recontact days", () => {
renderWithProviders(true, { recontactDays: null }, { recontactDays: 3 });
expect(
screen.getByText("3 environments.surveys.summary.in_app.display_criteria.time_based_days")
).toBeInTheDocument();
});
test("displays always when project has 0 recontact days", () => {
renderWithProviders(true, { recontactDays: null }, { recontactDays: 0 });
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always")
).toBeInTheDocument();
});
test("displays always when both survey and project have null recontact days", () => {
renderWithProviders(true, { recontactDays: null }, { recontactDays: null });
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always")
).toBeInTheDocument();
});
test("displays correct display option for displayOnce", () => {
renderWithProviders(true, { displayOption: "displayOnce" });
expect(screen.getByText("environments.surveys.edit.show_only_once")).toBeInTheDocument();
});
test("displays correct display option for displayMultiple", () => {
renderWithProviders(true, { displayOption: "displayMultiple" });
expect(screen.getByText("environments.surveys.edit.until_they_submit_a_response")).toBeInTheDocument();
});
test("displays correct display option for respondMultiple", () => {
renderWithProviders(true, { displayOption: "respondMultiple" });
expect(
screen.getByText("environments.surveys.edit.keep_showing_while_conditions_match")
).toBeInTheDocument();
});
test("displays correct display option for displaySome", () => {
renderWithProviders(true, { displayOption: "displaySome" });
expect(screen.getByText("environments.surveys.edit.show_multiple_times")).toBeInTheDocument();
});
test("displays everyone when survey has no segment", () => {
renderWithProviders(true, { segment: null });
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.everyone")
).toBeInTheDocument();
});
test("displays targeted when survey has segment with filters", () => {
renderWithProviders(true, {
segment: mockSegment,
});
expect(screen.getByText("Test Segment")).toBeInTheDocument();
});
test("displays segment title when survey has public segment with filters", () => {
const publicSegment = { ...mockSegment, isPrivate: false, title: "Public Segment" };
renderWithProviders(true, {
segment: publicSegment,
});
expect(screen.getByText("Public Segment")).toBeInTheDocument();
});
test("displays targeted when survey has private segment with filters", () => {
const privateSegment = { ...mockSegment, isPrivate: true };
renderWithProviders(true, {
segment: privateSegment,
});
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.targeted")
).toBeInTheDocument();
});
test("displays everyone when survey has segment with no filters", () => {
const emptySegment = { ...mockSegment, filters: [] };
renderWithProviders(true, {
segment: emptySegment,
});
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.everyone")
).toBeInTheDocument();
});
test("displays code trigger description correctly", () => {
renderWithProviders(true, { triggers: [{ actionClass: mockActionClass }] });
expect(screen.getByText("Test Action")).toBeInTheDocument();
expect(
screen.getByText("(environments.surveys.summary.in_app.display_criteria.code_trigger)")
).toBeInTheDocument();
});
test("displays no-code trigger description correctly", () => {
renderWithProviders(true, { triggers: [{ actionClass: mockNoCodeActionClass }] });
expect(screen.getByText("Test No Code Action")).toBeInTheDocument();
expect(
screen.getByText(
"(environments.surveys.summary.in_app.display_criteria.no_code_trigger, environments.actions.click)"
)
).toBeInTheDocument();
});
test("displays randomizer when displayPercentage is set", () => {
renderWithProviders(true, { displayPercentage: 25 });
expect(
screen.getAllByText(/environments\.surveys\.summary\.in_app\.display_criteria\.randomizer/)[0]
).toBeInTheDocument();
});
test("does not display randomizer when displayPercentage is null", () => {
renderWithProviders(true, { displayPercentage: null });
expect(screen.queryByText("Show to")).not.toBeInTheDocument();
});
test("does not display randomizer when displayPercentage is 0", () => {
renderWithProviders(true, { displayPercentage: 0 });
expect(screen.queryByText("Show to")).not.toBeInTheDocument();
});
test("renders documentation links section", () => {
renderWithProviders(true);
expect(screen.getByTestId("documentation-links")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.in_app.documentation_title")).toBeInTheDocument();
});
test("renders all display criteria items", () => {
renderWithProviders(true);
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_description")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.audience_description")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.trigger_description")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.recontact_description")
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,238 @@
"use client";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { H4, InlineSmall, Small } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
import {
CodeXmlIcon,
MousePointerClickIcon,
PercentIcon,
Repeat1Icon,
TimerResetIcon,
UsersIcon,
} from "lucide-react";
import Link from "next/link";
import { ReactNode, useMemo } from "react";
import { TActionClass } from "@formbricks/types/action-classes";
import { TSegment } from "@formbricks/types/segment";
import { DocumentationLinksSection } from "./documentation-links-section";
const createDocumentationLinks = (t: ReturnType<typeof useTranslate>["t"]) => [
{
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#html",
title: t("environments.surveys.summary.in_app.html_embed"),
},
{
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-js",
title: t("environments.surveys.summary.in_app.javascript_sdk"),
},
{
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#swift",
title: t("environments.surveys.summary.in_app.ios_sdk"),
},
{
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#android",
title: t("environments.surveys.summary.in_app.kotlin_sdk"),
},
{
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-native",
title: t("environments.surveys.summary.in_app.react_native_sdk"),
},
];
const createNoCodeConfigType = (t: ReturnType<typeof useTranslate>["t"]) => ({
click: t("environments.actions.click"),
pageView: t("environments.actions.page_view"),
exitIntent: t("environments.actions.exit_intent"),
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
});
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslate>["t"]) => {
if (days === 0) {
return t("environments.surveys.summary.in_app.display_criteria.time_based_always");
} else if (days === 1) {
return `${days} ${t("environments.surveys.summary.in_app.display_criteria.time_based_day")}`;
} else {
return `${days} ${t("environments.surveys.summary.in_app.display_criteria.time_based_days")}`;
}
};
interface DisplayCriteriaItemProps {
icon: ReactNode;
title: ReactNode;
titleSuffix?: ReactNode;
description: ReactNode;
}
const DisplayCriteriaItem = ({ icon, title, titleSuffix, description }: DisplayCriteriaItemProps) => {
return (
<div className="grid grid-cols-[auto_1fr] gap-x-2">
<div className="flex items-center justify-center">{icon}</div>
<div className="flex items-center">
<Small>
{title} {titleSuffix && <InlineSmall>{titleSuffix}</InlineSmall>}
</Small>
</div>
<div />
<div className="flex items-start">
<Small color="muted" margin="headerDescription">
{description}
</Small>
</div>
</div>
);
};
export const AppTab = () => {
const { t } = useTranslate();
const { environment, project } = useEnvironment();
const { survey } = useSurvey();
const documentationLinks = useMemo(() => createDocumentationLinks(t), [t]);
const noCodeConfigType = useMemo(() => createNoCodeConfigType(t), [t]);
const waitTime = () => {
if (survey.recontactDays !== null) {
return formatRecontactDaysString(survey.recontactDays, t);
}
if (project.recontactDays !== null) {
return formatRecontactDaysString(project.recontactDays, t);
}
return t("environments.surveys.summary.in_app.display_criteria.time_based_always");
};
const displayOption = () => {
if (survey.displayOption === "displayOnce") {
return t("environments.surveys.edit.show_only_once");
} else if (survey.displayOption === "displayMultiple") {
return t("environments.surveys.edit.until_they_submit_a_response");
} else if (survey.displayOption === "respondMultiple") {
return t("environments.surveys.edit.keep_showing_while_conditions_match");
} else if (survey.displayOption === "displaySome") {
return t("environments.surveys.edit.show_multiple_times");
}
// Default fallback for undefined or unexpected displayOption values
return t("environments.surveys.edit.show_only_once");
};
const getTriggerDescription = (
actionClass: TActionClass,
noCodeConfigTypeParam: ReturnType<typeof createNoCodeConfigType>
) => {
if (actionClass.type === "code") {
return `(${t("environments.surveys.summary.in_app.display_criteria.code_trigger")})`;
} else {
const configType = actionClass.noCodeConfig?.type;
let configTypeLabel = "unknown";
if (configType && configType in noCodeConfigTypeParam) {
configTypeLabel = noCodeConfigTypeParam[configType];
} else if (configType) {
configTypeLabel = configType;
}
return `(${t("environments.surveys.summary.in_app.display_criteria.no_code_trigger")}, ${configTypeLabel})`;
}
};
const getSegmentTitle = (segment: TSegment | null) => {
if (segment?.filters?.length && segment.filters.length > 0) {
return segment.isPrivate
? t("environments.surveys.summary.in_app.display_criteria.targeted")
: segment.title;
}
return t("environments.surveys.summary.in_app.display_criteria.everyone");
};
return (
<div className="flex flex-col justify-between space-y-6 pb-4">
<div className="flex flex-col space-y-6">
<Alert variant={environment.appSetupCompleted ? "success" : "warning"} size="default">
<AlertTitle>
{environment.appSetupCompleted
? t("environments.surveys.summary.in_app.connection_title")
: t("environments.surveys.summary.in_app.no_connection_title")}
</AlertTitle>
<AlertDescription>
{environment.appSetupCompleted
? t("environments.surveys.summary.in_app.connection_description")
: t("environments.surveys.summary.in_app.no_connection_description")}
</AlertDescription>
{!environment.appSetupCompleted && (
<AlertButton asChild>
<Link href={`/environments/${environment.id}/project/app-connection`}>
{t("common.connect_formbricks")}
</Link>
</AlertButton>
)}
</Alert>
<div className="flex flex-col space-y-3">
<H4>{t("environments.surveys.summary.in_app.display_criteria")}</H4>
<div
className={
"flex w-full flex-col space-y-4 rounded-xl border border-slate-200 bg-white p-3 text-left shadow-sm"
}>
<DisplayCriteriaItem
icon={<TimerResetIcon className="h-4 w-4" />}
title={waitTime()}
titleSuffix={
survey.recontactDays !== null
? `(${t("environments.surveys.summary.in_app.display_criteria.overwritten")})`
: undefined
}
description={t("environments.surveys.summary.in_app.display_criteria.time_based_description")}
/>
<DisplayCriteriaItem
icon={<UsersIcon className="h-4 w-4" />}
title={getSegmentTitle(survey.segment)}
description={t("environments.surveys.summary.in_app.display_criteria.audience_description")}
/>
{survey.triggers.map((trigger) => (
<DisplayCriteriaItem
key={trigger.actionClass.id}
icon={
trigger.actionClass.type === "code" ? (
<CodeXmlIcon className="h-4 w-4" />
) : (
<MousePointerClickIcon className="h-4 w-4" />
)
}
title={trigger.actionClass.name}
titleSuffix={getTriggerDescription(trigger.actionClass, noCodeConfigType)}
description={t("environments.surveys.summary.in_app.display_criteria.trigger_description")}
/>
))}
{survey.displayPercentage !== null && survey.displayPercentage > 0 && (
<DisplayCriteriaItem
icon={<PercentIcon className="h-4 w-4" />}
title={t("environments.surveys.summary.in_app.display_criteria.randomizer", {
percentage: survey.displayPercentage,
})}
description={t(
"environments.surveys.summary.in_app.display_criteria.randomizer_description",
{
percentage: survey.displayPercentage,
}
)}
/>
)}
<DisplayCriteriaItem
icon={<Repeat1Icon className="h-4 w-4" />}
title={displayOption()}
description={t("environments.surveys.summary.in_app.display_criteria.recontact_description")}
/>
</div>
</div>
</div>
<DocumentationLinksSection
title={t("environments.surveys.summary.in_app.documentation_title")}
links={documentationLinks}
/>
</div>
);
};

View File

@@ -0,0 +1,95 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DisableLinkModal } from "./disable-link-modal";
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
const onOpenChange = vi.fn();
const onDisable = vi.fn();
describe("DisableLinkModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("should render the modal for multi-use link", () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
expect(
screen.getByText("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description")
).toBeInTheDocument();
expect(
screen.getByText(
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext"
)
).toBeInTheDocument();
});
test("should render the modal for single-use link", () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="single-use" onDisable={onDisable} />
);
expect(screen.getByText("common.are_you_sure")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description")
).toBeInTheDocument();
});
test("should call onDisable and onOpenChange when the disable button is clicked for multi-use", async () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
const disableButton = screen.getByText(
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_button"
);
await userEvent.click(disableButton);
expect(onDisable).toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
test("should call onDisable and onOpenChange when the disable button is clicked for single-use", async () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="single-use" onDisable={onDisable} />
);
const disableButton = screen.getByText(
"environments.surveys.share.anonymous_links.disable_single_use_link_modal_button"
);
await userEvent.click(disableButton);
expect(onDisable).toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
test("should call onOpenChange when the cancel button is clicked", async () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
const cancelButton = screen.getByText("common.cancel");
await userEvent.click(cancelButton);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
test("should not render the modal when open is false", () => {
const { container } = render(
<DisableLinkModal open={false} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
expect(container.firstChild).toBeNull();
});
});

View File

@@ -0,0 +1,71 @@
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
interface DisableLinkModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
type: "multi-use" | "single-use";
onDisable: () => void;
}
export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: DisableLinkModalProps) => {
const { t } = useTranslate();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent width="narrow" className="flex flex-col" hideCloseButton disableCloseOnOutsideClick>
<DialogHeader className="text-sm font-medium text-slate-900">
{type === "multi-use"
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
: t("common.are_you_sure")}
</DialogHeader>
<DialogBody>
{type === "multi-use" ? (
<>
<p>
{t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description")}
</p>
<br />
<p>
{t(
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext"
)}
</p>
</>
) : (
<p>{t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description")}</p>
)}
</DialogBody>
<DialogFooter>
<div className="flex w-full flex-col gap-2">
<Button
variant="default"
onClick={() => {
onDisable();
onOpenChange(false);
}}>
{type === "multi-use"
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_button")
: t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_button")}
</Button>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,38 @@
"use client";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { H4 } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
interface DocumentationLink {
href: string;
title: string;
}
interface DocumentationLinksSectionProps {
title: string;
links: DocumentationLink[];
}
export const DocumentationLinksSection = ({ title, links }: DocumentationLinksSectionProps) => {
const { t } = useTranslate();
return (
<div className="flex w-full flex-col space-y-3">
<H4>{title}</H4>
{links.map((link) => (
<Alert key={link.title} size="small" variant="default">
<ArrowUpRight className="size-4" />
<AlertTitle>{link.title}</AlertTitle>
<AlertButton>
<Link href={link.href} target="_blank" rel="noopener noreferrer">
{t("common.read_docs")}
</Link>
</AlertButton>
</Alert>
))}
</div>
);
};

View File

@@ -0,0 +1,102 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { DocumentationLinks } from "./documentation-links";
describe("DocumentationLinks", () => {
afterEach(() => {
cleanup();
});
const mockLinks = [
{
title: "Getting Started Guide",
href: "https://docs.formbricks.com/getting-started",
},
{
title: "API Documentation",
href: "https://docs.formbricks.com/api",
},
{
title: "Integration Guide",
href: "https://docs.formbricks.com/integrations",
},
];
test("renders all documentation links", () => {
render(<DocumentationLinks links={mockLinks} />);
expect(screen.getByText("Getting Started Guide")).toBeInTheDocument();
expect(screen.getByText("API Documentation")).toBeInTheDocument();
expect(screen.getByText("Integration Guide")).toBeInTheDocument();
});
test("renders correct number of alert components", () => {
render(<DocumentationLinks links={mockLinks} />);
const alerts = screen.getAllByRole("alert");
expect(alerts).toHaveLength(3);
});
test("renders learn more links with correct href attributes", () => {
render(<DocumentationLinks links={mockLinks} />);
const learnMoreLinks = screen.getAllByText("common.learn_more");
expect(learnMoreLinks).toHaveLength(3);
expect(learnMoreLinks[0]).toHaveAttribute("href", "https://docs.formbricks.com/getting-started");
expect(learnMoreLinks[1]).toHaveAttribute("href", "https://docs.formbricks.com/api");
expect(learnMoreLinks[2]).toHaveAttribute("href", "https://docs.formbricks.com/integrations");
});
test("renders learn more links with target blank", () => {
render(<DocumentationLinks links={mockLinks} />);
const learnMoreLinks = screen.getAllByText("common.learn_more");
learnMoreLinks.forEach((link) => {
expect(link).toHaveAttribute("target", "_blank");
});
});
test("renders learn more links with correct CSS classes", () => {
render(<DocumentationLinks links={mockLinks} />);
const learnMoreLinks = screen.getAllByText("common.learn_more");
learnMoreLinks.forEach((link) => {
expect(link).toHaveClass("text-slate-900", "hover:underline");
});
});
test("renders empty list when no links provided", () => {
render(<DocumentationLinks links={[]} />);
const alerts = screen.queryAllByRole("alert");
expect(alerts).toHaveLength(0);
});
test("renders single link correctly", () => {
const singleLink = [mockLinks[0]];
render(<DocumentationLinks links={singleLink} />);
expect(screen.getByText("Getting Started Guide")).toBeInTheDocument();
expect(screen.getByText("common.learn_more")).toBeInTheDocument();
expect(screen.getByText("common.learn_more")).toHaveAttribute(
"href",
"https://docs.formbricks.com/getting-started"
);
});
test("renders with correct container structure", () => {
const { container } = render(<DocumentationLinks links={mockLinks} />);
const mainContainer = container.firstChild as HTMLElement;
expect(mainContainer).toHaveClass("flex", "w-full", "flex-col", "space-y-2");
const linkContainers = mainContainer.children;
expect(linkContainers).toHaveLength(3);
Array.from(linkContainers).forEach((linkContainer) => {
expect(linkContainer).toHaveClass("flex", "w-full", "flex-col", "gap-3");
});
});
});

View File

@@ -0,0 +1,37 @@
"use client";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
interface DocumentationLinksProps {
links: {
title: string;
href: string;
}[];
}
export const DocumentationLinks = ({ links }: DocumentationLinksProps) => {
const { t } = useTranslate();
return (
<div className="flex w-full flex-col space-y-2">
{links.map((link) => (
<div key={link.title} className="flex w-full flex-col gap-3">
<Alert variant="outbound" size="small">
<AlertTitle>{link.title}</AlertTitle>
<AlertButton asChild>
<Link
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 hover:underline">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,165 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DocumentationLinksSection } from "./documentation-links-section";
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
if (key === "common.read_docs") {
return "Read docs";
}
return key;
},
}),
}));
// Mock Next.js Link component
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// Mock Alert components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, size, variant }: any) => (
<div data-testid="alert" data-size={size} data-variant={variant}>
{children}
</div>
),
AlertButton: ({ children }: any) => <div data-testid="alert-button">{children}</div>,
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
}));
// Mock Typography components
vi.mock("@/modules/ui/components/typography", () => ({
H4: ({ children }: any) => <h4 data-testid="h4">{children}</h4>,
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
ArrowUpRight: ({ className }: any) => <svg data-testid="arrow-up-right-icon" className={className} />,
}));
describe("DocumentationLinksSection", () => {
afterEach(() => {
cleanup();
});
const mockLinks = [
{
href: "https://example.com/docs/html",
title: "HTML Documentation",
},
{
href: "https://example.com/docs/react",
title: "React Documentation",
},
{
href: "https://example.com/docs/javascript",
title: "JavaScript Documentation",
},
];
test("renders title correctly", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
expect(screen.getByTestId("h4")).toHaveTextContent("Test Documentation Title");
});
test("renders all documentation links", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
expect(screen.getAllByTestId("alert")).toHaveLength(3);
expect(screen.getByText("HTML Documentation")).toBeInTheDocument();
expect(screen.getByText("React Documentation")).toBeInTheDocument();
expect(screen.getByText("JavaScript Documentation")).toBeInTheDocument();
});
test("renders links with correct href attributes", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
const links = screen.getAllByRole("link");
expect(links[0]).toHaveAttribute("href", "https://example.com/docs/html");
expect(links[1]).toHaveAttribute("href", "https://example.com/docs/react");
expect(links[2]).toHaveAttribute("href", "https://example.com/docs/javascript");
});
test("renders links with correct target and rel attributes", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
const links = screen.getAllByRole("link");
links.forEach((link) => {
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});
test("renders read docs button for each link", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
const readDocsButtons = screen.getAllByText("Read docs");
expect(readDocsButtons).toHaveLength(3);
});
test("renders icons for each alert", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
const icons = screen.getAllByTestId("arrow-up-right-icon");
expect(icons).toHaveLength(3);
});
test("renders alerts with correct props", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
const alerts = screen.getAllByTestId("alert");
alerts.forEach((alert) => {
expect(alert).toHaveAttribute("data-size", "small");
expect(alert).toHaveAttribute("data-variant", "default");
});
});
test("renders with empty links array", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={[]} />);
expect(screen.getByTestId("h4")).toHaveTextContent("Test Documentation Title");
expect(screen.queryByTestId("alert")).not.toBeInTheDocument();
});
test("renders single link correctly", () => {
const singleLink = [
{
href: "https://example.com/docs/single",
title: "Single Documentation",
},
];
render(<DocumentationLinksSection title="Test Documentation Title" links={singleLink} />);
expect(screen.getAllByTestId("alert")).toHaveLength(1);
expect(screen.getByText("Single Documentation")).toBeInTheDocument();
expect(screen.getByRole("link")).toHaveAttribute("href", "https://example.com/docs/single");
});
test("renders with special characters in title and links", () => {
const specialLinks = [
{
href: "https://example.com/docs/special?param=value&other=test",
title: "Special Characters & Symbols",
},
];
render(<DocumentationLinksSection title="Special Title & Characters" links={specialLinks} />);
expect(screen.getByTestId("h4")).toHaveTextContent("Special Title & Characters");
expect(screen.getByText("Special Characters & Symbols")).toBeInTheDocument();
expect(screen.getByRole("link")).toHaveAttribute(
"href",
"https://example.com/docs/special?param=value&other=test"
);
});
});

View File

@@ -0,0 +1,217 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DynamicPopupTab } from "./dynamic-popup-tab";
// Mock components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: (props: { variant?: string; size?: string; children: React.ReactNode }) => (
<div data-testid="alert" data-variant={props.variant} data-size={props.size}>
{props.children}
</div>
),
AlertButton: (props: { asChild?: boolean; children: React.ReactNode }) => (
<div data-testid="alert-button" data-as-child={props.asChild}>
{props.children}
</div>
),
AlertDescription: (props: { children: React.ReactNode }) => (
<div data-testid="alert-description">{props.children}</div>
),
AlertTitle: (props: { children: React.ReactNode }) => <div data-testid="alert-title">{props.children}</div>,
}));
// Mock DocumentationLinks
vi.mock("./documentation-links", () => ({
DocumentationLinks: (props: { links: Array<{ href: string; title: string }> }) => (
<div data-testid="documentation-links">
{props.links.map((link) => (
<div key={link.title} data-testid="documentation-link" data-href={link.href} data-title={link.title}>
{link.title}
</div>
))}
</div>
),
}));
// Mock Next.js Link
vi.mock("next/link", () => ({
default: (props: { href: string; target?: string; className?: string; children: React.ReactNode }) => (
<a href={props.href} target={props.target} className={props.className} data-testid="next-link">
{props.children}
</a>
),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
describe("DynamicPopupTab", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
environmentId: "env-123",
surveyId: "survey-123",
};
test("renders with correct container structure", () => {
render(<DynamicPopupTab {...defaultProps} />);
const container = screen.getByTestId("dynamic-popup-container");
expect(container).toHaveClass("flex", "h-full", "flex-col", "justify-between", "space-y-4");
});
test("renders alert with correct props", () => {
render(<DynamicPopupTab {...defaultProps} />);
const alert = screen.getByTestId("alert");
expect(alert).toBeInTheDocument();
expect(alert).toHaveAttribute("data-variant", "info");
expect(alert).toHaveAttribute("data-size", "default");
});
test("renders alert title with correct translation key", () => {
render(<DynamicPopupTab {...defaultProps} />);
const alertTitle = screen.getByTestId("alert-title");
expect(alertTitle).toBeInTheDocument();
expect(alertTitle).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_title");
});
test("renders alert description with correct translation key", () => {
render(<DynamicPopupTab {...defaultProps} />);
const alertDescription = screen.getByTestId("alert-description");
expect(alertDescription).toBeInTheDocument();
expect(alertDescription).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_description");
});
test("renders alert button with link to survey edit page", () => {
render(<DynamicPopupTab {...defaultProps} />);
const alertButton = screen.getByTestId("alert-button");
expect(alertButton).toBeInTheDocument();
expect(alertButton).toHaveAttribute("data-as-child", "true");
const link = screen.getByTestId("next-link");
expect(link).toHaveAttribute("href", "/environments/env-123/surveys/survey-123/edit");
expect(link).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_button");
});
test("renders DocumentationLinks component", () => {
render(<DynamicPopupTab {...defaultProps} />);
const documentationLinks = screen.getByTestId("documentation-links");
expect(documentationLinks).toBeInTheDocument();
});
test("passes correct documentation links to DocumentationLinks component", () => {
render(<DynamicPopupTab {...defaultProps} />);
const documentationLinks = screen.getAllByTestId("documentation-link");
expect(documentationLinks).toHaveLength(3);
// Check attribute-based targeting link
const attributeLink = documentationLinks.find(
(link) =>
link.getAttribute("data-href") ===
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting"
);
expect(attributeLink).toBeInTheDocument();
expect(attributeLink).toHaveAttribute(
"data-title",
"environments.surveys.share.dynamic_popup.attribute_based_targeting"
);
// Check code and no code triggers link
const actionsLink = documentationLinks.find(
(link) =>
link.getAttribute("data-href") ===
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
);
expect(actionsLink).toBeInTheDocument();
expect(actionsLink).toHaveAttribute(
"data-title",
"environments.surveys.share.dynamic_popup.code_no_code_triggers"
);
// Check recontact options link
const recontactLink = documentationLinks.find(
(link) =>
link.getAttribute("data-href") ===
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact"
);
expect(recontactLink).toBeInTheDocument();
expect(recontactLink).toHaveAttribute(
"data-title",
"environments.surveys.share.dynamic_popup.recontact_options"
);
});
test("renders documentation links with correct titles", () => {
render(<DynamicPopupTab {...defaultProps} />);
const documentationLinks = screen.getAllByTestId("documentation-link");
const expectedTitles = [
"environments.surveys.share.dynamic_popup.attribute_based_targeting",
"environments.surveys.share.dynamic_popup.code_no_code_triggers",
"environments.surveys.share.dynamic_popup.recontact_options",
];
expectedTitles.forEach((title) => {
const link = documentationLinks.find((link) => link.getAttribute("data-title") === title);
expect(link).toBeInTheDocument();
expect(link).toHaveTextContent(title);
});
});
test("renders documentation links with correct URLs", () => {
render(<DynamicPopupTab {...defaultProps} />);
const documentationLinks = screen.getAllByTestId("documentation-link");
const expectedUrls = [
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
];
expectedUrls.forEach((url) => {
const link = documentationLinks.find((link) => link.getAttribute("data-href") === url);
expect(link).toBeInTheDocument();
});
});
test("calls translation function for all text content", () => {
render(<DynamicPopupTab {...defaultProps} />);
// Check alert translations
expect(screen.getByTestId("alert-title")).toHaveTextContent(
"environments.surveys.share.dynamic_popup.alert_title"
);
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"environments.surveys.share.dynamic_popup.alert_description"
);
expect(screen.getByTestId("next-link")).toHaveTextContent(
"environments.surveys.share.dynamic_popup.alert_button"
);
});
test("renders with correct props when environmentId and surveyId change", () => {
const newProps = {
environmentId: "env-456",
surveyId: "survey-456",
};
render(<DynamicPopupTab {...newProps} />);
const link = screen.getByTestId("next-link");
expect(link).toHaveAttribute("href", "/environments/env-456/surveys/survey-456/edit");
});
});

View File

@@ -0,0 +1,46 @@
"use client";
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
interface DynamicPopupTabProps {
environmentId: string;
surveyId: string;
}
export const DynamicPopupTab = ({ environmentId, surveyId }: DynamicPopupTabProps) => {
const { t } = useTranslate();
return (
<div className="flex h-full flex-col justify-between space-y-4" data-testid="dynamic-popup-container">
<Alert variant="info" size="default">
<AlertTitle>{t("environments.surveys.share.dynamic_popup.alert_title")}</AlertTitle>
<AlertDescription>{t("environments.surveys.share.dynamic_popup.alert_description")}</AlertDescription>
<AlertButton asChild>
<Link href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
{t("environments.surveys.share.dynamic_popup.alert_button")}
</Link>
</AlertButton>
</Alert>
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.dynamic_popup.attribute_based_targeting"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
},
{
title: t("environments.surveys.share.dynamic_popup.code_no_code_triggers"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions",
},
{
title: t("environments.surveys.share.dynamic_popup.recontact_options"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
},
]}
/>
</div>
);
};

View File

@@ -5,7 +5,7 @@ import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthenticationError } from "@formbricks/types/errors";
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
import { EmailTab } from "./EmailTab";
import { EmailTab } from "./email-tab";
// Mock actions
vi.mock("../../actions", () => ({
@@ -20,15 +20,23 @@ vi.mock("@/lib/utils/helper", () => ({
// Mock UI components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, title, ...props }: any) => (
<button onClick={onClick} data-variant={variant} title={title} {...props}>
Button: ({ children, onClick, variant, title, "aria-label": ariaLabel, ...props }: any) => (
<button onClick={onClick} data-variant={variant} title={title} aria-label={ariaLabel} {...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: ({ children, language }: { children: React.ReactNode; language: string }) => (
<div data-testid="code-block" data-language={language}>
CodeBlock: ({
children,
language,
showCopyToClipboard,
}: {
children: React.ReactNode;
language: string;
showCopyToClipboard?: boolean;
}) => (
<div data-testid="code-block" data-language={language} data-show-copy={showCopyToClipboard}>
{children}
</div>
),
@@ -41,7 +49,9 @@ vi.mock("@/modules/ui/components/loading-spinner", () => ({
vi.mock("lucide-react", () => ({
Code2Icon: () => <div data-testid="code2-icon" />,
CopyIcon: () => <div data-testid="copy-icon" />,
EyeIcon: () => <div data-testid="eye-icon" />,
MailIcon: () => <div data-testid="mail-icon" />,
SendIcon: () => <div data-testid="send-icon" />,
}));
// Mock navigator.clipboard
@@ -74,22 +84,42 @@ describe("EmailTab", () => {
expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalledWith({ surveyId });
// Buttons
expect(screen.getByRole("button", { name: "send preview email" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" })
screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" })
).toBeInTheDocument();
expect(screen.getByTestId("mail-icon")).toBeInTheDocument();
expect(screen.getByTestId("code2-icon")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.embed_code_tab" })
).toBeInTheDocument();
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
// Note: code2-icon is only visible in the embed code tab, not in initial render
// Email preview section
await waitFor(() => {
expect(screen.getByText(`To : ${userEmail}`)).toBeInTheDocument();
const emailToElements = screen.getAllByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
);
});
expect(emailToElements.length).toBeGreaterThan(0);
});
expect(
screen.getByText("Subject : environments.surveys.summary.formbricks_email_survey_preview")
).toBeInTheDocument();
screen.getAllByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_subject_label") || false
);
}).length
).toBeGreaterThan(0);
expect(
screen.getAllByText((content, element) => {
return (
element?.textContent?.includes(
"environments.surveys.share.send_email.formbricks_email_survey_preview"
) || false
);
}).length
).toBeGreaterThan(0);
await waitFor(() => {
expect(screen.getByText("Hello World ?preview=true&foo=bar")).toBeInTheDocument(); // Raw HTML content
expect(screen.getByText("Hello World ?foo=bar")).toBeInTheDocument(); // HTML content rendered as text (preview=true removed)
});
expect(screen.queryByTestId("code-block")).not.toBeInTheDocument();
});
@@ -99,32 +129,47 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
name: "environments.surveys.share.send_email.embed_code_tab",
});
await userEvent.click(viewEmbedButton);
// Embed code view
expect(screen.getByRole("button", { name: "Embed survey in your website" })).toBeInTheDocument(); // Updated name
expect(
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" }) // Updated name for hide button
screen.getByRole("button", { name: "environments.surveys.share.send_email.copy_embed_code" })
).toBeInTheDocument();
expect(screen.getByTestId("copy-icon")).toBeInTheDocument();
const codeBlock = screen.getByTestId("code-block");
expect(codeBlock).toBeInTheDocument();
expect(codeBlock).toHaveTextContent(mockCleanedEmailHtml); // Cleaned HTML
expect(screen.queryByText(`To : ${userEmail}`)).not.toBeInTheDocument();
// Toggle back
const hideEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email", // Updated name for hide button
});
await userEvent.click(hideEmbedButton);
expect(screen.getByRole("button", { name: "send preview email" })).toBeInTheDocument();
// The email_to_label should not be visible in embed code view
expect(
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" })
screen.queryByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
);
})
).not.toBeInTheDocument();
// Toggle back to preview
const previewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.email_preview_tab",
});
await userEvent.click(previewButton);
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" })
).toBeInTheDocument();
expect(screen.getByText(`To : ${userEmail}`)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.embed_code_tab" })
).toBeInTheDocument();
await waitFor(() => {
const emailToElements = screen.getAllByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
);
});
expect(emailToElements.length).toBeGreaterThan(0);
});
expect(screen.queryByTestId("code-block")).not.toBeInTheDocument();
});
@@ -133,16 +178,19 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
name: "environments.surveys.share.send_email.embed_code_tab",
});
await userEvent.click(viewEmbedButton);
// Ensure this line queries by the correct aria-label
const copyCodeButton = screen.getByRole("button", { name: "Embed survey in your website" });
const copyCodeButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.copy_embed_code",
});
await userEvent.click(copyCodeButton);
expect(mockWriteText).toHaveBeenCalledWith(mockCleanedEmailHtml);
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.embed_code_copied_to_clipboard");
expect(toast.success).toHaveBeenCalledWith(
"environments.surveys.share.send_email.embed_code_copied_to_clipboard"
);
});
test("sends preview email successfully", async () => {
@@ -150,11 +198,13 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.email_sent");
expect(toast.success).toHaveBeenCalledWith("environments.surveys.share.send_email.email_sent");
});
test("handles send preview email failure (server error)", async () => {
@@ -163,7 +213,9 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -176,7 +228,9 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -190,7 +244,9 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -208,14 +264,19 @@ describe("EmailTab", () => {
test("renders default email if email prop is not provided", async () => {
render(<EmailTab surveyId={surveyId} email="" />);
await waitFor(() => {
expect(screen.getByText("To : user@mail.com")).toBeInTheDocument();
expect(
screen.getByText((content, element) => {
return (
element?.textContent === "environments.surveys.share.send_email.email_to_label : user@mail.com"
);
})
).toBeInTheDocument();
});
});
test("emailHtml memo removes various ?preview=true patterns", async () => {
const htmlWithVariants =
"<p>Test1 ?preview=true</p><p>Test2 ?preview=true&amp;next</p><p>Test3 ?preview=true&;next</p>";
// Ensure this line matches the "Received" output from your test error
const expectedCleanHtml = "<p>Test1 </p><p>Test2 ?next</p><p>Test3 ?next</p>";
vi.mocked(getEmailHtmlAction).mockResolvedValue({ data: htmlWithVariants });
@@ -223,7 +284,7 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
name: "environments.surveys.share.send_email.embed_code_tab",
});
await userEvent.click(viewEmbedButton);

View File

@@ -0,0 +1,155 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { TabBar } from "@/modules/ui/components/tab-bar";
import { useTranslate } from "@tolgee/react";
import DOMPurify from "dompurify";
import { CopyIcon, SendIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { AuthenticationError } from "@formbricks/types/errors";
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
interface EmailTabProps {
surveyId: string;
email: string;
}
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [activeTab, setActiveTab] = useState("preview");
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const { t } = useTranslate();
const emailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return emailHtmlPreview
.replaceAll("?preview=true&amp;", "?")
.replaceAll("?preview=true&;", "?")
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
const tabs = [
{
id: "preview",
label: t("environments.surveys.share.send_email.email_preview_tab"),
},
{
id: "embed",
label: t("environments.surveys.share.send_email.embed_code_tab"),
},
];
useEffect(() => {
const getData = async () => {
const emailHtml = await getEmailHtmlAction({ surveyId });
setEmailHtmlPreview(emailHtml?.data || "");
};
getData();
}, [surveyId]);
const sendPreviewEmail = async () => {
try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
if (val?.data) {
toast.success(t("environments.surveys.share.send_email.email_sent"));
} else {
const errorMessage = getFormattedErrorMessage(val);
toast.error(errorMessage);
}
} catch (err) {
if (err instanceof AuthenticationError) {
toast.error(t("common.not_authenticated"));
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
const renderTabContent = () => {
if (activeTab === "preview") {
return (
<div className="space-y-4 pb-4">
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-amber-500" />
<div className="h-3 w-3 rounded-full bg-emerald-500" />
</div>
<div>
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">
{t("environments.surveys.share.send_email.email_to_label")} : {email || "user@mail.com"}
</div>
<div className="border-b border-slate-200 pb-2 text-sm">
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
</div>
<div className="p-2">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
) : (
<LoadingSpinner />
)}
</div>
</div>
</div>
<Button
title={t("environments.surveys.share.send_email.send_preview_email")}
aria-label={t("environments.surveys.share.send_email.send_preview_email")}
onClick={() => sendPreviewEmail()}
className="shrink-0">
{t("environments.surveys.share.send_email.send_preview")}
<SendIcon />
</Button>
</div>
);
}
if (activeTab === "embed") {
return (
<div className="space-y-4 pb-4">
<CodeBlock
customCodeClass="text-sm h-96 overflow-y-scroll"
language="html"
showCopyToClipboard
noMargin>
{emailHtml}
</CodeBlock>
<Button
title={t("environments.surveys.share.send_email.copy_embed_code")}
aria-label={t("environments.surveys.share.send_email.copy_embed_code")}
onClick={() => {
try {
navigator.clipboard.writeText(emailHtml);
toast.success(t("environments.surveys.share.send_email.embed_code_copied_to_clipboard"));
} catch {
toast.error(t("environments.surveys.share.send_email.embed_code_copied_to_clipboard_failed"));
}
}}
className="shrink-0">
{t("common.copy_code")}
<CopyIcon />
</Button>
</div>
);
}
return null;
};
return (
<div className="flex h-full w-full flex-col space-y-4">
<TabBar
tabs={tabs}
activeId={activeTab}
setActiveId={setActiveTab}
tabStyle="button"
className="h-10 min-h-10 rounded-md border border-slate-200 bg-slate-100"
/>
<div className="flex-1">{renderTabContent()}</div>
</div>
);
};

View File

@@ -0,0 +1,526 @@
import { generatePersonalLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PersonalLinksTab } from "./personal-links-tab";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions", () => ({
generatePersonalLinksAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: {
loading: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock UI components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant }: any) => (
<div data-testid="alert" data-variant={variant}>
{children}
</div>
),
AlertButton: ({ children }: any) => <div data-testid="alert-button">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, disabled, loading, className, ...props }: any) => (
<button
data-testid="button"
onClick={onClick}
disabled={disabled}
data-loading={loading}
className={className}
{...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/date-picker", () => ({
DatePicker: ({ date, updateSurveyDate, minDate, onClearDate }: any) => (
<div data-testid="date-picker">
<input
data-testid="date-input"
type="date"
value={date ? date.toISOString().split("T")[0] : ""}
onChange={(e) => {
const newDate = e.target.value ? new Date(e.target.value) : null;
updateSurveyDate(newDate);
}}
/>
<button data-testid="clear-date" onClick={() => onClearDate()}>
Clear
</button>
<div data-testid="min-date">{minDate ? minDate.toISOString() : ""}</div>
</div>
),
}));
vi.mock("@/modules/ui/components/select", () => {
let globalOnValueChange: ((value: string) => void) | null = null;
return {
Select: ({ children, value, onValueChange, disabled }: any) => {
globalOnValueChange = onValueChange;
return (
<div data-testid="select" data-disabled={disabled} data-value={value}>
<div data-testid="select-current-value">{value || "Select option"}</div>
{children}
</div>
);
},
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div
data-testid="select-item"
data-value={value}
onClick={() => {
if (globalOnValueChange) {
globalOnValueChange(value);
}
}}>
{children}
</div>
),
SelectTrigger: ({ children, className }: any) => (
<div data-testid="select-trigger" className={className}>
{children}
</div>
),
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
};
});
// Mock icons
vi.mock("lucide-react", () => ({
DownloadIcon: () => <div data-testid="download-icon" />,
KeyIcon: () => <div data-testid="key-icon" />,
}));
// Mock Next.js Link
vi.mock("next/link", () => ({
default: ({ children, href, target, rel }: any) => (
<a data-testid="link" href={href} target={target} rel={rel}>
{children}
</a>
),
}));
const mockGeneratePersonalLinksAction = vi.mocked(generatePersonalLinksAction);
const mockToast = vi.mocked(toast);
const mockGetFormattedErrorMessage = vi.mocked(getFormattedErrorMessage);
// Mock segments data
const mockSegments = [
{
id: "segment1",
title: "Public Segment 1",
isPrivate: false,
description: "Test segment 1",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
{
id: "segment2",
title: "Public Segment 2",
isPrivate: false,
description: "Test segment 2",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
{
id: "segment3",
title: "Private Segment",
isPrivate: true,
description: "Test private segment",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
];
const defaultProps = {
environmentId: "test-env-id",
surveyId: "test-survey-id",
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
};
// Helper function to trigger select change
const selectOption = (value: string) => {
const selectItems = screen.getAllByTestId("select-item");
const targetItem = selectItems.find((item) => item.getAttribute("data-value") === value);
if (targetItem) {
fireEvent.click(targetItem);
}
};
describe("PersonalLinksTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders recipients section with segment selection", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("common.recipients")).toBeInTheDocument();
expect(screen.getByTestId("select")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.create_and_manage_segments")
).toBeInTheDocument();
});
test("renders expiry date section with date picker", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(
screen.getByText("environments.surveys.share.personal_links.expiry_date_optional")
).toBeInTheDocument();
expect(screen.getByTestId("date-picker")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.expiry_date_description")
).toBeInTheDocument();
});
test("renders generate button with correct initial state", () => {
render(<PersonalLinksTab {...defaultProps} />);
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
expect(
screen.getByText("environments.surveys.share.personal_links.generate_and_download_links")
).toBeInTheDocument();
expect(screen.getByTestId("download-icon")).toBeInTheDocument();
});
test("renders info alert with correct content", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.work_with_segments")
).toBeInTheDocument();
expect(screen.getByTestId("link")).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
);
});
test("filters out private segments and shows only public segments", () => {
render(<PersonalLinksTab {...defaultProps} />);
const selectItems = screen.getAllByTestId("select-item");
expect(selectItems).toHaveLength(2); // Only public segments
expect(selectItems[0]).toHaveTextContent("Public Segment 1");
expect(selectItems[1]).toHaveTextContent("Public Segment 2");
});
test("shows no segments message when no public segments available", () => {
const propsWithPrivateSegments = {
...defaultProps,
segments: [mockSegments[2]], // Only private segment
};
render(<PersonalLinksTab {...propsWithPrivateSegments} />);
expect(
screen.getByText("environments.surveys.share.personal_links.no_segments_available")
).toBeInTheDocument();
expect(screen.getByTestId("select")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("button")).toBeDisabled();
});
test("enables button when segment is selected", () => {
render(<PersonalLinksTab {...defaultProps} />);
// Initially disabled
expect(screen.getByTestId("button")).toBeDisabled();
// Select a segment
selectOption("segment1");
// Should now be enabled
expect(screen.getByTestId("button")).not.toBeDisabled();
});
test("handles date selection correctly", () => {
render(<PersonalLinksTab {...defaultProps} />);
const dateInput = screen.getByTestId("date-input");
const testDate = "2024-12-31";
fireEvent.change(dateInput, { target: { value: testDate } });
expect(dateInput).toHaveValue(testDate);
});
test("clears date when clear button is clicked", () => {
render(<PersonalLinksTab {...defaultProps} />);
const dateInput = screen.getByTestId("date-input");
const clearButton = screen.getByTestId("clear-date");
// Set a date first
fireEvent.change(dateInput, { target: { value: "2024-12-31" } });
// Clear the date
fireEvent.click(clearButton);
expect(dateInput).toHaveValue("");
});
test("sets minimum date to tomorrow", () => {
render(<PersonalLinksTab {...defaultProps} />);
const minDateElement = screen.getByTestId("min-date");
// Should have some ISO date string for a future date
expect(minDateElement.textContent).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
test("successfully generates and downloads links", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "personal-links.csv",
count: 5,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Verify action was called
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: undefined,
});
});
// Verify loading toast
expect(mockToast.loading).toHaveBeenCalledWith(
"environments.surveys.share.personal_links.generating_links_toast",
{
duration: 5000,
id: "generating-links",
}
);
});
test("generates links with expiry date when date is selected", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "personal-links.csv",
count: 3,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Set expiry date (10 days from now)
const dateInput = screen.getByTestId("date-input");
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
const expiryDate = futureDate.toISOString().split("T")[0];
fireEvent.change(dateInput, { target: { value: expiryDate } });
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: expect.any(Number),
});
});
// Verify that expirationDays is a reasonable value (between 9-10 days)
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(9);
expect(callArgs.expirationDays).toBeLessThanOrEqual(10);
});
test("handles error response from generatePersonalLinksAction", async () => {
const mockErrorResult = {
serverError: "Test error message",
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockErrorResult);
mockGetFormattedErrorMessage.mockReturnValue("Formatted error message");
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Wait for the action to be called
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: undefined,
});
});
// Wait for error handling
await waitFor(() => {
expect(mockGetFormattedErrorMessage).toHaveBeenCalledWith(mockErrorResult);
expect(mockToast.error).toHaveBeenCalledWith("Formatted error message", {
duration: 5000,
id: "generating-links",
});
});
});
test("shows generating state when triggered", async () => {
// Mock a promise that resolves quickly
const mockResult = { data: { downloadUrl: "test", fileName: "test.csv", count: 1 } };
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Verify loading toast is called
expect(mockToast.loading).toHaveBeenCalledWith(
"environments.surveys.share.personal_links.generating_links_toast",
{
duration: 5000,
id: "generating-links",
}
);
});
test("button is disabled when no segment is selected", () => {
render(<PersonalLinksTab {...defaultProps} />);
const generateButton = screen.getByTestId("button");
expect(generateButton).toBeDisabled();
});
test("button is disabled when no public segments are available", () => {
const propsWithNoPublicSegments = {
...defaultProps,
segments: [mockSegments[2]], // Only private segments
};
render(<PersonalLinksTab {...propsWithNoPublicSegments} />);
const generateButton = screen.getByTestId("button");
expect(generateButton).toBeDisabled();
});
test("handles empty segments array", () => {
const propsWithEmptySegments = {
...defaultProps,
segments: [],
};
render(<PersonalLinksTab {...propsWithEmptySegments} />);
expect(
screen.getByText("environments.surveys.share.personal_links.no_segments_available")
).toBeInTheDocument();
expect(screen.getByTestId("button")).toBeDisabled();
});
test("calculates expiration days correctly for different dates", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "test.csv",
count: 1,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Set expiry date to 5 days from now
const dateInput = screen.getByTestId("date-input");
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 5);
const expiryDate = futureDate.toISOString().split("T")[0];
fireEvent.change(dateInput, { target: { value: expiryDate } });
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: expect.any(Number),
});
});
// Verify that expirationDays is a reasonable value (between 4-5 days)
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(4);
expect(callArgs.expirationDays).toBeLessThanOrEqual(5);
});
});

View File

@@ -0,0 +1,248 @@
"use client";
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DatePicker } from "@/modules/ui/components/date-picker";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
import { DownloadIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TSegment } from "@formbricks/types/segment";
import { generatePersonalLinksAction } from "../../actions";
interface PersonalLinksTabProps {
environmentId: string;
surveyId: string;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
interface PersonalLinksFormData {
selectedSegment: string;
expiryDate: Date | null;
}
// Custom DatePicker component with date restrictions
const RestrictedDatePicker = ({
date,
updateSurveyDate,
}: {
date: Date | null;
updateSurveyDate: (date: Date | null) => void;
}) => {
// Get tomorrow's date
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const handleDateUpdate = (date: Date) => {
updateSurveyDate(date);
};
return (
<DatePicker
date={date}
updateSurveyDate={handleDateUpdate}
minDate={tomorrow}
onClearDate={() => updateSurveyDate(null)}
/>
);
};
export const PersonalLinksTab = ({
environmentId,
segments,
surveyId,
isContactsEnabled,
isFormbricksCloud,
}: PersonalLinksTabProps) => {
const { t } = useTranslate();
const form = useForm<PersonalLinksFormData>({
defaultValues: {
selectedSegment: "",
expiryDate: null,
},
});
const { watch } = form;
const selectedSegment = watch("selectedSegment");
const expiryDate = watch("expiryDate");
const [isGenerating, setIsGenerating] = useState(false);
const publicSegments = segments.filter((segment) => !segment.isPrivate);
// Utility function for file downloads
const downloadFile = (url: string, filename: string) => {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleGenerateLinks = async () => {
if (!selectedSegment || isGenerating) return;
setIsGenerating(true);
// Show initial toast
toast.loading(t("environments.surveys.share.personal_links.generating_links_toast"), {
duration: 5000,
id: "generating-links",
});
const result = await generatePersonalLinksAction({
surveyId: surveyId,
segmentId: selectedSegment,
environmentId: environmentId,
expirationDays: expiryDate
? Math.max(1, Math.floor((expiryDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)))
: undefined,
});
if (result?.data) {
downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv");
toast.success(t("environments.surveys.share.personal_links.links_generated_success_toast"), {
duration: 5000,
id: "generating-links",
});
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage, {
duration: 5000,
id: "generating-links",
});
}
setIsGenerating(false);
};
// Button state logic
const isButtonDisabled = !selectedSegment || isGenerating || publicSegments.length === 0;
const buttonText = isGenerating
? t("environments.surveys.share.personal_links.generating_links")
: t("environments.surveys.share.personal_links.generate_and_download_links");
if (!isContactsEnabled) {
return (
<UpgradePrompt
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
]}
/>
);
}
return (
<div className="flex h-full flex-col justify-between space-y-4">
<FormProvider {...form}>
<div className="flex grow flex-col gap-6">
{/* Recipients Section */}
<FormField
control={form.control}
name="selectedSegment"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.recipients")}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={publicSegments.length === 0}>
<SelectTrigger className="w-full bg-white">
<SelectValue
placeholder={
publicSegments.length === 0
? t("environments.surveys.share.personal_links.no_segments_available")
: t("environments.surveys.share.personal_links.select_segment")
}
/>
</SelectTrigger>
<SelectContent>
{publicSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("environments.surveys.share.personal_links.create_and_manage_segments")}
</FormDescription>
</FormItem>
)}
/>
{/* Expiry Date Section */}
<FormField
control={form.control}
name="expiryDate"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.personal_links.expiry_date_optional")}</FormLabel>
<FormControl>
<RestrictedDatePicker date={field.value} updateSurveyDate={field.onChange} />
</FormControl>
<FormDescription>
{t("environments.surveys.share.personal_links.expiry_date_description")}
</FormDescription>
</FormItem>
)}
/>
{/* Generate Button */}
<Button
onClick={handleGenerateLinks}
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
</FormProvider>
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.personal_links.work_with_segments"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration",
},
]}
/>
</div>
);
};

View File

@@ -0,0 +1,284 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { QRCodeTab } from "./qr-code-tab";
// Mock the QR code options utility
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options",
() => ({
getQRCodeOptions: vi.fn((width: number, height: number) => ({
width,
height,
type: "svg",
data: "",
margin: 0,
qrOptions: {
typeNumber: 0,
mode: "Byte",
errorCorrectionLevel: "L",
},
imageOptions: {
saveAsBlob: true,
hideBackgroundDots: false,
imageSize: 0,
margin: 0,
},
dotsOptions: {
type: "extra-rounded",
color: "#000000",
roundSize: true,
},
backgroundOptions: {
color: "#ffffff",
},
cornersSquareOptions: {
type: "dot",
color: "#000000",
},
cornersDotOptions: {
type: "dot",
color: "#000000",
},
})),
})
);
// Mock UI components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
<div data-testid="alert" data-variant={variant}>
{children}
</div>
),
AlertDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-description">{children}</div>
),
AlertTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-title">{children}</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({
children,
onClick,
disabled,
variant,
size,
className,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: string;
size?: string;
className?: string;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={className}
data-variant={variant}
data-size={size}
data-testid="button">
{children}
</button>
),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
Download: () => <div data-testid="download-icon">Download</div>,
LoaderCircle: ({ className }: { className?: string }) => (
<div className={className} data-testid="loader-circle">
LoaderCircle
</div>
),
RefreshCw: ({ className }: { className?: string }) => (
<div className={className} data-testid="refresh-icon">
RefreshCw
</div>
),
}));
// Mock logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock QRCodeStyling
const mockQRCodeStyling = {
update: vi.fn(),
append: vi.fn(),
download: vi.fn(),
};
// Simple boolean flag to control mock behavior
let shouldMockThrowError = false;
// @ts-ignore - Ignore TypeScript error for mock
vi.mock("qr-code-styling", () => ({
default: vi.fn(() => {
// Default to success, only throw error when explicitly requested
if (shouldMockThrowError) {
throw new Error("QR code generation failed");
}
return mockQRCodeStyling;
}),
}));
const mockSurveyUrl = "https://example.com/survey/123";
describe("QRCodeTab", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
// Reset to success state by default
shouldMockThrowError = false;
// Reset mock implementations
mockQRCodeStyling.update.mockReset();
mockQRCodeStyling.append.mockReset();
mockQRCodeStyling.download.mockReset();
// Set up default mock behavior
mockQRCodeStyling.update.mockImplementation(() => {});
mockQRCodeStyling.append.mockImplementation(() => {});
mockQRCodeStyling.download.mockImplementation(() => {});
});
afterEach(() => {
cleanup();
});
describe("QR Code generation", () => {
test("attempts to generate QR code when surveyUrl is provided", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
// Wait for either success or error state
await waitFor(() => {
const hasButton = screen.queryByTestId("button");
const hasAlert = screen.queryByTestId("alert");
expect(hasButton || hasAlert).toBeTruthy();
});
});
test("shows download button when QR code generation succeeds", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
await waitFor(() => {
expect(screen.getByTestId("button")).toBeInTheDocument();
});
});
});
describe("Error handling", () => {
test("shows error state when QR code generation fails", async () => {
shouldMockThrowError = true;
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
await waitFor(() => {
expect(screen.getByTestId("alert")).toBeInTheDocument();
});
expect(screen.getByTestId("alert-title")).toHaveTextContent("common.something_went_wrong");
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"environments.surveys.summary.qr_code_generation_failed"
);
});
});
describe("Download functionality", () => {
test("has clickable download button when QR code is available", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
await waitFor(() => {
expect(screen.getByTestId("button")).toBeInTheDocument();
});
const downloadButton = screen.getByTestId("button");
expect(downloadButton).toBeInTheDocument();
expect(downloadButton).toHaveAttribute("type", "button");
// Button should be clickable
await userEvent.click(downloadButton);
// If the button is clicked without throwing, it's working
});
test("handles button interactions properly", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
await waitFor(() => {
expect(screen.getByTestId("button")).toBeInTheDocument();
});
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
// Test that button can be interacted with
await userEvent.click(button);
// Button should still be present after click
expect(screen.getByTestId("button")).toBeInTheDocument();
});
test("shows appropriate state when surveyUrl is empty", async () => {
render(<QRCodeTab surveyUrl="" />);
// Should show button (but disabled) when URL is empty, no alert
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
expect(screen.queryByTestId("alert")).not.toBeInTheDocument();
});
});
describe("Component lifecycle", () => {
test("responds to surveyUrl changes", async () => {
const { rerender } = render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
// Initial render should show download button
await waitFor(() => {
expect(screen.getByTestId("button")).toBeInTheDocument();
});
const newSurveyUrl = "https://example.com/survey/456";
rerender(<QRCodeTab surveyUrl={newSurveyUrl} />);
// After rerender, button should still be present
await waitFor(() => {
expect(screen.getByTestId("button")).toBeInTheDocument();
});
});
});
describe("Accessibility", () => {
test("has proper button labels and states", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
await waitFor(() => {
const downloadButton = screen.getByTestId("button");
expect(downloadButton).toBeInTheDocument();
expect(downloadButton).toHaveAttribute("type", "button");
});
});
test("shows appropriate loading or success state", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
// Component should show either loading or success content
await waitFor(() => {
const hasButton = screen.queryByTestId("button");
const hasLoader = screen.queryByTestId("loader-circle");
expect(hasButton || hasLoader).toBeTruthy();
});
});
});
});

View File

@@ -0,0 +1,115 @@
"use client";
import { getQRCodeOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { Download, LoaderCircle } from "lucide-react";
import QRCodeStyling from "qr-code-styling";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { logger } from "@formbricks/logger";
interface QRCodeTabProps {
surveyUrl: string;
}
export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
const { t } = useTranslate();
const qrCodeRef = useRef<HTMLDivElement>(null);
const qrInstance = useRef<QRCodeStyling | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
useEffect(() => {
const generateQRCode = async () => {
try {
setIsLoading(true);
setHasError(false);
qrInstance.current ??= new QRCodeStyling(getQRCodeOptions(184, 184));
if (surveyUrl && qrInstance.current) {
qrInstance.current.update({ data: surveyUrl });
if (qrCodeRef.current) {
qrCodeRef.current.innerHTML = "";
qrInstance.current.append(qrCodeRef.current);
}
}
} catch (error) {
logger.error("Failed to generate QR code:", error);
setHasError(true);
} finally {
setIsLoading(false);
}
};
if (surveyUrl) {
generateQRCode();
}
return () => {
const instance = qrInstance.current;
if (instance) {
qrInstance.current = null;
}
};
}, [surveyUrl]);
const downloadQRCode = async () => {
try {
setIsDownloading(true);
const downloadInstance = new QRCodeStyling(getQRCodeOptions(500, 500));
downloadInstance.update({ data: surveyUrl });
downloadInstance.download({ name: "survey-qr-code", extension: "png" });
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
} catch (error) {
logger.error("Failed to download QR code:", error);
toast.error(t("environments.surveys.summary.qr_code_download_failed"));
} finally {
setIsDownloading(false);
}
};
return (
<>
{isLoading && (
<div className="flex flex-col items-center gap-2">
<LoaderCircle className="h-8 w-8 animate-spin text-slate-500" />
<p className="text-sm text-slate-500">{t("environments.surveys.summary.generating_qr_code")}</p>
</div>
)}
{hasError && (
<Alert variant="error">
<AlertTitle>{t("common.something_went_wrong")}</AlertTitle>
<AlertDescription>{t("environments.surveys.summary.qr_code_generation_failed")}</AlertDescription>
</Alert>
)}
{!isLoading && !hasError && (
<div className="flex flex-col items-start justify-center gap-4">
<div className="flex h-[184px] w-[184px] items-center justify-center overflow-hidden rounded-lg border bg-white">
<div ref={qrCodeRef} className="h-full w-full" />
</div>
<Button
onClick={downloadQRCode}
data-testid="download-qr-code-button"
disabled={!surveyUrl || isDownloading || hasError}
className="flex items-center gap-2">
{isDownloading
? t("environments.surveys.summary.downloading_qr_code")
: t("environments.surveys.summary.download_qr_code")}
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</div>
)}
</>
);
};

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