Compare commits

...

55 Commits

Author SHA1 Message Date
Piyush Gupta
a9411c8ced fixing tailwind 2025-04-14 16:22:35 +05:30
Piyush Gupta
0440aeb143 Merge branch 'main' of https://github.com/formbricks/formbricks into chore/upgrade-web-tailwind4 2025-04-14 13:38:44 +05:30
Piyush Gupta
016289c8cb fix: download responses button label (#5324)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-14 07:10:40 +00:00
Dhruwang Jariwala
93a9575389 fix: missing translation in api key modal (#5341) 2025-04-14 07:02:43 +00:00
victorvhs017
9e265adf14 chore: add test files to modules/account and modules/analysis (#5294)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-13 19:04:56 +00:00
Dhruwang Jariwala
eb08a0ed14 fix: buttonLabel conditions (#5336)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-13 08:36:59 +00:00
Dhruwang Jariwala
c533f37983 chore: improve accessibility for matrix question (#5320)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-04-12 04:40:24 +00:00
Anshuman Pandey
ca4f8385e4 fix: adds FormbricksEnvironment struct for url constants (#5312) 2025-04-11 13:44:25 +00:00
Matti Nannt
3eb9aa74ed chore: upgrade typescript and react dependencies (#5317) 2025-04-11 13:01:54 +02:00
Piyush Gupta
e982fc5c3e Merge branch 'main' of https://github.com/formbricks/formbricks into chore/upgrade-web-tailwind4 2025-04-11 14:35:34 +05:30
Piyush Gupta
637b51464c docs: updates the API keys docs in API reference (#5319) 2025-04-11 08:46:04 +00:00
Dhruwang Jariwala
fd9585a66e fix: respondent should not see redirect card text (#5239)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-11 04:22:25 +00:00
Matti Nannt
49ecbcb0c9 fix: updatedAt not set in response update (#5315) 2025-04-10 11:04:42 +00:00
Piyush Gupta
1132bdd66a fix: openAPI spec for contact endpoints (#5247)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-10 10:22:40 +00:00
Anshuman Pandey
c7d6ed9ea3 chore: removes api package and deps (#5251)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-04-10 09:41:39 +00:00
Matti Nannt
782528f169 chore: update surveys package npm dependencies (#5302) 2025-04-10 10:44:56 +02:00
Piyush Gupta
104c78275f docs: fixes framework guide link (#5307) 2025-04-10 08:11:40 +00:00
Matti Nannt
d9d88f7175 chore: update eslint npm dependencies (#5313) 2025-04-10 10:22:58 +02:00
Dhruwang Jariwala
bf7e24cf11 fix: stripe issue for customers with existing stripe ID (#5308) 2025-04-10 07:56:01 +00:00
Anshuman Pandey
c8aba01db3 fix: adds isWebEnvironment check in the surveys package (#5310) 2025-04-10 09:01:36 +02:00
Piyush Gupta
a896c7e46e docs: updated API playground link in the webhooks docs (#5301) 2025-04-09 08:33:36 +00:00
Matti Nannt
8018ec14a2 chore: use remote turbocache for building formbricks (#5305) 2025-04-09 10:38:17 +02:00
victorvhs017
9c3208c860 chore: Refactored the Formbricks next public env variables and added test files (#5014)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 08:10:32 +00:00
Anshuman Pandey
e1063964cf fix: fixes segment self referencing issue (#5254)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 06:58:28 +00:00
victorvhs017
38568738cc feat: Added test configuration and initial test files to the surveys package (#5253)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-09 06:53:16 +00:00
Piyush Gupta
041dce2ec5 Merge branch 'main' of https://github.com/formbricks/formbricks into chore/upgrade-web-tailwind4 2025-04-09 11:29:06 +05:30
Piyush Gupta
15b8358b14 fix: date format in response table (#5304) 2025-04-09 05:39:57 +00:00
Anshuman Pandey
2173cb2610 fix: removes sourcemaps (#5257) 2025-04-09 04:50:56 +00:00
Matti Nannt
87b925d622 chore: update apps/web npm dependencies (#5300) 2025-04-09 06:58:53 +02:00
Piyush Gupta
885b06cc26 fix: adds date value check in date question summary (#5296) 2025-04-09 04:07:39 +00:00
Matti Nannt
adb6a5f41e chore: upgrade npm dependencies (#5299) 2025-04-09 04:47:07 +02:00
Matti Nannt
3b815e22e3 chore: add docker build check github action (#4875)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-04-08 13:26:48 +00:00
Matti Nannt
4d4a5c0e64 fix: solve sonarqube security hotspots (#5292) 2025-04-08 14:58:24 +02:00
Anshuman Pandey
0e89293974 fix: appUrl fix in iOS and android packages (#5295) 2025-04-08 14:51:30 +02:00
Jakob Schott
c306911b3a fix: replace hard-coded alerts with alert component (#5156)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-08 10:26:28 +00:00
Piyush Gupta
d6e30855f6 fix: bg-opacity 2025-04-08 12:24:00 +05:30
Piyush Gupta
4f276f0095 feat: personalized survey links for segment of users endpoint (#5032)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-08 05:54:27 +00:00
Piyush Gupta
f20b412a95 fix: build 2025-04-08 11:07:25 +05:30
Piyush Gupta
99b0713800 Merge branch 'main' of https://github.com/formbricks/formbricks into chore/upgrade-web-tailwind4 2025-04-08 11:01:21 +05:30
Dhruwang Jariwala
81fc97c7e9 fix: Add Cache-Control to allowed CORS headers (#5252) 2025-04-07 14:47:02 +00:00
Matti Nannt
785c5a59c6 chore: make mock passwords more obvious to test suites (#5240) 2025-04-07 12:40:40 +00:00
Piyush Gupta
25ecfaa883 fix: formbricks version on localhost (#5250) 2025-04-07 10:42:13 +00:00
Anshuman Pandey
38e2c019fa fix: ios package sonarqube fixes (#5249) 2025-04-07 08:48:56 +00:00
victorvhs017
15878a4ac5 chore: Refactored the Turnstile next public env variable and added test files (#4997)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-07 06:07:39 +00:00
Matti Nannt
9802536ded chore: upgrade demo app to tailwind v4 (#5237)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-07 05:40:10 +00:00
Matthias Nannt
a2ffea28a1 chore: upgrade web app to tailwind v4 2025-04-07 11:22:29 +09:00
victorvhs017
2c7f92a4d7 feat: user endpoints (#5232)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-06 06:06:18 +00:00
Piyush Gupta
c653841037 chore: block signin with SSO when user is not found (#5233)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-06 04:22:53 +00:00
Matti Nannt
ec314c14ea fix: failing e2e test (#5234) 2025-04-05 14:20:22 +02:00
victorvhs017
c03e60ac0b feat: organization endpoints (#5076)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-05 13:54:21 +02:00
Dhruwang Jariwala
cbf2343143 feat: lastLoginAt to user model (#5216) 2025-04-05 13:22:38 +02:00
Dhruwang Jariwala
9d9b3ac543 chore: added isActive to user model (#5211)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-05 12:22:45 +02:00
Matti Nannt
591b35a70b fix: upgrade npm dependencies with high security risk (#5221) 2025-04-05 06:04:01 +02:00
Piyush Gupta
f0c7b881d3 fix: don't allow spaces as "other" values in select questions (#5224) 2025-04-04 08:01:26 +00:00
dependabot[bot]
3fd5515db1 chore(deps): bump SonarSource/sonarqube-scan-action from 4.2.1 to 5.1.0 (#5104)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-04 05:03:40 +02:00
548 changed files with 20991 additions and 8445 deletions

View File

@@ -117,7 +117,7 @@ IMPRINT_URL=
IMPRINT_ADDRESS=
# Configure Turnstile in signup flow
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# Configure Github Login
@@ -155,9 +155,8 @@ STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks
NEXT_PUBLIC_FORMBRICKS_API_HOST=
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
FORMBRICKS_API_HOST=
FORMBRICKS_ENVIRONMENT_ID=
# Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID=

View File

@@ -8,6 +8,14 @@ on:
required: false
default: "0"
inputs:
turbo_token:
description: "Turborepo token"
required: false
turbo_team:
description: "Turborepo team"
required: false
runs:
using: "composite"
steps:
@@ -62,6 +70,8 @@ runs:
- run: |
pnpm build --filter=@formbricks/web...
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
env:
TURBO_TOKEN: ${{ inputs.turbo_token }}
TURBO_TEAM: ${{ inputs.turbo_team }}

View File

@@ -4,7 +4,7 @@ on:
permissions:
contents: read
jobs:
build:
name: Build Formbricks-web
@@ -25,3 +25,5 @@ jobs:
id: cache-build-web
with:
e2e_testing_mode: "0"
turbo_token: ${{ secrets.TURBO_TOKEN }}
turbo_team: ${{ vars.TURBO_TEAM }}

View File

@@ -0,0 +1,167 @@
name: Docker Build Validation
on:
pull_request:
branches:
- main
merge_group:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
validate-docker-build:
name: Validate Docker Build
runs-on: ubuntu-latest
# Add PostgreSQL service container
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: formbricks
ports:
- 5432:5432
# Health check to ensure PostgreSQL is ready before using it
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Verify PostgreSQL Connection
run: |
echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
# Show network configuration
echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Test Docker Image with Health Check
shell: bash
run: |
echo "🧪 Testing if the Docker image starts correctly..."
# Add extra docker run args to support host.docker.internal on Linux
DOCKER_RUN_ARGS="--add-host=host.docker.internal:host-gateway"
# Start the container with host.docker.internal pointing to the host
docker run --name formbricks-test \
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
# Give it more time to start up
echo "Waiting 45 seconds for application to start..."
sleep 45
# Check if the container is running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
echo "❌ Container failed to start properly!"
docker logs formbricks-test
exit 1
else
echo "✅ Container started successfully!"
fi
# Try connecting to PostgreSQL from inside the container
echo "Testing PostgreSQL connection from inside container..."
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
# Try to access the health endpoint
echo "🏥 Testing /health endpoint..."
MAX_RETRIES=10
RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false
set +e # Disable exit on error to allow for retries
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Show container logs before each attempt to help debugging
if [ $RETRY_COUNT -gt 1 ]; then
echo "📋 Current container logs:"
docker logs --tail 20 formbricks-test
fi
# Get detailed curl output for debugging
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
CURL_EXIT_CODE=$?
echo "Curl exit code: $CURL_EXIT_CODE"
echo "Curl output: $HTTP_OUTPUT"
if [ $CURL_EXIT_CODE -eq 0 ]; then
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
echo "Status code detected: $STATUS_CODE"
if [ "$STATUS_CODE" = "200" ]; then
echo "✅ Health check successful!"
HEALTH_CHECK_SUCCESS=true
break
else
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
fi
else
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
fi
echo "Waiting 15 seconds before next attempt..."
sleep 15
done
# Show full container logs for debugging
echo "📋 Full container logs:"
docker logs formbricks-test
# Clean up the container
echo "🧹 Cleaning up..."
docker rm -f formbricks-test
# Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $MAX_RETRIES attempts"
exit 1
fi
echo "✨ Docker validation complete - all checks passed!"

View File

@@ -16,6 +16,8 @@ on:
env:
TELEMETRY_DISABLED: 1
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
id-token: write

View File

@@ -48,7 +48,7 @@ jobs:
run: |
pnpm test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@@ -1,4 +1,8 @@
{
"sonarlint.connectedMode.project": {
"connectionId": "formbricks",
"projectKey": "formbricks_formbricks"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -27,7 +27,7 @@ const secondaryNavigation = [
export function Sidebar(): React.JSX.Element {
return (
<div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pt-5 pb-4">
<nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar">
@@ -38,10 +38,10 @@ export function Sidebar(): React.JSX.Element {
href={item.href}
className={classNames(
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 flex-shrink-0 text-cyan-200" aria-hidden="true" />
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
{item.name}
</a>
))}
@@ -52,7 +52,7 @@ export function Sidebar(): React.JSX.Element {
<a
key={item.name}
href={item.href}
className="group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6 text-cyan-100 hover:bg-cyan-600 hover:text-white">
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
{item.name}
</a>

View File

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

View File

@@ -13,12 +13,13 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/postcss": "4.1.3",
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwindcss": "3.4.16"
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwindcss": "4.1.3"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",

View File

@@ -96,10 +96,10 @@ export default function AppPage(): React.JSX.Element {
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded-xs" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>
<p className="mb-1 sm:mr-2 sm:mb-0">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}

View File

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

View File

@@ -1,13 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {
extend: {},
},
plugins: [require("@tailwindcss/forms")],
};

View File

@@ -27,14 +27,14 @@
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.29.0",
"@typescript-eslint/parser": "8.29.0",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.2",
"eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
"storybook": "8.6.12",
"tsup": "8.4.0",
"vite": "6.2.4"
"vite": "6.2.5"
}
}

View File

@@ -22,7 +22,7 @@ RUN npm install -g corepack@latest
RUN corepack enable
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
# BuildKit secret handling without hardcoded fallback values
# This approach relies entirely on secrets passed from GitHub Actions
@@ -40,8 +40,6 @@ RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096"
ENV NODE_OPTIONS=${NODE_OPTIONS}
@@ -87,31 +85,60 @@ RUN apk add --no-cache curl \
WORKDIR /home/nextjs
COPY --from=installer /app/apps/web/next.config.mjs .
COPY --from=installer /app/apps/web/package.json .
# Leverage output traces to reduce image size
# Ensure no write permissions are assigned to the copied resources
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chmod -R 755 ./
COPY --from=installer /app/apps/web/next.config.mjs .
RUN chmod 644 ./next.config.mjs
COPY --from=installer /app/apps/web/package.json .
RUN chmod 644 ./package.json
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
RUN chmod -R 755 ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
RUN chmod -R 755 ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chmod 644 ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
RUN chmod 644 ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
RUN chmod -R 755 ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
RUN chmod -R 755 ./packages/database/src
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
RUN chmod -R 755 ./packages/database/node_modules
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
# Copy Prisma-specific generated files
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
RUN chmod -R 755 ./node_modules/.prisma
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod 644 ./prisma_version.txt
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod -R 755 /app/docker/cronjobs
# Copy required dependencies
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty

View File

@@ -101,17 +101,17 @@ export const OnboardingSetupInstructions = ({
<div>
{activeTab === "npm" ? (
<div className="prose prose-slate w-full">
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="sh">
npm install @formbricks/js
</CodeBlock>
<p>{t("common.or")}</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="sh">
yarn add @formbricks/js
</CodeBlock>
<p className="text-sm text-slate-700">
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
</CodeBlock>
<Button id="onboarding-inapp-connect-read-npm-docs" className="mt-3" variant="secondary" asChild>
@@ -125,11 +125,11 @@ export const OnboardingSetupInstructions = ({
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
<p className="mt-6 -mb-1 text-sm text-slate-700">
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
</p>
<div>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="js">
{channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys}
</CodeBlock>
</div>

View File

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

View File

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

View File

@@ -80,7 +80,7 @@ export const LandingSidebar = ({
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-hidden">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<>

View File

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

View File

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

View File

@@ -218,14 +218,14 @@ export const ProjectSettings = ({
</FormProvider>
</div>
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow-sm">
{logoUrl && (
<Image
src={logoUrl}
alt="Logo"
width={256}
height={56}
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>

View File

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

View File

@@ -1,191 +1,120 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth";
import { redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import SurveyEditorEnvironmentLayout from "./layout";
// mock all dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
// Mock sub-components to render identifiable elements
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
<div data-testid="EnvironmentIdBaseLayout">
{environmentId}
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: any) => (
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
// Mocks for dependencies
vi.mock("@/modules/environments/lib/utils", () => ({
environmentIdLayoutChecks: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
return (key: string) => key; // trivial translator returning the key
}),
}));
// mock child components rendered by the layout:
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) => (
<div data-testid="dev-environment-banner">{environment?.id || "no-env"}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
describe("SurveyEditorEnvironmentLayout", () => {
beforeEach(() => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("redirects to /auth/login if there is no session", async () => {
// Mock no session
vi.mocked(getServerSession).mockResolvedValueOnce(null);
it("renders successfully when environment is found", 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,
});
vi.mocked(getEnvironment).mockResolvedValueOnce({ id: "env1" } as TEnvironment);
const layoutElement = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello!</div>,
const result = await SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Survey Editor Content</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
// No JSX is returned after redirect
expect(layoutElement).toBeUndefined();
render(result);
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
});
it("throws error if user does not exist in DB", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello!</div>,
})
).rejects.toThrow("common.user_not_found");
});
it("throws AuthorizationError if user does not have environment access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div>Child</div>,
})
).rejects.toThrow(AuthorizationError);
});
it("throws if no organization is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello from children!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("throws if no environment is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
it("throws an error when environment 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(getEnvironment).mockResolvedValueOnce(null);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div>Child</div>,
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.environment_not_found");
});
it("renders environment layout if everything is valid", async () => {
// Provide all valid data
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env-123",
name: "My Test Environment",
} as unknown as TEnvironment);
// Because it's an async server component, we typically wrap in act(...)
let layoutElement: React.ReactNode;
await act(async () => {
layoutElement = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello from children!</div>,
});
render(layoutElement);
it("calls redirect when session is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: undefined as unknown as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
// Now confirm we got the child plus all the mocked sub-components
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
expect(screen.getByTestId("dev-environment-banner")).toHaveTextContent("env-123");
await expect(
SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("Redirect called");
});
it("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");
});
await expect(
SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
});
});

View File

@@ -1,46 +1,24 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
const SurveyEditorEnvironmentLayout = async (props) => {
const params = await props.params;
const { children } = props;
const t = await getTranslate();
const session = await getServerSession(authOptions);
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session?.user) {
if (!session) {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new AuthorizationError(t("common.not_authorized"));
}
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
@@ -48,23 +26,16 @@ const SurveyEditorEnvironmentLayout = async (props) => {
}
return (
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
</ResponseFilterProvider>
</EnvironmentIdBaseLayout>
);
};

View File

@@ -1,5 +1,5 @@
import { render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient";
@@ -9,14 +9,6 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("foo=bar"),
}));
// Mock the environment variables.
vi.mock("@formbricks/lib/env", () => ({
env: {
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: "env-test",
NEXT_PUBLIC_FORMBRICKS_API_HOST: "https://api.test.com",
},
}));
// Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true,
@@ -34,17 +26,21 @@ vi.mock("@formbricks/js", () => ({
}));
describe("FormbricksClient", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="user-123" email="test@example.com" />);
render(
<FormbricksClient
userId="user-123"
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Expect the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({
@@ -64,7 +60,15 @@ describe("FormbricksClient", () => {
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="" email="test@example.com" />);
render(
<FormbricksClient
userId=""
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled();

View File

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

View File

@@ -36,7 +36,7 @@ export const ActionClassesTable = ({
return (
<>
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
{TableHeading}
<div id="actionClassesWrapper" className="flex flex-col">
{actionClasses.length > 0 ? (

View File

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

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<>
<PageContentWrapper>
<PageHeader pageTitle={t("common.actions")} />
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">{t("common.edit")}</span>
<div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div>
@@ -22,7 +22,7 @@ const Loading = () => {
className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="h-6 w-6 flex-shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
<div className="ml-4 text-left">
<div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div>
@@ -33,7 +33,7 @@ const Loading = () => {
</div>
</div>
</div>
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>

View File

@@ -7,7 +7,7 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { getTranslate } from "@/tolgee/server";
import type { Session } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
@@ -111,6 +111,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
organizationProjectsLimit={organizationProjectsLimit}
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}

View File

@@ -63,6 +63,7 @@ interface NavigationProps {
projects: TProject[];
isMultiOrgEnabled: boolean;
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
@@ -79,6 +80,7 @@ export const MainNavigation = ({
isFormbricksCloud,
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -263,7 +265,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-hidden"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -296,7 +298,7 @@ export const MainNavigation = ({
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
@@ -330,7 +332,7 @@ export const MainNavigation = ({
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-hidden">
<div
tabIndex={0}
className={cn(

View File

@@ -1,9 +1,7 @@
// PosthogIdentify.test.tsx
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";

View File

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

View File

@@ -1,250 +1,156 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import EnvLayout from "./layout";
// mock all the dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
// Mock sub-components to render identifiable elements
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>,
}));
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
<div data-testid="EnvironmentIdBaseLayout">
{environmentId}
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="ToasterClient" />,
}));
vi.mock("../../components/FormbricksClient", () => ({
FormbricksClient: ({ userId, email }: any) => (
<div data-testid="FormbricksClient">
{userId}-{email}
</div>
),
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
return (key: string) => {
return key;
};
}),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
// Mocks for dependencies
vi.mock("@/modules/environments/lib/utils", () => ({
environmentIdLayoutChecks: vi.fn(),
}));
vi.mock("@formbricks/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@formbricks/lib/aiModels", () => ({
llmModel: {},
}));
// mock all the components that are rendered in the layout
vi.mock("./components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: () => <div data-testid="mock-storage-handler" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-environment-result">{children}</div>
),
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
describe("EnvLayout", () => {
beforeEach(() => {
afterEach(() => {
cleanup();
});
it("redirects to /auth/login if there is no session", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
// Since it's an async server component, call EnvLayout yourself:
const layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello!</div>,
it("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,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
// Because we have no session, we expect a redirect to "/auth/login"
expect(redirect).toHaveBeenCalledWith("/auth/login");
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Content</div>,
});
render(result);
// If your code calls redirect() early and returns no JSX,
// layoutElement might be undefined or null.
expect(layoutElement).toBeUndefined();
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
expect(screen.getByTestId("child")).toHaveTextContent("Content");
});
it("redirects to /auth/login if user does not exist in DB", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
it("throws error if project 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(getUser).mockResolvedValueOnce(null); // user not found
const layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello!</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(layoutElement).toBeUndefined();
});
it("throws AuthorizationError if user does not have environment access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
})
).rejects.toThrow(AuthorizationError);
});
it("throws if no organization is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello from children!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("throws if no project is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("project_not_found");
).rejects.toThrow("common.project_not_found");
});
it("calls notFound if membership is missing", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
it("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(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("membership_not_found");
).rejects.toThrow("common.membership_not_found");
});
it("renders environment layout if everything is valid", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
it("calls redirect when session is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: undefined as unknown as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "membership-123",
} as unknown as TMembership);
let layoutElement: React.ReactNode;
await act(async () => {
layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello from children!</div>,
});
// Now render the fully resolved layout
render(layoutElement);
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
expect(screen.getByTestId("mock-storage-handler")).toBeInTheDocument();
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
expect(screen.getByTestId("mock-environment-result")).toBeInTheDocument();
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("Redirect called");
});
it("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");
});
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
});
});

View File

@@ -1,20 +1,10 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { FormbricksClient } from "../../components/FormbricksClient";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
import { PosthogIdentify } from "./components/PosthogIdentify";
const EnvLayout = async (props: {
params: Promise<{ environmentId: string }>;
@@ -24,27 +14,16 @@ const EnvLayout = async (props: {
const { children } = props;
const t = await getTranslate();
const session = await getServerSession(authOptions);
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session?.user) {
if (!session) {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect(`/auth/login`);
throw new Error(t("common.user_not_found"));
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new AuthorizationError(t("common.not_authorized"));
}
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
@@ -57,23 +36,16 @@ const EnvLayout = async (props: {
}
return (
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
</ResponseFilterProvider>
</EnvironmentIdBaseLayout>
);
};

View File

@@ -56,7 +56,7 @@ export const EditAlerts = ({
<TooltipTrigger>
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
<span>{t("environments.settings.notifications.every_response")}</span>
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
<HelpCircleIcon className="h-4 w-4 shrink-0 text-slate-500" />
</div>
</TooltipTrigger>
<TooltipContent>
@@ -99,7 +99,7 @@ export const EditAlerts = ({
))}
</div>
) : (
<div className="m-2 flex h-16 items-center justify-center rounded bg-slate-50 text-sm text-slate-500">
<div className="m-2 flex h-16 items-center justify-center rounded-sm bg-slate-50 text-sm text-slate-500">
<p>{t("common.no_surveys_found")}</p>
</div>
)}

View File

@@ -11,7 +11,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
const { t } = useTranslate();
return (
<div>
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-xs md:space-y-0 md:text-base">
<SlackIcon className="mr-3 h-4 w-4 text-blue-400" />
<p className="text-sm">
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?

View File

@@ -105,7 +105,7 @@ export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: Edit
<div>
<div className="relative h-10 w-10 overflow-hidden rounded-full">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path

View File

@@ -97,7 +97,7 @@ const Page = async (props) => {
</PageHeader>
{isEnterpriseEdition ? (
<div>
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-xs">
<div className="space-y-4 p-8">
<div className="flex items-center gap-x-2">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
@@ -123,7 +123,7 @@ const Page = async (props) => {
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg
viewBox="0 0 1024 1024"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true">
<circle
cx={512}
@@ -152,7 +152,7 @@ const Page = async (props) => {
</p>
</div>
</div>
<div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-xs">
<div className="p-8">
<h2 className="mr-2 inline-flex text-2xl font-bold text-slate-700">
{t("environments.settings.enterprise.enterprise_features")}

View File

@@ -33,12 +33,16 @@ vi.mock("@formbricks/lib/constants", () => ({
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({

View File

@@ -25,13 +25,13 @@ export const SettingsCard = ({
return (
<div
className={cn(
"relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-sm",
"relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-xs",
className
)}
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="text-lg leading-6 font-medium text-slate-900 capitalize">{title}</h3>
<div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && (

View File

@@ -36,7 +36,7 @@ export const ResponseTableCell = ({
// Conditional rendering of maximize icon
const renderMaximizeIcon = cell.column.id === "createdAt" && (
<div
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 hover:border-slate-300 group-hover:flex"
className="hidden shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300"
onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" />
</div>

View File

@@ -71,7 +71,11 @@ const getQuestionColumnsData = (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
<span className="truncate">{getLocalizedValue(matrixRow, "default")}</span>
<span className="truncate">
{getLocalizedValue(question.headline, "default") +
" - " +
getLocalizedValue(matrixRow, "default")}
</span>
</div>
</div>
);

View File

@@ -20,7 +20,7 @@ interface AddressSummaryProps {
export const AddressSummary = ({ questionSummary, environmentId, survey, locale }: AddressSummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div>
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">

View File

@@ -16,7 +16,7 @@ export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader
survey={survey}
questionSummary={questionSummary}
@@ -40,7 +40,7 @@ export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
</>
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">CTR</p>

View File

@@ -16,9 +16,9 @@ export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">

View File

@@ -39,9 +39,9 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
},
];
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{summaryItems.map((summaryItem) => {
return (
<div

View File

@@ -25,7 +25,7 @@ export const ContactInfoSummary = ({
}: ContactInfoSummaryProps) => {
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div>
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">

View File

@@ -35,8 +35,18 @@ export const DateQuestionSummary = ({
);
};
const renderResponseValue = (value: string) => {
const parsedDate = new Date(value);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
@@ -70,8 +80,8 @@ export const DateQuestionSummary = ({
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{formatDateWithOrdinal(new Date(response.value as string))}
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}

View File

@@ -36,7 +36,7 @@ export const FileUploadSummary = ({
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={index} target="_blank" rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="absolute top-0 right-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>

View File

@@ -27,8 +27,8 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
);
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
</div>
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">

View File

@@ -45,14 +45,14 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
: [];
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="overflow-x-auto p-6">
{/* Summary Table */}
<table className="mx-auto border-collapse cursor-default text-left">
<thead>
<tr>
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
{columns.map((column) => (
<th key={column} className="text-center font-medium">
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<tbody>
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<tr key={rowLabel}>
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer>
@@ -83,7 +83,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
)}>
<div
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded-sm p-4 text-sm text-slate-950 hover:outline"
onClick={() =>
setFilter(
questionSummary.question.id,

View File

@@ -65,7 +65,7 @@ export const MultipleChoiceSummary = ({
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
@@ -78,7 +78,7 @@ export const MultipleChoiceSummary = ({
) : undefined
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<div
key={result.value}

View File

@@ -60,16 +60,16 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<div className="cursor-pointer hover:opacity-80" key={group} onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
@@ -91,7 +91,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
))}
</div>
<div className="flex justify-center pb-4 pt-4">
<div className="flex justify-center pt-4 pb-4">
<HalfCircle value={questionSummary.score} />
</div>
</div>

View File

@@ -60,7 +60,7 @@ export const OpenTextSummary = ({
];
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}

View File

@@ -30,7 +30,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
const results = questionSummary.choices;
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
) : undefined
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
<div
className="cursor-pointer hover:opacity-80"

View File

@@ -17,16 +17,16 @@ export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingS
});
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="flex w-full items-center">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
<div className="rounded-sm bg-slate-100 px-2 py-1">{result.value}</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}

View File

@@ -37,7 +37,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
}, [questionSummary]);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</div>
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<div
className="cursor-pointer hover:opacity-80"

View File

@@ -43,7 +43,7 @@ const ScrollToTop: React.FC<ScrollToTopProps> = ({ containerId }) => {
return (
<button
onClick={scrollToTop}
className={`fixed bottom-4 right-4 z-[1] flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
className={`fixed right-4 bottom-4 z-1 flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
showButton ? "opacity-80" : "opacity-0"
}`}>

View File

@@ -39,7 +39,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
<div className="">
<div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-3 pl-4 md:pl-6">{t("common.questions")}</div>
@@ -77,10 +77,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
)}
</p>
</div>
<div className="whitespace-pre-wrap text-center font-semibold">
<div className="text-center font-semibold whitespace-pre-wrap">
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
</div>
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
<div className="text-center font-semibold whitespace-pre-wrap">{quesDropOff.impressions}</div>
<div className="pl-6 text-center md:px-6">
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>

View File

@@ -17,7 +17,7 @@ const StatCard = ({ label, percentage, value, tooltipText, isLoading }) => {
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-xs">
<p className="flex items-center gap-1 text-sm text-slate-600">
{label}
{typeof percentage === "number" && !isNaN(percentage) && !isLoading && (
@@ -101,7 +101,7 @@ export const SummaryMetadata = ({
<TooltipTrigger>
<div
onClick={() => setShowDropOffs(!showDropOffs)}
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-xs">
<span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (

View File

@@ -99,7 +99,7 @@ export const EmbedView = ({
className={cn(
"rounded-md px-4 py-2",
tab.id === activeId
? "bg-white text-slate-900 shadow-sm"
? "bg-white text-slate-900 shadow-xs"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
{tab.label}

View File

@@ -389,8 +389,8 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
onOpenChange={(value) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-hidden">
<div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
@@ -416,14 +416,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.current_selection_csv")}</p>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">
{t("environments.surveys.summary.current_selection_excel")}
{t("environments.surveys.summary.filtered_responses_excel")}
</p>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -96,7 +96,7 @@ export const QuestionFilterComboBox = ({
<DropdownMenuTrigger
disabled={disabled}
className={clsx(
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:ring-0 focus:outline-transparent",
!disabled ? "cursor-pointer" : "opacity-50"
)}>
<div className="flex items-center justify-between">
@@ -146,7 +146,7 @@ export const QuestionFilterComboBox = ({
key={`${o}-${index}`}
type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
{o}
<X width={14} height={14} className="ml-2" />
</button>
@@ -166,7 +166,7 @@ export const QuestionFilterComboBox = ({
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-hidden">
<CommandList>
<div className="p-2">
<Input

View File

@@ -164,7 +164,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
value={inputValue}
onValueChange={setInputValue}
placeholder={t("common.search") + "..."}
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
className="h-5 border-none border-transparent p-0 shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0"
/>
)}
<div>
@@ -177,7 +177,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-hidden">
<CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (

View File

@@ -199,7 +199,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded-sm border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<span>
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
</span>

View File

@@ -86,8 +86,8 @@ export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProp
<DropdownMenu>
<DropdownMenuTrigger
asChild
className="focus:bg-muted cursor-pointer border border-slate-200 outline-none hover:border-slate-300">
<div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
className="focus:bg-muted cursor-pointer border border-slate-200 outline-hidden hover:border-slate-300">
<div className="h-auto min-w-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">
{t("environments.surveys.summary.share_results")}

View File

@@ -36,6 +36,9 @@ vi.mock("@formbricks/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_API_HOST: "mock-formbricks-api-host",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({

View File

@@ -1,18 +1,31 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getServerSession } from "next-auth";
import { Suspense } from "react";
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@formbricks/lib/constants";
import {
FORMBRICKS_API_HOST,
FORMBRICKS_ENVIRONMENT_ID,
IS_FORMBRICKS_ENABLED,
IS_POSTHOG_CONFIGURED,
POSTHOG_API_HOST,
POSTHOG_API_KEY,
} from "@formbricks/lib/constants";
import { getUser } from "@formbricks/lib/user/service";
const AppLayout = async ({ children }) => {
const session = await getServerSession(authOptions);
const user = session?.user?.id ? await getUser(session.user.id) : null;
// If user account is deactivated, log them out instead of rendering the app
if (user?.isActive === false) {
return <ClientLogout />;
}
return (
<>
<NoMobileOverlay />
@@ -25,7 +38,15 @@ const AppLayout = async ({ children }) => {
</Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
{user ? <FormbricksClient userId={user.id} email={user.email} /> : null}
{user ? (
<FormbricksClient
userId={user.id}
email={user.email}
formbricksApiHost={FORMBRICKS_API_HOST}
formbricksEnvironmentId={FORMBRICKS_ENVIRONMENT_ID}
formbricksEnabled={IS_FORMBRICKS_ENABLED}
/>
) : null}
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}

View File

@@ -62,9 +62,27 @@ describe("getApiKeyWithPermissions", () => {
describe("hasPermission", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
{ environmentId: "env-1", permission: "manage" },
{ environmentId: "env-2", permission: "write" },
{ environmentId: "env-3", permission: "read" },
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
{
environmentId: "env-2",
permission: "write",
environmentType: "production",
projectId: "project-2",
projectName: "Project 2",
},
{
environmentId: "env-3",
permission: "read",
environmentType: "development",
projectId: "project-3",
projectName: "Project 3",
},
];
it("should return true for manage permission with any method", () => {
@@ -108,7 +126,12 @@ describe("authenticateRequest", () => {
{
environmentId: "env-1",
permission: "manage" as const,
environment: { id: "env-1" },
environment: {
id: "env-1",
projectId: "project-1",
project: { name: "Project 1" },
type: "development",
},
},
],
};
@@ -121,7 +144,15 @@ describe("authenticateRequest", () => {
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [{ environmentId: "env-1", permission: "manage" }],
environmentPermissions: [
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",

View File

@@ -21,11 +21,15 @@ export const authenticateRequest = async (request: Request): Promise<TAuthentica
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
environmentId: env.environmentId,
environmentType: env.environment.type,
permission: env.permission,
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
};
return authentication;

View File

@@ -35,7 +35,7 @@ export const GET = async (req: NextRequest) => {
<div tw="flex rounded-2xl absolute -right-2 mt-2">
<a tw={`rounded-xl border border-transparent bg-[${brandColor}] h-18 w-38 opacity-50`}></a>
</div>
<div tw="flex rounded-2xl shadow ">
<div tw="flex rounded-2xl shadow-sm ">
<a
tw={`flex items-center justify-center rounded-xl border border-transparent bg-[${brandColor}] text-2xl text-white h-18 w-38`}>
Begin!

View File

@@ -1,3 +0,0 @@
import { GET } from "@/modules/api/v2/management/roles/route";
export { GET };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
export { GET };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/me/route";
export { GET };

View File

@@ -0,0 +1,3 @@
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
export { GET, POST, PUT, DELETE };

View File

@@ -0,0 +1,3 @@
import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route";
export { GET, PUT, DELETE };

View File

@@ -0,0 +1,3 @@
import { GET, POST } from "@/modules/api/v2/organizations/[organizationId]/teams/route";
export { GET, POST };

View File

@@ -0,0 +1,3 @@
import { GET, PATCH, POST } from "@/modules/api/v2/organizations/[organizationId]/users/route";
export { GET, POST, PATCH };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/roles/route";
export { GET };

View File

@@ -1,10 +1,6 @@
import formbricks from "@formbricks/js";
import { env } from "@formbricks/lib/env";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
export const formbricksEnabled =
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
export const formbricksLogout = async () => {
const loggedInWith = localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS);
localStorage.clear();

View File

@@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SentryProvider } from "./SentryProvider";
vi.mock("@sentry/nextjs", async () => {

View File

@@ -47,6 +47,8 @@ export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) =>
},
});
}
// We only want to run this once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <>{children}</>;

View File

@@ -0,0 +1,74 @@
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { getOrganizationsWhereUserIsSingleOwner } from "@formbricks/lib/organization/service";
import { deleteUser } from "@formbricks/lib/user/service";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { deleteUserAction } from "./actions";
// Mock all dependencies
vi.mock("@formbricks/lib/user/service", () => ({
deleteUser: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
}));
// add a mock to authenticatedActionClient.action
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
action: (fn: any) => {
return fn;
},
},
}));
describe("deleteUserAction", () => {
it("deletes user successfully when multi-org is enabled", async () => {
const ctx = { user: { id: "test-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "test-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(true);
const result = await deleteUserAction({ ctx } as any);
expect(result).toStrictEqual({ id: "test-user" } as TUser);
expect(deleteUser).toHaveBeenCalledWith("test-user");
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("test-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
it("deletes user successfully when multi-org is disabled but user is not sole owner of any org", async () => {
const ctx = { user: { id: "another-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "another-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(false);
const result = await deleteUserAction({ ctx } as any);
expect(result).toStrictEqual({ id: "another-user" } as TUser);
expect(deleteUser).toHaveBeenCalledWith("another-user");
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("another-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
it("throws OperationNotAllowedError when user is sole owner in at least one org and multi-org is disabled", async () => {
const ctx = { user: { id: "sole-owner-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "test-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([
{ id: "org-1" } as TOrganization,
]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(false);
await expect(() => deleteUserAction({ ctx } as any)).rejects.toThrow(OperationNotAllowedError);
expect(deleteUser).not.toHaveBeenCalled();
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("sole-owner-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,161 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import * as nextAuth from "next-auth/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import * as actions from "./actions";
import { DeleteAccountModal } from "./index";
vi.mock("next-auth/react", async () => {
const actual = await vi.importActual("next-auth/react");
return {
...actual,
signOut: vi.fn(),
};
});
vi.mock("./actions", () => ({
deleteUserAction: vi.fn(),
}));
describe("DeleteAccountModal", () => {
const mockUser: TUser = {
email: "test@example.com",
} as TUser;
const mockOrgs: TOrganization[] = [{ name: "Org1" }, { name: "Org2" }] as TOrganization[];
const mockSetOpen = vi.fn();
const mockLogout = vi.fn();
afterEach(() => {
cleanup();
});
it("renders modal with correct props", () => {
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={mockOrgs}
formbricksLogout={mockLogout}
/>
);
expect(screen.getByText("Org1")).toBeInTheDocument();
expect(screen.getByText("Org2")).toBeInTheDocument();
});
it("disables delete button when email does not match", () => {
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "wrong@example.com" } });
expect(input).toHaveValue("wrong@example.com");
});
it("allows account deletion flow (non-cloud)", async () => {
const deleteUserAction = vi
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
const signOut = vi.spyOn(nextAuth, "signOut").mockResolvedValue(undefined);
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } });
const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form);
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockLogout).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
it("allows account deletion flow (cloud)", async () => {
const deleteUserAction = vi
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
const signOut = vi.spyOn(nextAuth, "signOut").mockResolvedValue(undefined);
Object.defineProperty(window, "location", {
writable: true,
value: { replace: vi.fn() },
});
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={true}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } });
const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form);
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockLogout).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ redirect: true });
expect(window.location.replace).toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
it("handles deletion errors", async () => {
const deleteUserAction = vi.spyOn(actions, "deleteUserAction").mockRejectedValue(new Error("fail"));
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } });
const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form);
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
});

View File

@@ -2,8 +2,7 @@
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { T } from "@tolgee/react";
import { T, useTranslate } from "@tolgee/react";
import { signOut } from "next-auth/react";
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
@@ -88,6 +87,7 @@ export const DeleteAccountModal = ({
<li>{t("environments.settings.profile.warning_cannot_undo")}</li>
</ul>
<form
data-testid="deleteAccountForm"
onSubmit={async (e) => {
e.preventDefault();
await deleteAccount();
@@ -98,6 +98,7 @@ export const DeleteAccountModal = ({
})}
</label>
<Input
data-testid="deleteAccountConfirmation"
value={inputValue}
onChange={handleInputChange}
placeholder={user.email}

View File

@@ -0,0 +1,117 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RatingSmiley } from "./index";
// Mock the smiley components from ../SingleResponseCard/components/Smileys
vi.mock("../SingleResponseCard/components/Smileys", () => ({
TiredFace: (props: any) => (
<span data-testid="TiredFace" className={props.className}>
TiredFace
</span>
),
WearyFace: (props: any) => (
<span data-testid="WearyFace" className={props.className}>
WearyFace
</span>
),
PerseveringFace: (props: any) => (
<span data-testid="PerseveringFace" className={props.className}>
PerseveringFace
</span>
),
FrowningFace: (props: any) => (
<span data-testid="FrowningFace" className={props.className}>
FrowningFace
</span>
),
ConfusedFace: (props: any) => (
<span data-testid="ConfusedFace" className={props.className}>
ConfusedFace
</span>
),
NeutralFace: (props: any) => (
<span data-testid="NeutralFace" className={props.className}>
NeutralFace
</span>
),
SlightlySmilingFace: (props: any) => (
<span data-testid="SlightlySmilingFace" className={props.className}>
SlightlySmilingFace
</span>
),
SmilingFaceWithSmilingEyes: (props: any) => (
<span data-testid="SmilingFaceWithSmilingEyes" className={props.className}>
SmilingFaceWithSmilingEyes
</span>
),
GrinningFaceWithSmilingEyes: (props: any) => (
<span data-testid="GrinningFaceWithSmilingEyes" className={props.className}>
GrinningFaceWithSmilingEyes
</span>
),
GrinningSquintingFace: (props: any) => (
<span data-testid="GrinningSquintingFace" className={props.className}>
GrinningSquintingFace
</span>
),
}));
describe("RatingSmiley", () => {
afterEach(() => {
cleanup();
});
const activeClass = "fill-rating-fill";
// Test branch: range === 10 => iconsIdx = [0,1,2,...,9]
it("renders correct icon for range 10 when active", () => {
// For idx 0, iconsIdx[0] === 0, which corresponds to TiredFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={10} addColors={true} />);
const icon = getByTestId("TiredFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
it("renders correct icon for range 10 when inactive", () => {
const { getByTestId } = render(<RatingSmiley active={false} idx={0} range={10} />);
const icon = getByTestId("TiredFace");
expect(icon).toBeDefined();
expect(icon.className).toContain("fill-none");
});
// Test branch: range === 7 => iconsIdx = [1,3,4,5,6,8,9]
it("renders correct icon for range 7 when active", () => {
// For idx 0, iconsIdx[0] === 1, which corresponds to WearyFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={7} addColors={true} />);
const icon = getByTestId("WearyFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
// Test branch: range === 5 => iconsIdx = [3,4,5,6,7]
it("renders correct icon for range 5 when active", () => {
// For idx 0, iconsIdx[0] === 3, which corresponds to FrowningFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={5} addColors={true} />);
const icon = getByTestId("FrowningFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
// Test branch: range === 4 => iconsIdx = [4,5,6,7]
it("renders correct icon for range 4 when active", () => {
// For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={4} addColors={true} />);
const icon = getByTestId("ConfusedFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
// Test branch: range === 3 => iconsIdx = [4,5,7]
it("renders correct icon for range 3 when active", () => {
// For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={3} addColors={true} />);
const icon = getByTestId("ConfusedFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
});

View File

@@ -40,16 +40,28 @@ const getSmiley = (iconIdx: number, idx: number, range: number, active: boolean,
const inactiveColor = addColors ? getSmileyColor(range, idx) : "fill-none";
const icons = [
<TiredFace className={active ? activeColor : inactiveColor} />,
<WearyFace className={active ? activeColor : inactiveColor} />,
<PerseveringFace className={active ? activeColor : inactiveColor} />,
<FrowningFace className={active ? activeColor : inactiveColor} />,
<ConfusedFace className={active ? activeColor : inactiveColor} />,
<NeutralFace className={active ? activeColor : inactiveColor} />,
<SlightlySmilingFace className={active ? activeColor : inactiveColor} />,
<SmilingFaceWithSmilingEyes className={active ? activeColor : inactiveColor} />,
<GrinningFaceWithSmilingEyes className={active ? activeColor : inactiveColor} />,
<GrinningSquintingFace className={active ? activeColor : inactiveColor} />,
<TiredFace className={active ? activeColor : inactiveColor} data-testid="TiredFace" />,
<WearyFace className={active ? activeColor : inactiveColor} data-testid="WearyFace" />,
<PerseveringFace className={active ? activeColor : inactiveColor} data-testid="PerseveringFace" />,
<FrowningFace className={active ? activeColor : inactiveColor} data-testid="FrowningFace" />,
<ConfusedFace className={active ? activeColor : inactiveColor} data-testid="ConfusedFace" />,
<NeutralFace className={active ? activeColor : inactiveColor} data-testid="NeutralFace" />,
<SlightlySmilingFace
className={active ? activeColor : inactiveColor}
data-testid="SlightlySmilingFace"
/>,
<SmilingFaceWithSmilingEyes
className={active ? activeColor : inactiveColor}
data-testid="SmilingFaceWithSmilingEyes"
/>,
<GrinningFaceWithSmilingEyes
className={active ? activeColor : inactiveColor}
data-testid="GrinningFaceWithSmilingEyes"
/>,
<GrinningSquintingFace
className={active ? activeColor : inactiveColor}
data-testid="GrinningSquintingFace"
/>,
];
return icons[iconIdx];

View File

@@ -0,0 +1,90 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getEnabledLanguages, getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { LanguageDropdown } from "./LanguageDropdown";
vi.mock("@formbricks/lib/i18n/utils", () => ({
getEnabledLanguages: vi.fn(),
getLanguageLabel: vi.fn(),
}));
describe("LanguageDropdown", () => {
const dummySurveyMultiple = {
languages: [
{ language: { code: "en" } } as TSurveyLanguage,
{ language: { code: "fr" } } as TSurveyLanguage,
],
} as TSurvey;
const dummySurveySingle = {
languages: [{ language: { code: "en" } }],
} as TSurvey;
const dummyLocale = "en-US";
const setLanguageMock = vi.fn();
afterEach(() => {
cleanup();
});
it("renders nothing when enabledLanguages length is 1", () => {
vi.mocked(getEnabledLanguages).mockReturnValueOnce([{ language: { code: "en" } } as TSurveyLanguage]);
render(
<LanguageDropdown survey={dummySurveySingle} setLanguage={setLanguageMock} locale={dummyLocale} />
);
// Since enabledLanguages.length === 1, component should render null.
expect(screen.queryByRole("button")).toBeNull();
});
it("renders button and toggles dropdown when multiple languages exist", async () => {
vi.mocked(getEnabledLanguages).mockReturnValue(dummySurveyMultiple.languages);
vi.mocked(getLanguageLabel).mockImplementation((code: string, _locale: string) => code.toUpperCase());
render(
<LanguageDropdown survey={dummySurveyMultiple} setLanguage={setLanguageMock} locale={dummyLocale} />
);
const button = screen.getByRole("button", { name: "Select Language" });
expect(button).toBeDefined();
await userEvent.click(button);
// Wait for the dropdown options to appear. They are wrapped in a div with no specific role,
// so we query for texts (our mock labels) instead.
const optionEn = await screen.findByText("EN");
const optionFr = await screen.findByText("FR");
expect(optionEn).toBeDefined();
expect(optionFr).toBeDefined();
await userEvent.click(optionFr);
expect(setLanguageMock).toHaveBeenCalledWith("fr");
// After clicking, dropdown should no longer be visible.
await waitFor(() => {
expect(screen.queryByText("EN")).toBeNull();
expect(screen.queryByText("FR")).toBeNull();
});
});
it("closes dropdown when clicking outside", async () => {
vi.mocked(getEnabledLanguages).mockReturnValue(dummySurveyMultiple.languages);
vi.mocked(getLanguageLabel).mockImplementation((code: string, _locale: string) => code);
render(
<LanguageDropdown survey={dummySurveyMultiple} setLanguage={setLanguageMock} locale={dummyLocale} />
);
const button = screen.getByRole("button", { name: "Select Language" });
await userEvent.click(button);
// Confirm dropdown shown
expect(await screen.findByText("en")).toBeDefined();
// Simulate clicking outside by dispatching a click event on the container's parent.
await userEvent.click(document.body);
// Wait for dropdown to close
await waitFor(() => {
expect(screen.queryByText("en")).toBeNull();
});
});
});

View File

@@ -1,8 +1,7 @@
import { Button } from "@/modules/ui/components/button";
import { Languages } from "lucide-react";
import { useRef, useState } from "react";
import { getEnabledLanguages } from "@formbricks/lib/i18n/utils";
import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { getEnabledLanguages, getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";

View File

@@ -0,0 +1,22 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { SurveyLinkDisplay } from "./SurveyLinkDisplay";
describe("SurveyLinkDisplay", () => {
afterEach(() => {
cleanup();
});
it("renders the Input when surveyUrl is provided", () => {
const surveyUrl = "http://example.com/s/123";
render(<SurveyLinkDisplay surveyUrl={surveyUrl} />);
const input = screen.getByTestId("survey-url-input");
expect(input).toBeInTheDocument();
});
it("renders loading state when surveyUrl is empty", () => {
render(<SurveyLinkDisplay surveyUrl="" />);
const loadingDiv = screen.getByTestId("loading-div");
expect(loadingDiv).toBeInTheDocument();
});
});

View File

@@ -9,13 +9,16 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
<>
{surveyUrl ? (
<Input
data-testid="survey-url-input"
autoFocus={true}
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
className="mt-2 w-full min-w-96 rounded-lg border bg-white px-4 py-2 text-ellipsis text-slate-800 caret-transparent"
value={surveyUrl}
/>
) : (
//loading state
<div className="mt-2 h-10 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-4 py-2 text-slate-800 caret-transparent"></div>
<div
data-testid="loading-div"
className="mt-2 h-10 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-4 py-2 text-slate-800 caret-transparent"></div>
)}
</>
);

View File

@@ -0,0 +1,247 @@
import { useSurveyQRCode } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ShareSurveyLink } from "./index";
const dummySurvey = {
id: "survey123",
singleUse: { enabled: true, isEncrypted: false },
type: "link",
status: "completed",
} as any;
const dummySurveyDomain = "http://dummy.com";
const dummyLocale = "en-US";
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
}));
vi.mock("@/modules/survey/list/actions", () => ({
generateSingleUseIdAction: vi.fn(),
}));
vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code",
() => ({
useSurveyQRCode: vi.fn(() => ({
downloadQRCode: vi.fn(),
})),
})
);
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((error: any) => error.message),
}));
vi.mock("./components/LanguageDropdown", () => {
const React = require("react");
return {
LanguageDropdown: (props: { setLanguage: (lang: string) => void }) => {
// Call setLanguage("fr-FR") when the component mounts to simulate a language change.
React.useEffect(() => {
props.setLanguage("fr-FR");
}, [props.setLanguage]);
return <div>Mocked LanguageDropdown</div>;
},
};
});
describe("ShareSurveyLink", () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
window.open = vi.fn();
});
afterEach(() => {
cleanup();
});
it("calls getUrl on mount and sets surveyUrl accordingly with singleUse enabled and default language", async () => {
// Inline mocks for this test
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl=""
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
await waitFor(() => {
expect(setSurveyUrl).toHaveBeenCalled();
});
const url = setSurveyUrl.mock.calls[0][0];
expect(url).toContain(`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`);
expect(url).not.toContain("lang=");
});
it("appends language query when language is changed from default", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
const DummyWrapper = () => (
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl="initial"
setSurveyUrl={setSurveyUrl}
locale="fr-FR"
/>
);
render(<DummyWrapper />);
await waitFor(() => {
const generatedUrl = setSurveyUrl.mock.calls[1][0];
expect(generatedUrl).toContain("lang=fr-FR");
});
});
it("preview button opens new window with preview query", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn().mockReturnValue(`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`);
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const previewButton = await screen.findByRole("button", {
name: /environments.surveys.preview_survey_in_a_new_tab/i,
});
fireEvent.click(previewButton);
await waitFor(() => {
expect(window.open).toHaveBeenCalled();
const previewUrl = vi.mocked(window.open).mock.calls[0][0];
expect(previewUrl).toMatch(/\?suId=dummySuId(&|\\?)preview=true/);
});
});
it("copy button writes surveyUrl to clipboard and shows toast", async () => {
vi.mocked(getFormattedErrorMessage).mockReturnValue("common.copied_to_clipboard");
vi.mocked(copySurveyLink).mockImplementation((url: string, newId: string) => `${url}?suId=${newId}`);
const setSurveyUrl = vi.fn();
const surveyUrl = `${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`;
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const copyButton = await screen.findByRole("button", {
name: /environments.surveys.copy_survey_link_to_clipboard/i,
});
fireEvent.click(copyButton);
await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
it("download QR code button calls downloadQRCode", async () => {
const dummyDownloadQRCode = vi.fn();
vi.mocked(getFormattedErrorMessage).mockReturnValue("common.copied_to_clipboard");
vi.mocked(useSurveyQRCode).mockReturnValue({ downloadQRCode: dummyDownloadQRCode } as any);
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const downloadButton = await screen.findByRole("button", {
name: /environments.surveys.summary.download_qr_code/i,
});
fireEvent.click(downloadButton);
expect(dummyDownloadQRCode).toHaveBeenCalled();
});
it("renders regenerate button when survey.singleUse.enabled is true and calls generateNewSingleUseLink", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const regenButton = await screen.findByRole("button", { name: /Regenerate single use survey link/i });
fireEvent.click(regenButton);
await waitFor(() => {
expect(generateSingleUseIdAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("environments.surveys.new_single_use_link_generated");
});
});
it("handles error when generating single-use link fails", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: undefined });
vi.mocked(getFormattedErrorMessage).mockReturnValue("Failed to generate link");
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl=""
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Failed to generate link");
});
});
});

View File

@@ -0,0 +1,206 @@
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getEnvironmentIdFromResponseId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromResponseId,
getOrganizationIdFromResponseNoteId,
getProjectIdFromEnvironmentId,
getProjectIdFromResponseId,
getProjectIdFromResponseNoteId,
} from "@/lib/utils/helper";
import { getTag } from "@/lib/utils/services";
import { describe, expect, it, vi } from "vitest";
import { deleteResponse, getResponse } from "@formbricks/lib/response/service";
import {
createResponseNote,
resolveResponseNote,
updateResponseNote,
} from "@formbricks/lib/responseNote/service";
import { createTag } from "@formbricks/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/tagOnResponse/service";
import {
createResponseNoteAction,
createTagAction,
createTagToResponseAction,
deleteResponseAction,
deleteTagOnResponseAction,
getResponseAction,
resolveResponseNoteAction,
updateResponseNoteAction,
} from "./actions";
// Dummy inputs and context
const dummyCtx = { user: { id: "user1" } };
const dummyTagInput = { environmentId: "env1", tagName: "tag1" };
const dummyTagToResponseInput = { responseId: "resp1", tagId: "tag1" };
const dummyResponseIdInput = { responseId: "resp1" };
const dummyResponseNoteInput = { responseNoteId: "note1", text: "Updated note" };
const dummyCreateNoteInput = { responseId: "resp1", text: "New note" };
const dummyGetResponseInput = { responseId: "resp1" };
// Mocks for external dependencies
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getProjectIdFromEnvironmentId: vi.fn().mockResolvedValue("proj-env"),
getOrganizationIdFromResponseId: vi.fn().mockResolvedValue("org-resp"),
getOrganizationIdFromResponseNoteId: vi.fn().mockResolvedValue("org-resp-note"),
getProjectIdFromResponseId: vi.fn().mockResolvedValue("proj-resp"),
getProjectIdFromResponseNoteId: vi.fn().mockResolvedValue("proj-resp-note"),
getEnvironmentIdFromResponseId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getTag: vi.fn(),
}));
vi.mock("@formbricks/lib/response/service", () => ({
deleteResponse: vi.fn().mockResolvedValue("deletedResponse"),
getResponse: vi.fn().mockResolvedValue({ data: "responseData" }),
}));
vi.mock("@formbricks/lib/responseNote/service", () => ({
createResponseNote: vi.fn().mockResolvedValue("createdNote"),
updateResponseNote: vi.fn().mockResolvedValue("updatedNote"),
resolveResponseNote: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@formbricks/lib/tag/service", () => ({
createTag: vi.fn().mockResolvedValue("createdTag"),
}));
vi.mock("@formbricks/lib/tagOnResponse/service", () => ({
addTagToRespone: vi.fn().mockResolvedValue("tagAdded"),
deleteTagOnResponse: vi.fn().mockResolvedValue("tagDeleted"),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
schema: () => ({
action: (fn: any) => async (input: any) => {
const { user, ...rest } = input;
return fn({
parsedInput: rest,
ctx: { user },
});
},
}),
},
}));
describe("createTagAction", () => {
it("successfully creates a tag", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValueOnce("org1");
await createTagAction({ ...dummyTagInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromEnvironmentId).toHaveBeenCalledWith(dummyTagInput.environmentId);
expect(getProjectIdFromEnvironmentId).toHaveBeenCalledWith(dummyTagInput.environmentId);
expect(createTag).toHaveBeenCalledWith(dummyTagInput.environmentId, dummyTagInput.tagName);
});
});
describe("createTagToResponseAction", () => {
it("adds tag to response when environments match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "env1" });
await createTagToResponseAction({ ...dummyTagToResponseInput, ...dummyCtx });
expect(getEnvironmentIdFromResponseId).toHaveBeenCalledWith(dummyTagToResponseInput.responseId);
expect(getTag).toHaveBeenCalledWith(dummyTagToResponseInput.tagId);
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(addTagToRespone).toHaveBeenCalledWith(
dummyTagToResponseInput.responseId,
dummyTagToResponseInput.tagId
);
});
it("throws error when environments do not match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "differentEnv" });
await expect(createTagToResponseAction({ ...dummyTagToResponseInput, ...dummyCtx })).rejects.toThrow(
"Response and tag are not in the same environment"
);
});
});
describe("deleteTagOnResponseAction", () => {
it("deletes tag on response when environments match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "env1" });
await deleteTagOnResponseAction({ ...dummyTagToResponseInput, ...dummyCtx });
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyTagToResponseInput.responseId);
expect(getTag).toHaveBeenCalledWith(dummyTagToResponseInput.tagId);
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(deleteTagOnResponse).toHaveBeenCalledWith(
dummyTagToResponseInput.responseId,
dummyTagToResponseInput.tagId
);
});
it("throws error when environments do not match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "differentEnv" });
await expect(deleteTagOnResponseAction({ ...dummyTagToResponseInput, ...dummyCtx })).rejects.toThrow(
"Response and tag are not in the same environment"
);
});
});
describe("deleteResponseAction", () => {
it("deletes response successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await deleteResponseAction({ ...dummyResponseIdInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
expect(deleteResponse).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
});
});
describe("updateResponseNoteAction", () => {
it("updates response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await updateResponseNoteAction({ ...dummyResponseNoteInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseNoteId).toHaveBeenCalledWith(dummyResponseNoteInput.responseNoteId);
expect(getProjectIdFromResponseNoteId).toHaveBeenCalledWith(dummyResponseNoteInput.responseNoteId);
expect(updateResponseNote).toHaveBeenCalledWith(
dummyResponseNoteInput.responseNoteId,
dummyResponseNoteInput.text
);
});
});
describe("resolveResponseNoteAction", () => {
it("resolves response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await resolveResponseNoteAction({ responseNoteId: "note1", ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseNoteId).toHaveBeenCalledWith("note1");
expect(getProjectIdFromResponseNoteId).toHaveBeenCalledWith("note1");
expect(resolveResponseNote).toHaveBeenCalledWith("note1");
});
});
describe("createResponseNoteAction", () => {
it("creates a response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await createResponseNoteAction({ ...dummyCreateNoteInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyCreateNoteInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyCreateNoteInput.responseId);
expect(createResponseNote).toHaveBeenCalledWith(
dummyCreateNoteInput.responseId,
dummyCtx.user.id,
dummyCreateNoteInput.text
);
});
});
describe("getResponseAction", () => {
it("retrieves response successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await getResponseAction({ ...dummyGetResponseInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
expect(getResponse).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
});
});

View File

@@ -0,0 +1,70 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TSurveyHiddenFields } from "@formbricks/types/surveys/types";
import { HiddenFields } from "./HiddenFields";
// Mock tooltip components to always render their children
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe("HiddenFields", () => {
afterEach(() => {
cleanup();
});
it("renders empty container when no fieldIds are provided", () => {
render(
<HiddenFields hiddenFields={{ fieldIds: [] } as unknown as TSurveyHiddenFields} responseData={{}} />
);
const container = screen.getByTestId("main-hidden-fields-div");
expect(container).toBeDefined();
});
it("renders nothing for fieldIds with no corresponding response data", () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1"] } as unknown as TSurveyHiddenFields}
responseData={{}}
/>
);
expect(screen.queryByText("field1")).toBeNull();
});
it("renders field and value when responseData exists and is a string", async () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1", "field2"] } as unknown as TSurveyHiddenFields}
responseData={{ field1: "Value 1", field2: "" }}
/>
);
expect(screen.getByText("field1")).toBeInTheDocument();
expect(screen.getByText("Value 1")).toBeInTheDocument();
expect(screen.queryByText("field2")).toBeNull();
});
it("renders empty text when responseData value is not a string", () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1"] } as unknown as TSurveyHiddenFields}
responseData={{ field1: { any: "object" } }}
/>
);
expect(screen.getByText("field1")).toBeInTheDocument();
const valueParagraphs = screen.getAllByText("", { selector: "p" });
expect(valueParagraphs.length).toBeGreaterThan(0);
});
it("displays tooltip content for hidden field", async () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1"] } as unknown as TSurveyHiddenFields}
responseData={{ field1: "Value 1" }}
/>
);
expect(screen.getByText("common.hidden_field")).toBeInTheDocument();
});
});

View File

@@ -15,7 +15,7 @@ export const HiddenFields = ({ hiddenFields, responseData }: HiddenFieldsProps)
const { t } = useTranslate();
const fieldIds = hiddenFields.fieldIds ?? [];
return (
<div className="mt-6 flex flex-col gap-6">
<div data-testid="main-hidden-fields-div" className="mt-6 flex flex-col gap-6">
{fieldIds.map((field) => {
if (!responseData[field]) return;
return (

View File

@@ -0,0 +1,98 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { QuestionSkip } from "./QuestionSkip";
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: any) => <div>{children}</div>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
TooltipProvider: ({ children }: any) => <div>{children}</div>,
TooltipTrigger: ({ children }: any) => <div>{children}</div>,
}));
vi.mock("@/modules/i18n/utils", () => ({
getLocalizedValue: vi.fn((value, _) => value),
}));
// Mock recall utils
vi.mock("@formbricks/lib/utils/recall", () => ({
parseRecallInfo: vi.fn((headline, _) => {
return `parsed: ${headline}`;
}),
}));
const dummyQuestions = [
{ id: "f1", headline: "headline1" },
{ id: "f2", headline: "headline2" },
] as unknown as TSurveyQuestion[];
const dummyResponseData = { f1: "Answer 1", f2: "Answer 2" };
describe("QuestionSkip", () => {
afterEach(() => {
cleanup();
});
it("renders nothing when skippedQuestions is falsy", () => {
render(
<QuestionSkip
skippedQuestions={undefined}
status="skipped"
questions={dummyQuestions}
responseData={dummyResponseData}
/>
);
expect(screen.queryByText("headline1")).toBeNull();
expect(screen.queryByText("headline2")).toBeNull();
});
it("renders welcomeCard branch", () => {
render(
<QuestionSkip
skippedQuestions={["f1"]}
status="welcomeCard"
questions={dummyQuestions}
responseData={{ f1: "Answer 1" }}
isFirstQuestionAnswered={false}
/>
);
expect(screen.getByText("common.welcome_card")).toBeInTheDocument();
});
it("renders skipped branch with tooltip and parsed headlines", () => {
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline1");
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline2");
render(
<QuestionSkip
skippedQuestions={["f1", "f2"]}
status="skipped"
questions={dummyQuestions}
responseData={dummyResponseData}
/>
);
// Check tooltip text from TooltipContent
expect(screen.getByTestId("tooltip-respondent_skipped_questions")).toBeInTheDocument();
// Check mapping: parseRecallInfo should be called on each headline value, so expect the parsed text to appear.
expect(screen.getByText("parsed: headline1")).toBeInTheDocument();
expect(screen.getByText("parsed: headline2")).toBeInTheDocument();
});
it("renders aborted branch with closed message and parsed headlines", () => {
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline1");
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline2");
render(
<QuestionSkip
skippedQuestions={["f1", "f2"]}
status="aborted"
questions={dummyQuestions}
responseData={dummyResponseData}
/>
);
expect(screen.getByTestId("tooltip-survey_closed")).toBeInTheDocument();
expect(screen.getByText("parsed: headline1")).toBeInTheDocument();
expect(screen.getByText("parsed: headline2")).toBeInTheDocument();
});
});

View File

@@ -39,7 +39,7 @@ export const QuestionSkip = ({
background:
"repeating-linear-gradient(rgb(148, 163, 184), rgb(148, 163, 184) 5px, transparent 5px, transparent 8px)", // adjust the values to fit your design
}}>
<CheckCircle2Icon className="p-0.25 absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white text-slate-400" />
<CheckCircle2Icon className="absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white p-0.25 text-slate-400" />
</div>
}
<div className="ml-6 flex flex-col text-slate-700">{t("common.welcome_card")}</div>
@@ -60,34 +60,35 @@ export const QuestionSkip = ({
<ChevronsDownIcon className="w-[1.25rem] min-w-[1.25rem] rounded-full bg-slate-400 p-0.5 text-white" />
</TooltipTrigger>
<TooltipContent>
<p>{t("environments.surveys.responses.respondent_skipped_questions")}</p>
<p data-testid="tooltip-respondent_skipped_questions">
{t("environments.surveys.responses.respondent_skipped_questions")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="ml-6 flex flex-col">
{skippedQuestions &&
skippedQuestions.map((questionId) => {
return (
<p className="my-2" key={questionId}>
{parseRecallInfo(
getLocalizedValue(
questions.find((question) => question.id === questionId)!.headline,
"default"
),
responseData
)}
</p>
);
})}
{skippedQuestions?.map((questionId) => {
return (
<p className="my-2" key={questionId}>
{parseRecallInfo(
getLocalizedValue(
questions.find((question) => question.id === questionId)!.headline,
"default"
),
responseData
)}
</p>
);
})}
</div>
</div>
)}
{status === "aborted" && (
<div className="flex">
<div
className="flex w-0.5 flex-grow items-start justify-center"
className="flex w-0.5 grow items-start justify-center"
style={{
background:
"repeating-linear-gradient(to bottom, rgb(148 163 184), rgb(148 163 184) 2px, transparent 2px, transparent 10px)", // adjust the 2px to change dot size and 10px to change space between dots
@@ -97,7 +98,9 @@ export const QuestionSkip = ({
</div>
</div>
<div className="mb-2 ml-4 flex flex-col">
<p className="mb-2 w-fit rounded-lg bg-slate-100 px-2 font-medium text-slate-700">
<p
data-testid="tooltip-survey_closed"
className="mb-2 w-fit rounded-lg bg-slate-100 px-2 font-medium text-slate-700">
{t("environments.surveys.responses.survey_closed")}
</p>
{skippedQuestions &&

View File

@@ -0,0 +1,277 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RenderResponse } from "./RenderResponse";
// Mocks for dependencies
vi.mock("@/modules/ui/components/rating-response", () => ({
RatingResponse: ({ answer }: any) => <div data-testid="RatingResponse">Rating: {answer}</div>,
}));
vi.mock("@/modules/ui/components/file-upload-response", () => ({
FileUploadResponse: ({ selected }: any) => (
<div data-testid="FileUploadResponse">FileUpload: {selected.join(",")}</div>
),
}));
vi.mock("@/modules/ui/components/picture-selection-response", () => ({
PictureSelectionResponse: ({ selected, isExpanded }: any) => (
<div data-testid="PictureSelectionResponse">
PictureSelection: {selected.join(",")} ({isExpanded ? "expanded" : "collapsed"})
</div>
),
}));
vi.mock("@/modules/ui/components/array-response", () => ({
ArrayResponse: ({ value }: any) => <div data-testid="ArrayResponse">{value.join(",")}</div>,
}));
vi.mock("@/modules/ui/components/response-badges", () => ({
ResponseBadges: ({ items }: any) => <div data-testid="ResponseBadges">{items.join(",")}</div>,
}));
vi.mock("@/modules/ui/components/ranking-response", () => ({
RankingRespone: ({ value }: any) => <div data-testid="RankingRespone">{value.join(",")}</div>,
}));
vi.mock("@/modules/analysis/utils", () => ({
renderHyperlinkedContent: vi.fn((text: string) => "hyper:" + text),
}));
vi.mock("@formbricks/lib/responses", () => ({
processResponseData: (val: any) => "processed:" + val,
}));
vi.mock("@formbricks/lib/utils/datetime", () => ({
formatDateWithOrdinal: (d: Date) => "formatted_" + d.toISOString(),
}));
vi.mock("@formbricks/lib/cn", () => ({
cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(" "),
}));
vi.mock("@formbricks/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn((val, _) => val),
getLanguageCode: vi.fn().mockReturnValue("default"),
}));
describe("RenderResponse", () => {
afterEach(() => {
cleanup();
});
const defaultSurvey = { languages: [] } as any;
const defaultQuestion = { id: "q1", type: "Unknown" } as any;
const dummyLanguage = "default";
it("returns '-' for empty responseData (string)", () => {
const { container } = render(
<RenderResponse
responseData={""}
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(container.textContent).toBe("-");
});
it("returns '-' for empty responseData (array)", () => {
const { container } = render(
<RenderResponse
responseData={[]}
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(container.textContent).toBe("-");
});
it("returns '-' for empty responseData (object)", () => {
const { container } = render(
<RenderResponse
responseData={{}}
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(container.textContent).toBe("-");
});
it("renders RatingResponse for 'Rating' question with number", () => {
const question = { ...defaultQuestion, type: "rating", scale: 5, range: [1, 5] };
render(
<RenderResponse responseData={4} question={question} survey={defaultSurvey} language={dummyLanguage} />
);
expect(screen.getByTestId("RatingResponse")).toHaveTextContent("Rating: 4");
});
it("renders formatted date for 'Date' question", () => {
const question = { ...defaultQuestion, type: "date" };
const dateStr = new Date("2023-01-01T12:00:00Z").toISOString();
render(
<RenderResponse
responseData={dateStr}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByText(/formatted_/)).toBeInTheDocument();
});
it("renders PictureSelectionResponse for 'PictureSelection' question", () => {
const question = { ...defaultQuestion, type: "pictureSelection", choices: ["a", "b"] };
render(
<RenderResponse
responseData={["choice1", "choice2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("PictureSelectionResponse")).toHaveTextContent(
"PictureSelection: choice1,choice2"
);
});
it("renders FileUploadResponse for 'FileUpload' question", () => {
const question = { ...defaultQuestion, type: "fileUpload" };
render(
<RenderResponse
responseData={["file1", "file2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("FileUploadResponse")).toHaveTextContent("FileUpload: file1,file2");
});
it("renders Matrix response", () => {
const question = { id: "q1", type: "matrix", rows: ["row1", "row2"] } as any;
// getLocalizedValue returns the row value itself
const responseData = { row1: "answer1", row2: "answer2" };
render(
<RenderResponse
responseData={responseData}
question={question}
survey={{ languages: [] } as any}
language={dummyLanguage}
/>
);
expect(screen.getByText("row1:processed:answer1")).toBeInTheDocument();
expect(screen.getByText("row2:processed:answer2")).toBeInTheDocument();
});
it("renders ArrayResponse for 'Address' question", () => {
const question = { ...defaultQuestion, type: "address" };
render(
<RenderResponse
responseData={["addr1", "addr2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("ArrayResponse")).toHaveTextContent("addr1,addr2");
});
it("renders ResponseBadges for 'Cal' question (string)", () => {
const question = { ...defaultQuestion, type: "cal" };
render(
<RenderResponse
responseData={"value"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Value");
});
it("renders ResponseBadges for 'Consent' question (number)", () => {
const question = { ...defaultQuestion, type: "consent" };
render(
<RenderResponse responseData={5} question={question} survey={defaultSurvey} language={dummyLanguage} />
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("5");
});
it("renders ResponseBadges for 'CTA' question (string)", () => {
const question = { ...defaultQuestion, type: "cta" };
render(
<RenderResponse
responseData={"click"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Click");
});
it("renders ResponseBadges for 'MultipleChoiceSingle' question (string)", () => {
const question = { ...defaultQuestion, type: "multipleChoiceSingle" };
render(
<RenderResponse
responseData={"option1"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("option1");
});
it("renders ResponseBadges for 'MultipleChoiceMulti' question (array)", () => {
const question = { ...defaultQuestion, type: "multipleChoiceMulti" };
render(
<RenderResponse
responseData={["opt1", "opt2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("opt1,opt2");
});
it("renders ResponseBadges for 'NPS' question (number)", () => {
const question = { ...defaultQuestion, type: "nps" };
render(
<RenderResponse responseData={9} question={question} survey={defaultSurvey} language={dummyLanguage} />
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("9");
});
it("renders RankingRespone for 'Ranking' question", () => {
const question = { ...defaultQuestion, type: "ranking" };
render(
<RenderResponse
responseData={["first", "second"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("RankingRespone")).toHaveTextContent("first,second");
});
it("renders default branch for unknown question type with string", () => {
const question = { ...defaultQuestion, type: "unknown" };
render(
<RenderResponse
responseData={"some text"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByText("hyper:some text")).toBeInTheDocument();
});
it("renders default branch for unknown question type with array", () => {
const question = { ...defaultQuestion, type: "unknown" };
render(
<RenderResponse
responseData={["a", "b"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByText("a, b")).toBeInTheDocument();
});
});

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