mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-17 11:31:09 -05:00
Compare commits
55 Commits
chore/upda
...
fix/tailwi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9411c8ced | ||
|
|
0440aeb143 | ||
|
|
016289c8cb | ||
|
|
93a9575389 | ||
|
|
9e265adf14 | ||
|
|
eb08a0ed14 | ||
|
|
c533f37983 | ||
|
|
ca4f8385e4 | ||
|
|
3eb9aa74ed | ||
|
|
e982fc5c3e | ||
|
|
637b51464c | ||
|
|
fd9585a66e | ||
|
|
49ecbcb0c9 | ||
|
|
1132bdd66a | ||
|
|
c7d6ed9ea3 | ||
|
|
782528f169 | ||
|
|
104c78275f | ||
|
|
d9d88f7175 | ||
|
|
bf7e24cf11 | ||
|
|
c8aba01db3 | ||
|
|
a896c7e46e | ||
|
|
8018ec14a2 | ||
|
|
9c3208c860 | ||
|
|
e1063964cf | ||
|
|
38568738cc | ||
|
|
041dce2ec5 | ||
|
|
15b8358b14 | ||
|
|
2173cb2610 | ||
|
|
87b925d622 | ||
|
|
885b06cc26 | ||
|
|
adb6a5f41e | ||
|
|
3b815e22e3 | ||
|
|
4d4a5c0e64 | ||
|
|
0e89293974 | ||
|
|
c306911b3a | ||
|
|
d6e30855f6 | ||
|
|
4f276f0095 | ||
|
|
f20b412a95 | ||
|
|
99b0713800 | ||
|
|
81fc97c7e9 | ||
|
|
785c5a59c6 | ||
|
|
25ecfaa883 | ||
|
|
38e2c019fa | ||
|
|
15878a4ac5 | ||
|
|
9802536ded | ||
|
|
a2ffea28a1 | ||
|
|
2c7f92a4d7 | ||
|
|
c653841037 | ||
|
|
ec314c14ea | ||
|
|
c03e60ac0b | ||
|
|
cbf2343143 | ||
|
|
9d9b3ac543 | ||
|
|
591b35a70b | ||
|
|
f0c7b881d3 | ||
|
|
3fd5515db1 |
@@ -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=
|
||||
|
||||
12
.github/actions/cache-build-web/action.yml
vendored
12
.github/actions/cache-build-web/action.yml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/workflows/build-web.yml
vendored
4
.github/workflows/build-web.yml
vendored
@@ -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 }}
|
||||
|
||||
167
.github/workflows/docker-build-validation.yml
vendored
Normal file
167
.github/workflows/docker-build-validation.yml
vendored
Normal 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!"
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -16,6 +16,8 @@ on:
|
||||
|
||||
env:
|
||||
TELEMETRY_DISABLED: 1
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
2
.github/workflows/sonarqube.yml
vendored
2
.github/workflows/sonarqube.yml
vendored
@@ -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 }}
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "formbricks",
|
||||
"projectKey": "formbricks_formbricks"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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're connected with env:</p>
|
||||
<p className="mb-1 sm:mr-2 sm:mb-0">You'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}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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")],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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} />
|
||||
<>
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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")}?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}`}>
|
||||
↑
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GET } from "@/modules/api/v2/management/roles/route";
|
||||
|
||||
export { GET };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
|
||||
|
||||
export { GET };
|
||||
3
apps/web/app/api/v2/me/route.ts
Normal file
3
apps/web/app/api/v2/me/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/me/route";
|
||||
|
||||
export { GET };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
|
||||
|
||||
export { GET, POST, PUT, DELETE };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route";
|
||||
|
||||
export { GET, PUT, DELETE };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, POST } from "@/modules/api/v2/organizations/[organizationId]/teams/route";
|
||||
|
||||
export { GET, POST };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, PATCH, POST } from "@/modules/api/v2/organizations/[organizationId]/users/route";
|
||||
|
||||
export { GET, POST, PATCH };
|
||||
3
apps/web/app/api/v2/roles/route.ts
Normal file
3
apps/web/app/api/v2/roles/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/roles/route";
|
||||
|
||||
export { GET };
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}</>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
117
apps/web/modules/analysis/components/RatingSmiley/index.test.tsx
Normal file
117
apps/web/modules/analysis/components/RatingSmiley/index.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user