Compare commits

...

7 Commits

Author SHA1 Message Date
longvantruong
562b9c5296 chore: merge main in to mobile-sdk-custom (#5523)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: victorvhs017 <115753265+victorvhs017@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Vijay <vijayraghav22@gmail.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Peter Pesti-Varga <peter@lokin.hu>
Co-authored-by: Piyush Jain <122745947+d3vb0ox@users.noreply.github.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: Gulshan Kumar <gulshanbahadur002@gmail.com>
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
2025-04-28 09:32:17 +02:00
longvantruong
f7b57f420b chore: merge main into mobile-sdk-custom (#5322)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: victorvhs017 <115753265+victorvhs017@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-17 11:58:34 +02:00
longvantruong
5e2b30ca6d feat: add hidden field in HTML template Android/IOS (#5293)
Co-authored-by: duyht-home-work <huynhtanduy.grey@gmail.com>
2025-04-09 04:50:14 +02:00
Matthias Nannt
b2cb9062f7 merge latest main into branch 2025-04-08 21:53:17 +09:00
longvantruong
329c1265bc chore: 20250401 Android sdk backwards compatibility (#5200)
Co-authored-by: Peter Pesti-Varga <peter@lokin.hu>
Co-authored-by: sharegray097 <share.gray@gmail.com>
2025-04-02 11:57:20 +02:00
Matthias Nannt
95e5490685 Merge branch 'main' of github.com:formbricks/formbricks into mobile-sdk-custom 2025-04-02 16:01:00 +07:00
longvantruong
9ec5a15310 fix: downgrade Kotlin version to 1.7.20 for compatibility (build) (#5025) 2025-03-21 05:26:24 -07:00
1094 changed files with 43265 additions and 17121 deletions

View File

@@ -155,9 +155,8 @@ STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks # Configure Formbricks usage within Formbricks
NEXT_PUBLIC_FORMBRICKS_API_HOST= FORMBRICKS_API_HOST=
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID= FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Oauth credentials for Google sheet integration # Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID= GOOGLE_SHEETS_CLIENT_ID=
@@ -220,3 +219,8 @@ UNKEY_ROOT_KEY=
# PROMETHEUS_ENABLED= # PROMETHEUS_ENABLED=
# PROMETHEUS_EXPORTER_PORT= # PROMETHEUS_EXPORTER_PORT=
# The SENTRY_DSN is used for error tracking and performance monitoring with Sentry.
# SENTRY_DSN=
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
# It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN=

View File

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

26
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,26 @@
# Testing Instructions
When generating test files inside the "/app/web" path, follow these rules:
- Use vitest
- Ensure 100% code coverage
- Add as few comments as possible
- The test file should be located in the same folder as the original file
- Use the `test` function instead of `it`
- Follow the same test pattern used for other files in the package where the file is located
- All imports should be at the top of the file, not inside individual tests
- For mocking inside "test" blocks use "vi.mocked"
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
If it's a test for a ".tsx" file, follow these extra instructions:
- Add this code inside the "describe" block and before any test:
afterEach(() => {
cleanup();
});
- the "afterEach" function should only have "cleanup()" inside it and should be adde to the "vitest" imports
- For click events, import userEvent from "@testing-library/user-event"
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.

View File

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

View File

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

View File

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

View File

@@ -82,8 +82,6 @@ jobs:
secrets: | secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }} database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs. # Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker # This will only write to the public Rekor transparency log when the Docker

View File

@@ -102,8 +102,6 @@ jobs:
secrets: | secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }} database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs. # Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker # This will only write to the public Rekor transparency log when the Docker

1
.gitignore vendored
View File

@@ -72,3 +72,4 @@ infra/terraform/.terraform/
# IntelliJ IDEA # IntelliJ IDEA
/.idea/ /.idea/
/*.iml /*.iml
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata

View File

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

View File

@@ -18,7 +18,7 @@
"expo-status-bar": "2.0.1", "expo-status-bar": "2.0.1",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.78.2", "react-native": "0.76.6",
"react-native-webview": "13.12.5" "react-native-webview": "13.12.5"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -27,7 +27,7 @@ const secondaryNavigation = [
export function Sidebar(): React.JSX.Element { export function Sidebar(): React.JSX.Element {
return ( return (
<div className="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 <nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto" className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar"> aria-label="Sidebar">
@@ -38,7 +38,7 @@ export function Sidebar(): React.JSX.Element {
href={item.href} href={item.href}
className={classNames( className={classNames(
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white", 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}> aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 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" />
@@ -52,7 +52,7 @@ export function Sidebar(): React.JSX.Element {
<a <a
key={item.name} key={item.name}
href={item.href} 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.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
{item.name} {item.name}
</a> </a>

View File

@@ -17,8 +17,8 @@
"lucide-react": "0.486.0", "lucide-react": "0.486.0",
"next": "15.2.4", "next": "15.2.4",
"postcss": "8.5.3", "postcss": "8.5.3",
"react": "19.0.0", "react": "19.1.0",
"react-dom": "19.0.0", "react-dom": "19.1.0",
"tailwindcss": "4.1.3" "tailwindcss": "4.1.3"
}, },
"devDependencies": { "devDependencies": {

View File

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

View File

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

View File

@@ -1,3 +1,20 @@
module.exports = { module.exports = {
extends: ["@formbricks/eslint-config/legacy-next.js"], extends: ["@formbricks/eslint-config/legacy-next.js"],
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
overrides: [
{
files: ["lib/messages/**/*.json"],
plugins: ["i18n-json"],
rules: {
"i18n-json/identical-keys": [
"error",
{
filePath: require("path").join(__dirname, "messages", "en-US.json"),
checkExtraKeys: false,
checkMissingKeys: true,
},
],
},
},
],
}; };

2
apps/web/.gitignore vendored
View File

@@ -50,4 +50,4 @@ uploads/
.sentryclirc .sentryclirc
# SAML Preloaded Connections # SAML Preloaded Connections
saml-connection/ saml-connection/

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS base FROM node:22-alpine3.21 AS base
# #
## step 1: Prune monorepo ## step 1: Prune monorepo
@@ -22,7 +22,7 @@ RUN npm install -g corepack@latest
RUN corepack enable RUN corepack enable
# Install necessary build tools and compilers # 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 # BuildKit secret handling without hardcoded fallback values
# This approach relies entirely on secrets passed from GitHub Actions # 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 && \ echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh chmod +x /tmp/read-secrets.sh
ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit as a regular build argument # Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096" ARG NODE_OPTIONS="--max_old_space_size=4096"
ENV NODE_OPTIONS=${NODE_OPTIONS} ENV NODE_OPTIONS=${NODE_OPTIONS}
@@ -83,35 +81,65 @@ RUN corepack enable
RUN apk add --no-cache curl \ RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \ && apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \ # && addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs && addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
WORKDIR /home/nextjs WORKDIR /home/nextjs
# Ensure no write permissions are assigned to the copied resources
COPY --from=installer /app/apps/web/.next/standalone ./
RUN chown -R nextjs:nextjs ./ && chmod -R 755 ./
COPY --from=installer /app/apps/web/next.config.mjs . COPY --from=installer /app/apps/web/next.config.mjs .
RUN chmod 644 ./next.config.mjs
COPY --from=installer /app/apps/web/package.json . COPY --from=installer /app/apps/web/package.json .
# Leverage output traces to reduce image size RUN chmod 644 ./package.json
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./ COPY --from=installer /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./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
# Copy Prisma-specific generated files COPY --from=installer /app/apps/web/public ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
COPY /docker/cronjobs /app/docker/cronjobs 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 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 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 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 RUN npm install -g tsx typescript prisma pino-pretty

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { cn } from "@formbricks/lib/cn";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TProjectConfigChannel } from "@formbricks/types/project"; import { TProjectConfigChannel } from "@formbricks/types/project";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions"; import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";

View File

@@ -1,12 +1,12 @@
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks"; import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { WEBAPP_URL } from "@/lib/constants";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
interface ConnectPageProps { interface ConnectPageProps {
params: Promise<{ params: Promise<{
@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel} channel={channel}
/> />
<Button <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" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}`}> <Link href={`/environments/${environment.id}`}>

View File

@@ -1,7 +1,7 @@
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { AuthorizationError } from "@formbricks/types/errors"; import { AuthorizationError } from "@formbricks/types/errors";
const OnboardingLayout = async (props) => { const OnboardingLayout = async (props) => {

View File

@@ -1,4 +1,4 @@
import { replaceQuestionPresetPlaceholders } from "@formbricks/lib/utils/templates"; import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";

View File

@@ -1,8 +1,13 @@
import { getDefaultEndingCard } from "@/app/lib/templates"; import {
buildCTAQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
getDefaultEndingCard,
} from "@/app/lib/survey-builder";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { TFnType } from "@tolgee/react"; import { TFnType } from "@tolgee/react";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => { export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
@@ -26,35 +31,26 @@ const npsSurvey = (t: TFnType): TXMTemplate => {
...getXMSurveyDefault(t), ...getXMSurveyDefault(t),
name: t("templates.nps_survey_name"), name: t("templates.nps_survey_name"),
questions: [ questions: [
{ buildNPSQuestion({
id: createId(), headline: t("templates.nps_survey_question_1_headline"),
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: t("templates.nps_survey_question_1_headline") },
required: true, required: true,
lowerLabel: { default: t("templates.nps_survey_question_1_lower_label") }, lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: { default: t("templates.nps_survey_question_1_upper_label") }, upperLabel: t("templates.nps_survey_question_1_upper_label"),
isColorCodingEnabled: true, isColorCodingEnabled: true,
}, t,
{ }),
id: createId(), buildOpenTextQuestion({
type: TSurveyQuestionTypeEnum.OpenText, headline: t("templates.nps_survey_question_2_headline"),
headline: { default: t("templates.nps_survey_question_2_headline") },
required: false, required: false,
inputType: "text", inputType: "text",
charLimit: { t,
enabled: false, }),
}, buildOpenTextQuestion({
}, headline: t("templates.nps_survey_question_3_headline"),
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.nps_survey_question_3_headline") },
required: false, required: false,
inputType: "text", inputType: "text",
charLimit: { t,
enabled: false, }),
},
},
], ],
}; };
}; };
@@ -67,9 +63,8 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
...defaultSurvey, ...defaultSurvey,
name: t("templates.star_rating_survey_name"), name: t("templates.star_rating_survey_name"),
questions: [ questions: [
{ buildRatingQuestion({
id: reusableQuestionIds[0], id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [ logic: [
{ {
id: createId(), id: createId(),
@@ -102,16 +97,15 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
], ],
range: 5, range: 5,
scale: "number", scale: "number",
headline: { default: t("templates.star_rating_survey_question_1_headline") }, headline: t("templates.star_rating_survey_question_1_headline"),
required: true, required: true,
lowerLabel: { default: t("templates.star_rating_survey_question_1_lower_label") }, lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: { default: t("templates.star_rating_survey_question_1_upper_label") }, upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
isColorCodingEnabled: false, t,
}, }),
{ buildCTAQuestion({
id: reusableQuestionIds[1], id: reusableQuestionIds[1],
html: { default: t("templates.star_rating_survey_question_2_html") }, html: t("templates.star_rating_survey_question_2_html"),
type: TSurveyQuestionTypeEnum.CTA,
logic: [ logic: [
{ {
id: createId(), id: createId(),
@@ -138,25 +132,23 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
], ],
}, },
], ],
headline: { default: t("templates.star_rating_survey_question_2_headline") }, headline: t("templates.star_rating_survey_question_2_headline"),
required: true, required: true,
buttonUrl: "https://formbricks.com/github", buttonUrl: "https://formbricks.com/github",
buttonLabel: { default: t("templates.star_rating_survey_question_2_button_label") }, buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
buttonExternal: true, buttonExternal: true,
}, t,
{ }),
buildOpenTextQuestion({
id: reusableQuestionIds[2], id: reusableQuestionIds[2],
type: TSurveyQuestionTypeEnum.OpenText, headline: t("templates.star_rating_survey_question_3_headline"),
headline: { default: t("templates.star_rating_survey_question_3_headline") },
required: true, required: true,
subheader: { default: t("templates.star_rating_survey_question_3_subheader") }, subheader: t("templates.star_rating_survey_question_3_subheader"),
buttonLabel: { default: t("templates.star_rating_survey_question_3_button_label") }, buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
placeholder: { default: t("templates.star_rating_survey_question_3_placeholder") }, placeholder: t("templates.star_rating_survey_question_3_placeholder"),
inputType: "text", inputType: "text",
charLimit: { t,
enabled: false, }),
},
},
], ],
}; };
}; };
@@ -169,9 +161,8 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
...defaultSurvey, ...defaultSurvey,
name: t("templates.csat_survey_name"), name: t("templates.csat_survey_name"),
questions: [ questions: [
{ buildRatingQuestion({
id: reusableQuestionIds[0], id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [ logic: [
{ {
id: createId(), id: createId(),
@@ -204,15 +195,14 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
], ],
range: 5, range: 5,
scale: "smiley", scale: "smiley",
headline: { default: t("templates.csat_survey_question_1_headline") }, headline: t("templates.csat_survey_question_1_headline"),
required: true, required: true,
lowerLabel: { default: t("templates.csat_survey_question_1_lower_label") }, lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: { default: t("templates.csat_survey_question_1_upper_label") }, upperLabel: t("templates.csat_survey_question_1_upper_label"),
isColorCodingEnabled: false, t,
}, }),
{ buildOpenTextQuestion({
id: reusableQuestionIds[1], id: reusableQuestionIds[1],
type: TSurveyQuestionTypeEnum.OpenText,
logic: [ logic: [
{ {
id: createId(), id: createId(),
@@ -239,25 +229,20 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
], ],
}, },
], ],
headline: { default: t("templates.csat_survey_question_2_headline") }, headline: t("templates.csat_survey_question_2_headline"),
required: false, required: false,
placeholder: { default: t("templates.csat_survey_question_2_placeholder") }, placeholder: t("templates.csat_survey_question_2_placeholder"),
inputType: "text", inputType: "text",
charLimit: { t,
enabled: false, }),
}, buildOpenTextQuestion({
},
{
id: reusableQuestionIds[2], id: reusableQuestionIds[2],
type: TSurveyQuestionTypeEnum.OpenText, headline: t("templates.csat_survey_question_3_headline"),
headline: { default: t("templates.csat_survey_question_3_headline") },
required: false, required: false,
placeholder: { default: t("templates.csat_survey_question_3_placeholder") }, placeholder: t("templates.csat_survey_question_3_placeholder"),
inputType: "text", inputType: "text",
charLimit: { t,
enabled: false, }),
},
},
], ],
}; };
}; };
@@ -267,28 +252,22 @@ const cessSurvey = (t: TFnType): TXMTemplate => {
...getXMSurveyDefault(t), ...getXMSurveyDefault(t),
name: t("templates.cess_survey_name"), name: t("templates.cess_survey_name"),
questions: [ questions: [
{ buildRatingQuestion({
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
range: 5, range: 5,
scale: "number", scale: "number",
headline: { default: t("templates.cess_survey_question_1_headline") }, headline: t("templates.cess_survey_question_1_headline"),
required: true, required: true,
lowerLabel: { default: t("templates.cess_survey_question_1_lower_label") }, lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: { default: t("templates.cess_survey_question_1_upper_label") }, upperLabel: t("templates.cess_survey_question_1_upper_label"),
isColorCodingEnabled: false, t,
}, }),
{ buildOpenTextQuestion({
id: createId(), headline: t("templates.cess_survey_question_2_headline"),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.cess_survey_question_2_headline") },
required: true, required: true,
placeholder: { default: t("templates.cess_survey_question_2_placeholder") }, placeholder: t("templates.cess_survey_question_2_placeholder"),
inputType: "text", inputType: "text",
charLimit: { t,
enabled: false, }),
},
},
], ],
}; };
}; };
@@ -301,9 +280,8 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
...defaultSurvey, ...defaultSurvey,
name: t("templates.smileys_survey_name"), name: t("templates.smileys_survey_name"),
questions: [ questions: [
{ buildRatingQuestion({
id: reusableQuestionIds[0], id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [ logic: [
{ {
id: createId(), id: createId(),
@@ -336,16 +314,15 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
], ],
range: 5, range: 5,
scale: "smiley", scale: "smiley",
headline: { default: t("templates.smileys_survey_question_1_headline") }, headline: t("templates.smileys_survey_question_1_headline"),
required: true, required: true,
lowerLabel: { default: t("templates.smileys_survey_question_1_lower_label") }, lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: { default: t("templates.smileys_survey_question_1_upper_label") }, upperLabel: t("templates.smileys_survey_question_1_upper_label"),
isColorCodingEnabled: false, t,
}, }),
{ buildCTAQuestion({
id: reusableQuestionIds[1], id: reusableQuestionIds[1],
html: { default: t("templates.smileys_survey_question_2_html") }, html: t("templates.smileys_survey_question_2_html"),
type: TSurveyQuestionTypeEnum.CTA,
logic: [ logic: [
{ {
id: createId(), id: createId(),
@@ -372,25 +349,23 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
], ],
}, },
], ],
headline: { default: t("templates.smileys_survey_question_2_headline") }, headline: t("templates.smileys_survey_question_2_headline"),
required: true, required: true,
buttonUrl: "https://formbricks.com/github", buttonUrl: "https://formbricks.com/github",
buttonLabel: { default: t("templates.smileys_survey_question_2_button_label") }, buttonLabel: t("templates.smileys_survey_question_2_button_label"),
buttonExternal: true, buttonExternal: true,
}, t,
{ }),
buildOpenTextQuestion({
id: reusableQuestionIds[2], id: reusableQuestionIds[2],
type: TSurveyQuestionTypeEnum.OpenText, headline: t("templates.smileys_survey_question_3_headline"),
headline: { default: t("templates.smileys_survey_question_3_headline") },
required: true, required: true,
subheader: { default: t("templates.smileys_survey_question_3_subheader") }, subheader: t("templates.smileys_survey_question_3_subheader"),
buttonLabel: { default: t("templates.smileys_survey_question_3_button_label") }, buttonLabel: t("templates.smileys_survey_question_3_button_label"),
placeholder: { default: t("templates.smileys_survey_question_3_placeholder") }, placeholder: t("templates.smileys_survey_question_3_placeholder"),
inputType: "text", inputType: "text",
charLimit: { t,
enabled: false, }),
},
},
], ],
}; };
}; };
@@ -400,37 +375,26 @@ const enpsSurvey = (t: TFnType): TXMTemplate => {
...getXMSurveyDefault(t), ...getXMSurveyDefault(t),
name: t("templates.enps_survey_name"), name: t("templates.enps_survey_name"),
questions: [ questions: [
{ buildNPSQuestion({
id: createId(), headline: t("templates.enps_survey_question_1_headline"),
type: TSurveyQuestionTypeEnum.NPS,
headline: {
default: t("templates.enps_survey_question_1_headline"),
},
required: false, required: false,
lowerLabel: { default: t("templates.enps_survey_question_1_lower_label") }, lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: { default: t("templates.enps_survey_question_1_upper_label") }, upperLabel: t("templates.enps_survey_question_1_upper_label"),
isColorCodingEnabled: true, isColorCodingEnabled: true,
}, t,
{ }),
id: createId(), buildOpenTextQuestion({
type: TSurveyQuestionTypeEnum.OpenText, headline: t("templates.enps_survey_question_2_headline"),
headline: { default: t("templates.enps_survey_question_2_headline") },
required: false, required: false,
inputType: "text", inputType: "text",
charLimit: { t,
enabled: false, }),
}, buildOpenTextQuestion({
}, headline: t("templates.enps_survey_question_3_headline"),
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.enps_survey_question_3_headline") },
required: false, required: false,
inputType: "text", inputType: "text",
charLimit: { t,
enabled: false, }),
},
},
], ],
}; };
}; };

View File

@@ -1,4 +1,7 @@
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList"; import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper"; import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -7,9 +10,6 @@ import { getTranslate } from "@/tolgee/server";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
interface XMTemplatePageProps { interface XMTemplatePageProps {
params: Promise<{ params: Promise<{
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} /> <XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && ( {projects.length >= 2 && (
<Button <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" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}/surveys`}> <Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -1,12 +1,12 @@
"use server"; "use server";
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding"; import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
import { cache } from "@/lib/cache";
import { teamCache } from "@/lib/cache/team"; import { teamCache } from "@/lib/cache/team";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -2,6 +2,8 @@
import { formbricksLogout } from "@/app/lib/formbricks"; import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg"; import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal"; import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { import {
@@ -24,8 +26,6 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
@@ -112,7 +112,7 @@ export const LandingSidebar = ({
{/* Dropdown Items */} {/* Dropdown Items */}
{dropdownNavigation.map((link) => ( {dropdownNavigation.map((link) => (
<Link href={link.href} target={link.target} className="flex w-full items-center"> <Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
<DropdownMenuItem> <DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} /> <link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label} {link.label}

View File

@@ -1,9 +1,9 @@
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getUserProjects } from "@formbricks/lib/project/service";
const LandingLayout = async (props) => { const LandingLayout = async (props) => {
const params = await props.params; const params = await props.params;

View File

@@ -1,11 +1,11 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;

View File

@@ -1,19 +1,19 @@
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react"; import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import React from "react"; import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import ProjectOnboardingLayout from "./layout"; import ProjectOnboardingLayout from "./layout";
// Mock all the modules and functions that this layout uses: // Mock all the modules and functions that this layout uses:
vi.mock("@formbricks/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false, IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key", POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host", POSTHOG_HOST: "mock-posthog-host",
@@ -42,13 +42,13 @@ vi.mock("next-auth", () => ({
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
redirect: vi.fn(), redirect: vi.fn(),
})); }));
vi.mock("@formbricks/lib/organization/auth", () => ({ vi.mock("@/lib/organization/auth", () => ({
canUserAccessOrganization: vi.fn(), canUserAccessOrganization: vi.fn(),
})); }));
vi.mock("@formbricks/lib/organization/service", () => ({ vi.mock("@/lib/organization/service", () => ({
getOrganization: vi.fn(), getOrganization: vi.fn(),
})); }));
vi.mock("@formbricks/lib/user/service", () => ({ vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(), getUser: vi.fn(),
})); }));
vi.mock("@/tolgee/server", () => ({ vi.mock("@/tolgee/server", () => ({
@@ -71,7 +71,7 @@ describe("ProjectOnboardingLayout", () => {
cleanup(); cleanup();
}); });
it("redirects to /auth/login if there is no session", async () => { test("redirects to /auth/login if there is no session", async () => {
// Mock no session // Mock no session
vi.mocked(getServerSession).mockResolvedValueOnce(null); vi.mocked(getServerSession).mockResolvedValueOnce(null);
@@ -85,7 +85,7 @@ describe("ProjectOnboardingLayout", () => {
expect(layoutElement).toBeUndefined(); expect(layoutElement).toBeUndefined();
}); });
it("throws an error if user does not exist", async () => { test("throws an error if user does not exist", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" }, user: { id: "user-123" },
}); });
@@ -99,7 +99,7 @@ describe("ProjectOnboardingLayout", () => {
).rejects.toThrow("common.user_not_found"); ).rejects.toThrow("common.user_not_found");
}); });
it("throws AuthorizationError if user cannot access organization", async () => { test("throws AuthorizationError if user cannot access organization", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser); vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false); vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false);
@@ -112,7 +112,7 @@ describe("ProjectOnboardingLayout", () => {
).rejects.toThrow("common.not_authorized"); ).rejects.toThrow("common.not_authorized");
}); });
it("throws an error if organization does not exist", async () => { test("throws an error if organization does not exist", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser); vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true); vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
@@ -126,7 +126,7 @@ describe("ProjectOnboardingLayout", () => {
).rejects.toThrow("common.organization_not_found"); ).rejects.toThrow("common.organization_not_found");
}); });
it("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => { test("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => {
// Provide valid data // Provide valid data
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser); vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser);

View File

@@ -1,13 +1,13 @@
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify"; import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors"; import { AuthorizationError } from "@formbricks/types/errors";
const ProjectOnboardingLayout = async (props) => { const ProjectOnboardingLayout = async (props) => {

View File

@@ -1,4 +1,5 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
@@ -6,7 +7,6 @@ import { getTranslate } from "@/tolgee/server";
import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react"; import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getUserProjects } from "@formbricks/lib/project/service";
interface ChannelPageProps { interface ChannelPageProps {
params: Promise<{ params: Promise<{
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <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" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -1,12 +1,12 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getOrganizationProjectsCount } from "@formbricks/lib/project/service";
const OnboardingLayout = async (props) => { const OnboardingLayout = async (props) => {
const params = await props.params; const params = await props.params;

View File

@@ -1,4 +1,5 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
@@ -6,7 +7,6 @@ import { getTranslate } from "@/tolgee/server";
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react"; import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getUserProjects } from "@formbricks/lib/project/service";
interface ModePageProps { interface ModePageProps {
params: Promise<{ params: Promise<{
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <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" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -2,6 +2,7 @@
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions"; import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import { previewSurvey } from "@/app/lib/templates"; import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team"; import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal"; import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
@@ -26,7 +27,6 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage";
import { import {
TProjectConfigChannel, TProjectConfigChannel,
TProjectConfigIndustry, TProjectConfigIndustry,
@@ -225,7 +225,7 @@ export const ProjectSettings = ({
alt="Logo" alt="Logo"
width={256} width={256}
height={56} 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> <p className="text-sm text-slate-400">{t("common.preview")}</p>

View File

@@ -1,5 +1,7 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils"; import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -8,8 +10,6 @@ import { getTranslate } from "@/tolgee/server";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
import { getUserProjects } from "@formbricks/lib/project/service";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project"; import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
interface ProjectSettingsPageProps { interface ProjectSettingsPageProps {
@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <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" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -1,191 +1,120 @@
import "@testing-library/jest-dom/vitest"; import { getEnvironment } from "@/lib/environment/service";
import { act, cleanup, render, screen } from "@testing-library/react"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { getServerSession } from "next-auth"; import { cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import React from "react"; import { afterEach, describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
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 { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import SurveyEditorEnvironmentLayout from "./layout"; import SurveyEditorEnvironmentLayout from "./layout";
// mock all dependencies // Mock sub-components to render identifiable elements
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
vi.mock("@formbricks/lib/constants", () => ({ EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
IS_FORMBRICKS_CLOUD: false, <div data-testid="EnvironmentIdBaseLayout">
POSTHOG_API_KEY: "mock-posthog-api-key", {environmentId}
POSTHOG_HOST: "mock-posthog-host", {children}
IS_POSTHOG_CONFIGURED: true, </div>
ENCRYPTION_KEY: "mock-encryption-key", ),
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", }));
GITHUB_ID: "mock-github-id", vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
GITHUB_SECRET: "test-githubID", DevEnvironmentBanner: ({ environment }: any) => (
GOOGLE_CLIENT_ID: "test-google-client-id", <div data-testid="DevEnvironmentBanner">{environment.id}</div>
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,
})); }));
vi.mock("next-auth", () => ({ // Mocks for dependencies
getServerSession: vi.fn(), vi.mock("@/modules/environments/lib/utils", () => ({
environmentIdLayoutChecks: vi.fn(),
}));
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
})); }));
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
redirect: vi.fn(), redirect: vi.fn(),
})); }));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: 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>
),
}));
describe("SurveyEditorEnvironmentLayout", () => { describe("SurveyEditorEnvironmentLayout", () => {
beforeEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("redirects to /auth/login if there is no session", async () => { test("renders successfully when environment is found", async () => {
// Mock no session vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
vi.mocked(getServerSession).mockResolvedValueOnce(null); 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({ const result = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" }, params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child-content">Hello!</div>, children: <div data-testid="child">Survey Editor Content</div>,
}); });
expect(redirect).toHaveBeenCalledWith("/auth/login"); render(result);
// No JSX is returned after redirect
expect(layoutElement).toBeUndefined(); 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 () => { test("throws an error when environment is not found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
await expect( user: { id: "user1", email: "user1@example.com" } as TUser,
SurveyEditorEnvironmentLayout({ organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
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);
vi.mocked(getEnvironment).mockResolvedValueOnce(null); vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect( await expect(
SurveyEditorEnvironmentLayout({ SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" }, params: Promise.resolve({ environmentId: "env1" }),
children: <div>Child</div>, children: <div>Content</div>,
}) })
).rejects.toThrow("common.environment_not_found"); ).rejects.toThrow("common.environment_not_found");
}); });
it("renders environment layout if everything is valid", async () => { test("calls redirect when session is null", async () => {
// Provide all valid data vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); t: ((key: string) => key) as any,
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser); session: undefined as unknown as Session,
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true); user: undefined as unknown as TUser,
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization); organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
vi.mocked(getEnvironment).mockResolvedValueOnce({ });
id: "env-123", vi.mocked(redirect).mockImplementationOnce(() => {
name: "My Test Environment", throw new Error("Redirect called");
} 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);
}); });
// Now confirm we got the child plus all the mocked sub-components await expect(
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!"); SurveyEditorEnvironmentLayout({
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument(); params: Promise.resolve({ environmentId: "env1" }),
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument(); children: <div>Content</div>,
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument(); })
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument(); ).rejects.toThrow("Redirect called");
expect(screen.getByTestId("dev-environment-banner")).toHaveTextContent("env-123"); });
test("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
await expect(
SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
}); });
}); });

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@formbricks/lib/cn"; import { cn } from "@/lib/cn";
export const LoadingCard = ({ export const LoadingCard = ({
title, title,

View File

@@ -1,5 +1,8 @@
"use server"; "use server";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { import {
@@ -8,9 +11,6 @@ import {
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project"; import { createProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod"; import { z } from "zod";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getOrganizationProjectsCount } from "@formbricks/lib/project/service";
import { updateUser } from "@formbricks/lib/user/service";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors"; import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project"; import { ZProjectUpdateInput } from "@formbricks/types/project";

View File

@@ -1,12 +1,12 @@
"use server"; "use server";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { cache } from "@/lib/cache";
import { getSurveysByActionClassId } from "@/lib/survey/service";
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client"; import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper"; import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
import { z } from "zod"; import { z } from "zod";
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { cache } from "@formbricks/lib/cache";
import { getSurveysByActionClassId } from "@formbricks/lib/survey/service";
import { ZActionClassInput } from "@formbricks/types/action-classes"; import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils"; import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils";
import { convertDateTimeStringShort } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { createActionClassAction } from "@/modules/survey/editor/actions"; import { createActionClassAction } from "@/modules/survey/editor/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component"; import { ErrorComponent } from "@/modules/ui/components/error-component";
@@ -10,8 +12,6 @@ import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { convertDateTimeStringShort } from "@formbricks/lib/time";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes"; import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { getActiveInactiveSurveysAction } from "../actions"; import { getActiveInactiveSurveysAction } from "../actions";

View File

@@ -1,5 +1,5 @@
import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils"; import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils";
import { timeSince } from "@formbricks/lib/time"; import { timeSince } from "@/lib/time";
import { TActionClass } from "@formbricks/types/action-classes"; import { TActionClass } from "@formbricks/types/action-classes";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500"> <div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)} {timeSince(actionClass.createdAt.toString(), locale)}
</div> </div>
<div className="text-center"></div> <div className="text-center"></div>

View File

@@ -2,15 +2,15 @@ import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/act
import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData"; import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData";
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading"; import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal"; import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
import { getActionClasses } from "@/lib/actionClass/service";
import { getEnvironments } from "@/lib/environment/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { Metadata } from "next"; import { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Actions", title: "Actions",

View File

@@ -1,5 +1,17 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation"; import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar"; import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner"; import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
@@ -7,18 +19,6 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner"; import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
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";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@formbricks/lib/organization/service";
import { getUserProjects } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
interface EnvironmentLayoutProps { interface EnvironmentLayoutProps {
environmentId: string; environmentId: string;

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { useEffect } from "react"; import { useEffect } from "react";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
interface EnvironmentStorageHandlerProps { interface EnvironmentStorageHandlerProps {
environmentId: string; environmentId: string;

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { cn } from "@/lib/cn";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch"; import { Switch } from "@/modules/ui/components/switch";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
interface EnvironmentSwitchProps { interface EnvironmentSwitchProps {

View File

@@ -4,6 +4,9 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { formbricksLogout } from "@/app/lib/formbricks"; import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg"; import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal"; import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher"; import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -45,9 +48,6 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
@@ -265,7 +265,7 @@ export const MainNavigation = ({
size="icon" size="icon"
onClick={toggleSidebar} onClick={toggleSidebar}
className={cn( 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-none"
)}> )}>
{isCollapsed ? ( {isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} /> <PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
import { cn } from "@formbricks/lib/cn";
interface NavigationLinkProps { interface NavigationLinkProps {
href: string; href: string;

View File

@@ -2,7 +2,7 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react"; import { cleanup, render } from "@testing-library/react";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganizationBilling } from "@formbricks/types/organizations"; import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { PosthogIdentify } from "./PosthogIdentify"; import { PosthogIdentify } from "./PosthogIdentify";
@@ -18,7 +18,7 @@ describe("PosthogIdentify", () => {
cleanup(); cleanup();
}); });
it("identifies the user and sets groups when isPosthogEnabled is true", () => { test("identifies the user and sets groups when isPosthogEnabled is true", () => {
const mockIdentify = vi.fn(); const mockIdentify = vi.fn();
const mockGroup = vi.fn(); const mockGroup = vi.fn();
@@ -72,7 +72,7 @@ describe("PosthogIdentify", () => {
}); });
}); });
it("does nothing if isPosthogEnabled is false", () => { test("does nothing if isPosthogEnabled is false", () => {
const mockIdentify = vi.fn(); const mockIdentify = vi.fn();
const mockGroup = vi.fn(); const mockGroup = vi.fn();
@@ -95,7 +95,7 @@ describe("PosthogIdentify", () => {
expect(mockGroup).not.toHaveBeenCalled(); expect(mockGroup).not.toHaveBeenCalled();
}); });
it("does nothing if session user is missing", () => { test("does nothing if session user is missing", () => {
const mockIdentify = vi.fn(); const mockIdentify = vi.fn();
const mockGroup = vi.fn(); const mockGroup = vi.fn();
@@ -120,7 +120,7 @@ describe("PosthogIdentify", () => {
expect(mockGroup).not.toHaveBeenCalled(); expect(mockGroup).not.toHaveBeenCalled();
}); });
it("identifies user but does not group if environmentId/organizationId not provided", () => { test("identifies user but does not group if environmentId/organizationId not provided", () => {
const mockIdentify = vi.fn(); const mockIdentify = vi.fn();
const mockGroup = vi.fn(); const mockGroup = vi.fn();

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { EnvironmentSwitch } from "@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch"; import { EnvironmentSwitch } from "@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch";
import { getAccessFlags } from "@/lib/membership/utils";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -9,7 +10,6 @@ import { useTranslate } from "@tolgee/react";
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react"; import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react"; import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { cn } from "@formbricks/lib/cn";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
interface WidgetStatusIndicatorProps { interface WidgetStatusIndicatorProps {
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon /> <currentStatus.icon />
</div> </div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p> <p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p> <p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && ( {status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}> <Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon /> <RotateCcwIcon />

View File

@@ -1,5 +1,6 @@
"use server"; "use server";
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { import {
@@ -9,7 +10,6 @@ import {
getProjectIdFromIntegrationId, getProjectIdFromIntegrationId,
} from "@/lib/utils/helper"; } from "@/lib/utils/helper";
import { z } from "zod"; import { z } from "zod";
import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ZIntegrationInput } from "@formbricks/types/integration"; import { ZIntegrationInput } from "@formbricks/types/integration";

View File

@@ -4,6 +4,8 @@ import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[envir
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown"; import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable"; import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg"; import AirtableLogo from "@/images/airtableLogo.svg";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -23,8 +25,6 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { import {
TIntegrationAirtable, TIntegrationAirtable,

View File

@@ -0,0 +1,151 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationAirtable, TIntegrationAirtableConfig } from "@formbricks/types/integration/airtable";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal",
() => ({
AddIntegrationModal: ({ open, setOpenWithStates }) =>
open ? (
<div data-testid="add-modal">
<button onClick={() => setOpenWithStates(false)}>close</button>
</div>
) : null,
})
);
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete }) =>
open ? (
<div data-testid="delete-dialog">
<button onClick={onDelete}>confirm</button>
<button onClick={() => setOpen(false)}>cancel</button>
</div>
) : null,
}));
vi.mock("react-hot-toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const baseProps = {
environment: { id: "env1" } as TEnvironment,
environmentId: "env1",
setIsConnected: vi.fn(),
surveys: [],
airtableArray: [],
locale: "en-US" as const,
};
describe("ManageIntegration", () => {
afterEach(() => {
cleanup();
});
test("empty state", () => {
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
expect(screen.getByText(/link_new_table/)).toBeInTheDocument();
});
test("open add modal", async () => {
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
await userEvent.click(screen.getByText(/link_new_table/));
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
});
test("list integrations and open edit modal", async () => {
const item = {
baseId: "b",
tableId: "t",
surveyId: "s",
surveyName: "S",
tableName: "T",
questions: "Q",
questionIds: ["x"],
createdAt: new Date(),
includeVariables: false,
includeHiddenFields: false,
includeMetadata: false,
includeCreatedAt: false,
};
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [item] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
expect(screen.getByText("S")).toBeInTheDocument();
await userEvent.click(screen.getByText("S"));
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
});
test("delete integration success", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByText("confirm"));
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
const { toast } = await import("react-hot-toast");
expect(toast.success).toHaveBeenCalled();
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
});
test("delete integration error", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
await userEvent.click(screen.getByText("confirm"));
const { toast } = await import("react-hot-toast");
expect(toast.error).toHaveBeenCalled();
});
});

View File

@@ -5,6 +5,7 @@ import {
AddIntegrationModal, AddIntegrationModal,
IntegrationModalInputs, IntegrationModalInputs,
} from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal"; } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -13,7 +14,6 @@ import { useTranslate } from "@tolgee/react";
import { Trash2Icon } from "lucide-react"; import { Trash2Icon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
@@ -98,17 +98,17 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
{integrationData.length ? ( {integrationData.length ? (
<div className="mt-6 w-full rounded-lg border border-slate-200"> <div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900"> <div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{tableHeaders.map((header, idx) => ( {tableHeaders.map((header) => (
<div key={idx} className={`col-span-2 hidden text-center sm:block`}> <div key={header} className={`col-span-2 hidden text-center sm:block`}>
{t(header)} {t(header)}
</div> </div>
))} ))}
</div> </div>
{integrationData.map((data, index) => ( {integrationData.map((data, index) => (
<div <button
key={index} key={`${index}-${data.baseId}-${data.tableId}-${data.surveyId}`}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100" className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => { onClick={() => {
setDefaultValues({ setDefaultValues({
base: data.baseId, base: data.baseId,
@@ -129,7 +129,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="col-span-2 text-center"> <div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)} {timeSince(data.createdAt.toString(), props.locale)}
</div> </div>
</div> </button>
))} ))}
</div> </div>
) : ( ) : (

View File

@@ -1,15 +1,15 @@
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"; import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getAirtableTables } from "@formbricks/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";

View File

@@ -1,10 +1,10 @@
"use server"; "use server";
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper"; import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod"; import { z } from "zod";
import { getSpreadsheetNameById } from "@formbricks/lib/googleSheet/service";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
const ZGetSpreadsheetNameByIdAction = z.object({ const ZGetSpreadsheetNameByIdAction = z.object({

View File

@@ -8,7 +8,9 @@ import {
isValidGoogleSheetsUrl, isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util"; } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png"; import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
@@ -21,8 +23,6 @@ import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { import {
TIntegrationGoogleSheets, TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData, TIntegrationGoogleSheetsConfigData,
@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="Surveys">{t("common.questions")}</Label> <Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200"> <div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900"> <div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => ( {replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2"> <div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -0,0 +1,162 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<button onClick={onDelete}>confirm</button>
<button onClick={() => setOpen(false)}>cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
}));
const baseProps = {
environment: { id: "env1" } as TEnvironment,
setOpenAddIntegrationModal: vi.fn(),
setIsConnected: vi.fn(),
setSelectedIntegration: vi.fn(),
locale: "en-US" as const,
} as const;
describe("ManageIntegration (Google Sheets)", () => {
afterEach(() => {
cleanup();
});
test("empty state", () => {
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
expect(screen.getByText(/link_new_sheet/)).toBeInTheDocument();
});
test("click link new sheet", async () => {
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
await userEvent.click(screen.getByText(/link_new_sheet/));
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("list integrations and open edit", async () => {
const item = {
spreadsheetId: "sid",
spreadsheetName: "SheetName",
surveyId: "s1",
surveyName: "Survey1",
questionIds: ["q1"],
questions: "Q",
createdAt: new Date(),
};
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [item] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
expect(screen.getByText("Survey1")).toBeInTheDocument();
await userEvent.click(screen.getByText("Survey1"));
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({
...item,
index: 0,
});
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("delete integration success", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByText("confirm"));
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
const { default: toast } = await import("react-hot-toast");
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
});
test("delete integration error", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
await userEvent.click(screen.getByText("confirm"));
const { default: toast } = await import("react-hot-toast");
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
});
});

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -9,7 +10,6 @@ import { useTranslate } from "@tolgee/react";
import { Trash2Icon } from "lucide-react"; import { Trash2Icon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { import {
TIntegrationGoogleSheets, TIntegrationGoogleSheets,
@@ -36,11 +36,10 @@ export const ManageIntegration = ({
}: ManageIntegrationProps) => { }: ManageIntegrationProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false); const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const integrationArray = googleSheetIntegration let integrationArray: TIntegrationGoogleSheetsConfigData[] = [];
? googleSheetIntegration.config.data if (googleSheetIntegration?.config.data) {
? googleSheetIntegration.config.data integrationArray = googleSheetIntegration.config.data;
: [] }
: [];
const [isDeleting, setisDeleting] = useState(false); const [isDeleting, setisDeleting] = useState(false);
const handleDeleteIntegration = async () => { const handleDeleteIntegration = async () => {
@@ -112,9 +111,9 @@ export const ManageIntegration = ({
{integrationArray && {integrationArray &&
integrationArray.map((data, index) => { integrationArray.map((data, index) => {
return ( return (
<div <button
key={index} key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
className="m-2 grid h-16 cursor-pointer grid-cols-8 content-center rounded-lg hover:bg-slate-100" className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => { onClick={() => {
editIntegration(index); editIntegration(index);
}}> }}>
@@ -124,7 +123,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center"> <div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)} {timeSince(data.createdAt.toString(), locale)}
</div> </div>
</div> </button>
); );
})} })}
</div> </div>

View File

@@ -1,19 +1,19 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper"; import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
const Page = async (props) => { const Page = async (props) => {

View File

@@ -1,12 +1,12 @@
import "server-only"; import "server-only";
import { cache } from "@/lib/cache";
import { surveyCache } from "@/lib/survey/cache";
import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { selectSurvey } from "@formbricks/lib/survey/service";
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -1,9 +1,9 @@
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook"; import { webhookCache } from "@/lib/cache/webhook";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma, Webhook } from "@prisma/client"; import { Prisma, Webhook } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";

View File

@@ -7,6 +7,9 @@ import {
UNSUPPORTED_TYPES_BY_NOTION, UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants"; } from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
import NotionLogo from "@/images/notion.png"; import NotionLogo from "@/images/notion.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions"; import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
@@ -18,9 +21,6 @@ import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TIntegrationInput } from "@formbricks/types/integration"; import { TIntegrationInput } from "@formbricks/types/integration";
import { import {
TIntegrationNotion, TIntegrationNotion,

View File

@@ -0,0 +1,91 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import type {
TIntegrationNotion,
TIntegrationNotionConfig,
TIntegrationNotionConfigData,
TIntegrationNotionCredential,
} from "@formbricks/types/integration/notion";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("react-hot-toast", () => ({ success: vi.fn(), error: vi.fn() }));
vi.mock("@/lib/time", () => ({ timeSince: () => "ago" }));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
describe("ManageIntegration", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
environment: {} as any,
locale: "en-US" as const,
setOpenAddIntegrationModal: vi.fn(),
setIsConnected: vi.fn(),
setSelectedIntegration: vi.fn(),
handleNotionAuthorization: vi.fn(),
};
test("shows empty state when no databases", () => {
render(
<ManageIntegration
{...defaultProps}
notionIntegration={
{
id: "1",
config: {
data: [] as TIntegrationNotionConfigData[],
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
} as TIntegrationNotionConfig,
} as TIntegrationNotion
}
/>
);
expect(screen.getByText("environments.integrations.notion.no_databases_found")).toBeInTheDocument();
});
test("renders list and handles clicks", async () => {
const data = [
{ surveyName: "S", databaseName: "D", createdAt: new Date().toISOString(), databaseId: "db" },
] as unknown as TIntegrationNotionConfigData[];
render(
<ManageIntegration
{...defaultProps}
notionIntegration={
{
id: "1",
config: { data, key: { workspace_name: "ws" } as TIntegrationNotionCredential },
} as TIntegrationNotion
}
/>
);
expect(screen.getByText("S")).toBeInTheDocument();
await userEvent.click(screen.getByText("S"));
expect(defaultProps.setSelectedIntegration).toHaveBeenCalledWith({ ...data[0], index: 0 });
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
});
test("update and link new buttons invoke handlers", async () => {
render(
<ManageIntegration
{...defaultProps}
notionIntegration={
{
id: "1",
config: {
data: [],
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
} as TIntegrationNotionConfig,
} as TIntegrationNotion
}
/>
);
await userEvent.click(screen.getByText("environments.integrations.notion.update_connection"));
expect(defaultProps.handleNotionAuthorization).toHaveBeenCalled();
await userEvent.click(screen.getByText("environments.integrations.notion.link_new_database"));
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -10,7 +11,6 @@ import { useTranslate } from "@tolgee/react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react"; import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react"; import React, { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion"; import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
@@ -39,11 +39,11 @@ export const ManageIntegration = ({
const { t } = useTranslate(); const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false); const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false); const [isDeleting, setisDeleting] = useState(false);
const integrationArray = notionIntegration
? notionIntegration.config.data let integrationArray: TIntegrationNotionConfigData[] = [];
? notionIntegration.config.data if (notionIntegration?.config.data) {
: [] integrationArray = notionIntegration.config.data;
: []; }
const handleDeleteIntegration = async () => { const handleDeleteIntegration = async () => {
setisDeleting(true); setisDeleting(true);
@@ -121,9 +121,9 @@ export const ManageIntegration = ({
{integrationArray && {integrationArray &&
integrationArray.map((data, index) => { integrationArray.map((data, index) => {
return ( return (
<div <button
key={index} key={`${index}-${data.databaseId}`}
className="m-2 grid h-16 cursor-pointer grid-cols-6 content-center rounded-lg hover:bg-slate-100" className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => { onClick={() => {
editIntegration(index); editIntegration(index);
}}> }}>
@@ -132,7 +132,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center"> <div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)} {timeSince(data.createdAt.toString(), locale)}
</div> </div>
</div> </button>
); );
})} })}
</div> </div>

View File

@@ -25,6 +25,8 @@ export const TYPE_MAPPING = {
[TSurveyQuestionTypeEnum.Address]: ["rich_text"], [TSurveyQuestionTypeEnum.Address]: ["rich_text"],
[TSurveyQuestionTypeEnum.Matrix]: ["rich_text"], [TSurveyQuestionTypeEnum.Matrix]: ["rich_text"],
[TSurveyQuestionTypeEnum.Cal]: ["checkbox"], [TSurveyQuestionTypeEnum.Cal]: ["checkbox"],
[TSurveyQuestionTypeEnum.ContactInfo]: ["rich_text"],
[TSurveyQuestionTypeEnum.Ranking]: ["rich_text"],
}; };
export const UNSUPPORTED_TYPES_BY_NOTION = [ export const UNSUPPORTED_TYPES_BY_NOTION = [

View File

@@ -1,21 +1,21 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper"; import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { redirect } from "next/navigation";
import { import {
NOTION_AUTH_URL, NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID, NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET, NOTION_OAUTH_CLIENT_SECRET,
NOTION_REDIRECT_URI, NOTION_REDIRECT_URI,
WEBAPP_URL, WEBAPP_URL,
} from "@formbricks/lib/constants"; } from "@/lib/constants";
import { getIntegrationByType } from "@formbricks/lib/integration/service"; import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@formbricks/lib/notion/service"; import { getNotionDatabases } from "@/lib/notion/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { redirect } from "next/navigation";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion"; import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
const Page = async (props) => { const Page = async (props) => {

View File

@@ -9,6 +9,7 @@ import notionLogo from "@/images/notion.png";
import SlackLogo from "@/images/slacklogo.png"; import SlackLogo from "@/images/slacklogo.png";
import WebhookLogo from "@/images/webhook.png"; import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png"; import ZapierLogo from "@/images/zapier-small.png";
import { getIntegrations } from "@/lib/integration/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Card } from "@/modules/ui/components/integration-card"; import { Card } from "@/modules/ui/components/integration-card";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -16,7 +17,6 @@ import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import Image from "next/image"; import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { TIntegrationType } from "@formbricks/types/integration"; import { TIntegrationType } from "@formbricks/types/integration";
const Page = async (props) => { const Page = async (props) => {

View File

@@ -1,10 +1,10 @@
"use server"; "use server";
import { getSlackChannels } from "@/lib/slack/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper"; import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod"; import { z } from "zod";
import { getSlackChannels } from "@formbricks/lib/slack/service";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
const ZGetSlackChannelsAction = z.object({ const ZGetSlackChannelsAction = z.object({

View File

@@ -2,6 +2,8 @@
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import SlackLogo from "@/images/slacklogo.png"; import SlackLogo from "@/images/slacklogo.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
@@ -15,8 +17,6 @@ import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { import {
TIntegrationSlack, TIntegrationSlack,

View File

@@ -0,0 +1,158 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({ default: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<button onClick={onDelete}>confirm</button>
<button onClick={() => setOpen(false)}>cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
}));
const baseProps = {
environment: { id: "env1" } as TEnvironment,
setOpenAddIntegrationModal: vi.fn(),
setIsConnected: vi.fn(),
setSelectedIntegration: vi.fn(),
refreshChannels: vi.fn(),
handleSlackAuthorization: vi.fn(),
showReconnectButton: false,
locale: "en-US" as const,
};
describe("ManageIntegration (Slack)", () => {
afterEach(() => cleanup());
test("empty state", () => {
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
expect(screen.getByText(/connect_your_first_slack_channel/)).toBeInTheDocument();
expect(screen.getByText(/link_channel/)).toBeInTheDocument();
});
test("link channel triggers handlers", async () => {
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
await userEvent.click(screen.getByText(/link_channel/));
expect(baseProps.refreshChannels).toHaveBeenCalled();
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("show reconnect button and triggers authorization", async () => {
render(
<ManageIntegration
{...baseProps}
showReconnectButton={true}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "Team" } } },
} as unknown as TIntegrationSlack
}
/>
);
expect(screen.getByText("environments.integrations.slack.slack_reconnect_button")).toBeInTheDocument();
await userEvent.click(screen.getByText("environments.integrations.slack.slack_reconnect_button"));
expect(baseProps.handleSlackAuthorization).toHaveBeenCalled();
});
test("list integrations and open edit", async () => {
const item = {
surveyName: "S",
channelName: "C",
questions: "Q",
createdAt: new Date().toISOString(),
surveyId: "s",
channelId: "c",
} as unknown as TIntegrationSlackConfigData;
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [item], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
expect(screen.getByText("S")).toBeInTheDocument();
await userEvent.click(screen.getByText("S"));
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({ ...item, index: 0 });
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("delete integration success", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByText("confirm"));
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
const { default: toast } = await import("react-hot-toast");
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
});
test("delete integration error", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
await userEvent.click(screen.getByText("confirm"));
const { default: toast } = await import("react-hot-toast");
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
});
});

View File

@@ -1,16 +1,15 @@
"use client"; "use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler"; import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { useTranslate } from "@tolgee/react"; import { T, useTranslate } from "@tolgee/react";
import { T } from "@tolgee/react";
import { Trash2Icon } from "lucide-react"; import { Trash2Icon } from "lucide-react";
import React, { useState } from "react"; import React, { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack"; import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
@@ -43,11 +42,10 @@ export const ManageIntegration = ({
const { t } = useTranslate(); const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false); const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false); const [isDeleting, setisDeleting] = useState(false);
const integrationArray = slackIntegration let integrationArray: TIntegrationSlackConfigData[] = [];
? slackIntegration.config.data if (slackIntegration?.config.data) {
? slackIntegration.config.data integrationArray = slackIntegration.config.data;
: [] }
: [];
const handleDeleteIntegration = async () => { const handleDeleteIntegration = async () => {
setisDeleting(true); setisDeleting(true);
@@ -129,9 +127,9 @@ export const ManageIntegration = ({
{integrationArray && {integrationArray &&
integrationArray.map((data, index) => { integrationArray.map((data, index) => {
return ( return (
<div <button
key={index} key={`${index}-${data.surveyName}-${data.channelName}`}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg text-slate-700 hover:cursor-pointer hover:bg-slate-100" className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
onClick={() => { onClick={() => {
editIntegration(index); editIntegration(index);
}}> }}>
@@ -141,7 +139,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center"> <div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)} {timeSince(data.createdAt.toString(), locale)}
</div> </div>
</div> </button>
); );
})} })}
</div> </div>

View File

@@ -1,14 +1,14 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper"; import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationSlack } from "@formbricks/types/integration/slack"; import { TIntegrationSlack } from "@formbricks/types/integration/slack";
const Page = async (props) => { const Page = async (props) => {

View File

@@ -1,250 +1,156 @@
import "@testing-library/jest-dom/vitest"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { act, cleanup, render, screen } from "@testing-library/react"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getServerSession } from "next-auth"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { notFound, redirect } from "next/navigation"; import { cleanup, render, screen } from "@testing-library/react";
import React from "react"; import { Session } from "next-auth";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { redirect } from "next/navigation";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { afterEach, describe, expect, test, 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 { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import EnvLayout from "./layout"; import EnvLayout from "./layout";
// mock all the dependencies // Mock sub-components to render identifiable elements
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
vi.mock("@formbricks/lib/constants", () => ({ EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>,
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,
})); }));
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
vi.mock("@/tolgee/server", () => ({ EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
getTranslate: vi.fn(() => { <div data-testid="EnvironmentIdBaseLayout">
return (key: string) => { {environmentId}
return key; {children}
}; </div>
}), ),
}));
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(),
}));
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", () => ({ vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />, ToasterClient: () => <div data-testid="ToasterClient" />,
}));
vi.mock("../../components/FormbricksClient", () => ({
FormbricksClient: ({ userId, email }: any) => (
<div data-testid="FormbricksClient">
{userId}-{email}
</div>
),
})); }));
vi.mock("./components/EnvironmentStorageHandler", () => ({ vi.mock("./components/EnvironmentStorageHandler", () => ({
default: () => <div data-testid="mock-storage-handler" />, default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
})); }));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => ( // Mocks for dependencies
<div data-testid="mock-response-filter-provider">{children}</div> vi.mock("@/modules/environments/lib/utils", () => ({
), environmentIdLayoutChecks: vi.fn(),
})); }));
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({ vi.mock("@/lib/project/service", () => ({
EnvironmentLayout: ({ children }: { children: React.ReactNode }) => ( getProjectByEnvironmentId: vi.fn(),
<div data-testid="mock-environment-result">{children}</div> }));
), vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
})); }));
describe("EnvLayout", () => { describe("EnvLayout", () => {
beforeEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
it("redirects to /auth/login if there is no session", async () => { test("renders successfully when all dependencies return valid data", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null); vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
// Since it's an async server component, call EnvLayout yourself: session: { user: { id: "user1" } } as Session,
const layoutElement = await EnvLayout({ user: { id: "user1", email: "user1@example.com" } as TUser,
params: Promise.resolve({ environmentId: "env-123" }), organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
children: <div data-testid="child-content">Hello!</div>,
}); });
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" const result = await EnvLayout({
expect(redirect).toHaveBeenCalledWith("/auth/login"); params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Content</div>,
});
render(result);
// If your code calls redirect() early and returns no JSX, expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
// layoutElement might be undefined or null. expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
expect(layoutElement).toBeUndefined(); expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
expect(screen.getByTestId("child")).toHaveTextContent("Content");
}); });
it("redirects to /auth/login if user does not exist in DB", async () => { test("throws error if project is not found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
user: { id: "user-123" }, 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(getProjectByEnvironmentId).mockResolvedValueOnce(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
await expect( await expect(
EnvLayout({ EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }), params: Promise.resolve({ environmentId: "env1" }),
children: <div>Child</div>, children: <div>Content</div>,
}) })
).rejects.toThrow("project_not_found"); ).rejects.toThrow("common.project_not_found");
}); });
it("calls notFound if membership is missing", async () => { test("throws error if membership is not found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
user: { id: "user-123" }, 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({ vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
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(null); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect( await expect(
EnvLayout({ EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }), params: Promise.resolve({ environmentId: "env1" }),
children: <div>Child</div>, children: <div>Content</div>,
}) })
).rejects.toThrow("membership_not_found"); ).rejects.toThrow("common.membership_not_found");
}); });
it("renders environment layout if everything is valid", async () => { test("calls redirect when session is null", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
user: { id: "user-123" }, 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({ vi.mocked(redirect).mockImplementationOnce(() => {
id: "user-123", throw new Error("Redirect called");
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);
}); });
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!"); await expect(
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument(); EnvLayout({
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument(); params: Promise.resolve({ environmentId: "env1" }),
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument(); children: <div>Content</div>,
expect(screen.getByTestId("mock-storage-handler")).toBeInTheDocument(); })
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument(); ).rejects.toThrow("Redirect called");
expect(screen.getByTestId("mock-environment-result")).toBeInTheDocument(); });
test("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
}); });
}); });

View File

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

View File

@@ -1,7 +1,7 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
const EnvironmentPage = async (props) => { const EnvironmentPage = async (props) => {
const params = await props.params; const params = await props.params;

View File

@@ -1,8 +1,8 @@
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
const AccountSettingsLayout = async (props) => { const AccountSettingsLayout = async (props) => {
const params = await props.params; const params = await props.params;

View File

@@ -1,8 +1,8 @@
"use server"; "use server";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { z } from "zod"; import { z } from "zod";
import { updateUser } from "@formbricks/lib/user/service";
import { ZUserNotificationSettings } from "@formbricks/types/user"; import { ZUserNotificationSettings } from "@formbricks/types/user";
const ZUpdateNotificationSettingsAction = z.object({ const ZUpdateNotificationSettingsAction = z.object({

View File

@@ -1,12 +1,12 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { getUser } from "@formbricks/lib/user/service";
import { TUserNotificationSettings } from "@formbricks/types/user"; import { TUserNotificationSettings } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts"; import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary"; import { EditWeeklySummary } from "./components/EditWeeklySummary";

View File

@@ -1,10 +1,10 @@
"use server"; "use server";
import { deleteFile } from "@/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { z } from "zod"; import { z } from "zod";
import { deleteFile } from "@formbricks/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@formbricks/lib/storage/utils";
import { updateUser } from "@formbricks/lib/user/service";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ZUserUpdateInput } from "@formbricks/types/user"; import { ZUserUpdateInput } from "@formbricks/types/user";

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { appLanguages } from "@/lib/i18n/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -23,7 +24,6 @@ import { ChevronDownIcon } from "lucide-react";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { z } from "zod"; import { z } from "zod";
import { appLanguages } from "@formbricks/lib/i18n/utils";
import { TUser, ZUser } from "@formbricks/types/user"; import { TUser, ZUser } from "@formbricks/types/user";
import { updateUserAction } from "../actions"; import { updateUserAction } from "../actions";

View File

@@ -1,5 +1,8 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -7,9 +10,6 @@ import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id"; import { SettingsId } from "@/modules/ui/components/settings-id";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount"; import { DeleteAccount } from "./components/DeleteAccount";
import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm"; import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm";

View File

@@ -1,5 +1,5 @@
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import Loading from "@/modules/organization/settings/api-keys/loading"; import Loading from "@/modules/organization/settings/api-keys/loading";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
export default function LoadingPage() { export default function LoadingPage() {
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />; return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;

View File

@@ -1,8 +1,8 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
const Loading = async () => { const Loading = async () => {
const t = await getTranslate(); const t = await getTranslate();

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { getAccessFlags } from "@/lib/membership/utils";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
interface OrganizationSettingsNavbarProps { interface OrganizationSettingsNavbarProps {
@@ -22,7 +22,7 @@ export const OrganizationSettingsNavbar = ({
loading, loading,
}: OrganizationSettingsNavbarProps) => { }: OrganizationSettingsNavbarProps) => {
const pathname = usePathname(); const pathname = usePathname();
const { isMember } = getAccessFlags(membershipRole); const { isMember, isOwner } = getAccessFlags(membershipRole);
const isPricingDisabled = isMember; const isPricingDisabled = isMember;
const { t } = useTranslate(); const { t } = useTranslate();
@@ -59,6 +59,7 @@ export const OrganizationSettingsNavbar = ({
label: t("common.api_keys"), label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`, href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"), current: pathname?.includes("/api-keys"),
hidden: !isOwner,
}, },
]; ];

View File

@@ -1,8 +1,8 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
const Loading = async () => { const Loading = async () => {
const t = await getTranslate(); const t = await getTranslate();

View File

@@ -1,4 +1,5 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -8,7 +9,6 @@ import { getTranslate } from "@/tolgee/server";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
@@ -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"> <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 <svg
viewBox="0 0 1024 1024" 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"> aria-hidden="true">
<circle <circle
cx={512} cx={512}

View File

@@ -1,10 +1,10 @@
"use server"; "use server";
import { deleteOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod"; import { z } from "zod";
import { deleteOrganization, updateOrganization } from "@formbricks/lib/organization/service";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors"; import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations"; import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions"; import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -9,7 +10,6 @@ import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Dispatch, SetStateAction, useState } from "react"; import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
type DeleteOrganizationProps = { type DeleteOrganizationProps = {

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions"; import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -18,7 +19,6 @@ import { useTranslate } from "@tolgee/react";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { z } from "zod"; import { z } from "zod";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization, ZOrganization } from "@formbricks/types/organizations"; import { TOrganization, ZOrganization } from "@formbricks/types/organizations";

View File

@@ -1,9 +1,9 @@
import { LoadingCard } from "@/app/(app)/components/LoadingCard"; import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
const Loading = async () => { const Loading = async () => {
const t = await getTranslate(); const t = await getTranslate();

View File

@@ -1,3 +1,4 @@
import { getUser } from "@/lib/user/service";
import { import {
getIsMultiOrgEnabled, getIsMultiOrgEnabled,
getIsOrganizationAIReady, getIsOrganizationAIReady,
@@ -6,12 +7,11 @@ import {
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { getUser } from "@formbricks/lib/user/service";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import Page from "./page"; import Page from "./page";
vi.mock("@formbricks/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false, IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false, IS_PRODUCTION: false,
FB_LOGO_URL: "https://example.com/mock-logo.png", FB_LOGO_URL: "https://example.com/mock-logo.png",
@@ -49,7 +49,7 @@ vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(), getTranslate: vi.fn(),
})); }));
vi.mock("@formbricks/lib/user/service", () => ({ vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(), getUser: vi.fn(),
})); }));
@@ -84,7 +84,7 @@ describe("Page", () => {
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true); vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
}); });
it("renders the page with organization settings", async () => { test("renders the page with organization settings", async () => {
const props = { const props = {
params: Promise.resolve({ environmentId: "env-123" }), params: Promise.resolve({ environmentId: "env-123" }),
}; };
@@ -94,7 +94,7 @@ describe("Page", () => {
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it("renders if session user id empty", async () => { test("renders if session user id empty", async () => {
mockEnvironmentAuth.session.user.id = ""; mockEnvironmentAuth.session.user.id = "";
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth); vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
@@ -108,7 +108,7 @@ describe("Page", () => {
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it("handles getEnvironmentAuth error", async () => { test("handles getEnvironmentAuth error", async () => {
vi.mocked(getEnvironmentAuth).mockRejectedValue(new Error("Authentication error")); vi.mocked(getEnvironmentAuth).mockRejectedValue(new Error("Authentication error"));
const props = { const props = {

View File

@@ -1,5 +1,7 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle"; import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { import {
getIsMultiOrgEnabled, getIsMultiOrgEnabled,
getIsOrganizationAIReady, getIsOrganizationAIReady,
@@ -11,8 +13,6 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id"; import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getUser } from "@formbricks/lib/user/service";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization"; import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"; import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";

View File

@@ -1,8 +1,8 @@
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
const Layout = async (props) => { const Layout = async (props) => {
const params = await props.params; const params = await props.params;

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { cn } from "@formbricks/lib/cn";
export const SettingsCard = ({ export const SettingsCard = ({
title, title,
@@ -31,7 +31,7 @@ export const SettingsCard = ({
id={title}> id={title}>
<div className="border-b border-slate-200 px-4 pb-4"> <div className="border-b border-slate-200 px-4 pb-4">
<div className="flex"> <div className="flex">
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3> <h3 className="text-lg leading-6 font-medium text-slate-900 capitalize">{title}</h3>
<div className="ml-2"> <div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />} {beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && ( {soon && (

View File

@@ -1,12 +1,12 @@
"use server"; "use server";
import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils"; import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { getResponseCountBySurveyId, getResponses } from "@formbricks/lib/response/service";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ZResponseFilterCriteria } from "@formbricks/types/responses"; import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { getSurveySummary } from "./summary/lib/surveySummary"; import { getSurveySummary } from "./summary/lib/surveySummary";

View File

@@ -7,12 +7,12 @@ import {
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"; } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getFormattedFilters } from "@/app/lib/surveys/surveys"; import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getResponseCountBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions"; import { getResponseCountBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { InboxIcon, PresentationIcon } from "lucide-react"; import { InboxIcon, PresentationIcon } from "lucide-react";
import { useParams, usePathname, useSearchParams } from "next/navigation"; import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useIntervalWhenFocused } from "@formbricks/lib/utils/hooks/useIntervalWhenFocused";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyAnalysisNavigationProps { interface SurveyAnalysisNavigationProps {

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