mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 16:30:21 -06:00
Compare commits
50 Commits
ci-check-6
...
pr-6518-du
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4512cc86e | ||
|
|
0c77da1456 | ||
|
|
2db309dd1f | ||
|
|
feee22b5c3 | ||
|
|
a5433f6748 | ||
|
|
557f14bab8 | ||
|
|
fdba260301 | ||
|
|
f0c4c7000c | ||
|
|
ccd401c0a5 | ||
|
|
e9bf0b7dd9 | ||
|
|
9e322a4fff | ||
|
|
cf36b59fd6 | ||
|
|
764b8ec260 | ||
|
|
ac5d1e651e | ||
|
|
83b6f71867 | ||
|
|
26203640a4 | ||
|
|
62ffcc8e68 | ||
|
|
326872a86b | ||
|
|
892b55662e | ||
|
|
23143c8664 | ||
|
|
4c71caf0da | ||
|
|
173821f846 | ||
|
|
f139830020 | ||
|
|
70979a3b5b | ||
|
|
061fa036be | ||
|
|
b83c0a4a5d | ||
|
|
1bc0563965 | ||
|
|
3a4e2a9f85 | ||
|
|
bd48139a4f | ||
|
|
89fe82a0d6 | ||
|
|
65dc1fa771 | ||
|
|
438990bffc | ||
|
|
7f7bc989c6 | ||
|
|
baa2b31bc9 | ||
|
|
77aecf3aad | ||
|
|
7c1110239b | ||
|
|
eeb337521b | ||
|
|
182f674879 | ||
|
|
73c0da4b75 | ||
|
|
f475b2e6d5 | ||
|
|
e5e8941016 | ||
|
|
c39c9998f0 | ||
|
|
a8c8e6f83f | ||
|
|
8a5e9f38d7 | ||
|
|
a0740d20ea | ||
|
|
71f378a494 | ||
|
|
4bececeb56 | ||
|
|
71c96f48d7 | ||
|
|
05d88a3069 | ||
|
|
b6a63edc88 |
@@ -1,23 +1,28 @@
|
||||
---
|
||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
||||
globs:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
||||
|
||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
||||
|
||||
---
|
||||
|
||||
title: "FEATURE NAME"
|
||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
||||
icon: "link"
|
||||
|
||||
---
|
||||
|
||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
|
||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
|
||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt - e.g. if docs describe consecutive steps, always use Mintlify Step component.
|
||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case.
|
||||
- The page should never start with H1 headline, because it's already part of the template.
|
||||
- Tonality: Keep it concise and to the point. Avoid Jargon where possible.
|
||||
- If a feature is part of the Enterprise Edition, use this note:
|
||||
|
||||
<Note>
|
||||
FEATURE NAME is part of the @Enterprise Edition.
|
||||
</Note>
|
||||
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license)
|
||||
</Note>
|
||||
|
||||
104
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
104
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
@@ -1,104 +0,0 @@
|
||||
name: "Upload Sentry Sourcemaps"
|
||||
description: "Extract sourcemaps from Docker image and upload to Sentry"
|
||||
|
||||
inputs:
|
||||
docker_image:
|
||||
description: "Docker image to extract sourcemaps from"
|
||||
required: true
|
||||
release_version:
|
||||
description: "Sentry release version (e.g., v1.2.3)"
|
||||
required: true
|
||||
sentry_auth_token:
|
||||
description: "Sentry authentication token"
|
||||
required: true
|
||||
environment:
|
||||
description: "Sentry environment (e.g., production, staging)"
|
||||
required: false
|
||||
default: "staging"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract sourcemaps from Docker image
|
||||
shell: bash
|
||||
env:
|
||||
DOCKER_IMAGE: ${{ inputs.docker_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Validate docker image format (basic validation)
|
||||
if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
|
||||
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
|
||||
echo "Provided: ${DOCKER_IMAGE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📦 Extracting sourcemaps from Docker image: ${DOCKER_IMAGE}"
|
||||
|
||||
# Create temporary container from the image and capture its ID
|
||||
echo "Creating temporary container..."
|
||||
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
|
||||
echo "Container created with ID: ${CONTAINER_ID}"
|
||||
|
||||
# Set up cleanup function to ensure container is removed on script exit
|
||||
cleanup_container() {
|
||||
# Capture the current exit code to preserve it
|
||||
local original_exit_code=$?
|
||||
|
||||
echo "🧹 Cleaning up Docker container..."
|
||||
|
||||
# Remove the container if it exists (ignore errors if already removed)
|
||||
if [ -n "$CONTAINER_ID" ]; then
|
||||
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
|
||||
echo "Container ${CONTAINER_ID} removed"
|
||||
fi
|
||||
|
||||
# Exit with the original exit code to preserve script success/failure status
|
||||
exit $original_exit_code
|
||||
}
|
||||
|
||||
# Register cleanup function to run on script exit (success or failure)
|
||||
trap cleanup_container EXIT
|
||||
|
||||
# Extract .next directory containing sourcemaps
|
||||
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
|
||||
|
||||
# Verify sourcemaps exist
|
||||
if [ ! -d "./extracted-next/static/chunks" ]; then
|
||||
echo "❌ Error: .next/static/chunks directory not found in Docker image"
|
||||
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
|
||||
echo "✅ Found ${sourcemap_count} sourcemap files"
|
||||
|
||||
if [ "$sourcemap_count" -eq 0 ]; then
|
||||
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Sentry release and upload sourcemaps
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
|
||||
SENTRY_ORG: formbricks
|
||||
SENTRY_PROJECT: formbricks-cloud
|
||||
with:
|
||||
environment: ${{ inputs.environment }}
|
||||
version: ${{ inputs.release_version }}
|
||||
sourcemaps: "./extracted-next/"
|
||||
|
||||
- name: Clean up extracted files
|
||||
shell: bash
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Clean up extracted files
|
||||
rm -rf ./extracted-next
|
||||
echo "🧹 Cleaned up extracted files"
|
||||
77
.github/workflows/build-and-push-ecr.yml
vendored
77
.github/workflows/build-and-push-ecr.yml
vendored
@@ -4,9 +4,19 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: "Image tag to push (e.g., v3.16.1)"
|
||||
description: "Image tag to push (e.g., v3.16.1, main)"
|
||||
required: true
|
||||
default: "v3.16.1"
|
||||
deploy_production:
|
||||
description: "Tag image for production deployment"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
deploy_staging:
|
||||
description: "Tag image for staging deployment"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -70,6 +80,66 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Update package.json version
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Remove 'v' prefix if present (e.g., v3.16.1 -> 3.16.1)
|
||||
VERSION="${IMAGE_TAG#v}"
|
||||
|
||||
# Validate SemVer format (major.minor.patch with optional prerelease and build metadata)
|
||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid version format after extraction. Must be SemVer (e.g., 1.2.3, 1.2.3-alpha, 1.2.3+build.1)"
|
||||
echo "Original input: ${IMAGE_TAG}"
|
||||
echo "Extracted version: ${VERSION}"
|
||||
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILDMETADATA]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Valid SemVer format detected: ${VERSION}"
|
||||
echo "Updating package.json version to: ${VERSION}"
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${VERSION}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Build tag list
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
|
||||
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
|
||||
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Start with the base image tag
|
||||
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
|
||||
|
||||
# Add production tag if requested
|
||||
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
|
||||
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
|
||||
fi
|
||||
|
||||
# Add staging tag if requested
|
||||
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
|
||||
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
|
||||
fi
|
||||
|
||||
# Output for debugging
|
||||
echo "Generated tags:"
|
||||
echo -e "${TAGS}"
|
||||
|
||||
# Set output for next step (escape newlines for GitHub Actions)
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo -e "${TAGS}"
|
||||
echo "EOF"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
|
||||
with:
|
||||
@@ -91,9 +161,8 @@ jobs:
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ inputs.image_tag }}
|
||||
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
26
.github/workflows/formbricks-release.yml
vendored
26
.github/workflows/formbricks-release.yml
vendored
@@ -45,30 +45,4 @@ jobs:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- docker-build
|
||||
- deploy-formbricks-cloud
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Upload Sentry Sourcemaps
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
environment: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
|
||||
@@ -29,9 +29,7 @@ jobs:
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
outputs:
|
||||
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
|
||||
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
|
||||
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
@@ -54,23 +52,27 @@ jobs:
|
||||
echo "Reference type: $REF_TYPE"
|
||||
echo "Reference name: $REF_NAME"
|
||||
|
||||
# Create unique timestamped version for testing sourcemap resolution
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
if [[ "$REF_TYPE" == "tag" ]]; then
|
||||
# If running from a tag, use the tag name
|
||||
# If running from a tag, use the tag name + timestamp
|
||||
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
||||
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
|
||||
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
||||
echo "Using SemVer tag: $VERSION"
|
||||
BASE_VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
||||
VERSION="${BASE_VERSION}-${TIMESTAMP}"
|
||||
echo "Using SemVer tag with timestamp: $VERSION"
|
||||
else
|
||||
# Tag is not SemVer, treat as prerelease
|
||||
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-$SANITIZED_TAG"
|
||||
echo "Using tag as prerelease: $VERSION"
|
||||
VERSION="0.0.0-${SANITIZED_TAG}-${TIMESTAMP}"
|
||||
echo "Using tag as prerelease with timestamp: $VERSION"
|
||||
fi
|
||||
else
|
||||
# Running from branch, use branch name as prerelease
|
||||
# Running from branch, use branch name as prerelease + timestamp
|
||||
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-$SANITIZED_BRANCH"
|
||||
echo "Using branch as prerelease: $VERSION"
|
||||
VERSION="0.0.0-${SANITIZED_BRANCH}-${TIMESTAMP}"
|
||||
echo "Using branch as prerelease with timestamp: $VERSION"
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
@@ -82,15 +84,6 @@ jobs:
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Set Sentry environment in .env
|
||||
run: |
|
||||
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
|
||||
echo "SENTRY_ENVIRONMENT=staging" >> .env
|
||||
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
|
||||
else
|
||||
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
|
||||
fi
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
@@ -117,6 +110,9 @@ jobs:
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=raw,value=${{ env.VERSION }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
@@ -135,21 +131,9 @@ jobs:
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Extract image info for sourcemap upload
|
||||
id: extract_image_info
|
||||
run: |
|
||||
# Use the first readable tag from metadata action output
|
||||
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
|
||||
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
|
||||
|
||||
# Use the generated version for Sentry release
|
||||
RELEASE_VERSION="$VERSION"
|
||||
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "Docker image: $DOCKER_IMAGE"
|
||||
echo "Release version: $RELEASE_VERSION"
|
||||
echo "Available tags: ${{ steps.meta.outputs.tags }}"
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
@@ -164,31 +148,4 @@ jobs:
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Upload Sentry Sourcemaps
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }}
|
||||
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
environment: staging
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
|
||||
1
.github/workflows/release-docker-github.yml
vendored
1
.github/workflows/release-docker-github.yml
vendored
@@ -128,6 +128,7 @@ jobs:
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
|
||||
48
.github/workflows/upload-sentry-sourcemaps.yml
vendored
48
.github/workflows/upload-sentry-sourcemaps.yml
vendored
@@ -1,48 +0,0 @@
|
||||
name: Upload Sentry Sourcemaps (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
docker_image:
|
||||
description: "Docker image to extract sourcemaps from"
|
||||
required: true
|
||||
type: string
|
||||
release_version:
|
||||
description: "Release version (e.g., v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
tag_version:
|
||||
description: "Docker image tag (leave empty to use release_version)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload-sourcemaps:
|
||||
name: Upload Sourcemaps to Sentry
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set Docker Image
|
||||
run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
|
||||
env:
|
||||
DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
|
||||
|
||||
- name: Upload Sourcemaps to Sentry
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
with:
|
||||
docker_image: ${{ env.DOCKER_IMAGE }}
|
||||
release_version: ${{ inputs.release_version }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
@@ -35,6 +35,14 @@
|
||||
{
|
||||
"language": "ro-RO",
|
||||
"path": "./apps/web/locales/ro-RO.json"
|
||||
},
|
||||
{
|
||||
"language": "ja-JP",
|
||||
"path": "./apps/web/locales/ja-JP.json"
|
||||
},
|
||||
{
|
||||
"language": "zh-Hans-CN",
|
||||
"path": "./apps/web/locales/zh-Hans-CN.json"
|
||||
}
|
||||
],
|
||||
"forceMode": "OVERRIDE"
|
||||
|
||||
@@ -21,6 +21,7 @@ The Open Source Qualtrics Alternative
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/formbricks/formbricks/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL-purple" alt="License"></a> <a href="https://github.com/formbricks/formbricks/stargazers"><img src="https://img.shields.io/github/stars/formbricks/formbricks?logo=github" alt="Github Stars"></a>
|
||||
<a href="https://insights.linuxfoundation.org/project/formbricks"><img src="https://insights.linuxfoundation.org/api/badge/health-score?project=formbricks"></a>
|
||||
<a href="https://news.ycombinator.com/item?id=32303986"><img src="https://img.shields.io/badge/Hacker%20News-122-%23FF6600" alt="Hacker News"></a>
|
||||
<a href="[https://www.producthunt.com/products/formbricks](https://www.producthunt.com/posts/formbricks)"><img src="https://img.shields.io/badge/Product%20Hunt-455-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
|
||||
<a href="https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next/"><img src="https://img.shields.io/badge/2023-blue?logo=github&label=Github%20Accelerator" alt="Github Accelerator"></a>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TolgeeProvider } from "@tolgee/react";
|
||||
import React from "react";
|
||||
// Import translation data for Storybook
|
||||
import enUSTranslations from "../../web/locales/en-US.json";
|
||||
import "../../web/modules/ui/globals.css";
|
||||
import { TolgeeBase } from "../../web/tolgee/shared";
|
||||
|
||||
@@ -12,7 +14,16 @@ const withTolgee = (Story: any) => {
|
||||
|
||||
return React.createElement(
|
||||
TolgeeProvider,
|
||||
{ tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } },
|
||||
{
|
||||
tolgee,
|
||||
fallback: "Loading",
|
||||
ssr: {
|
||||
language: "en-US",
|
||||
staticData: {
|
||||
"en-US": enUSTranslations,
|
||||
},
|
||||
},
|
||||
},
|
||||
React.createElement(Story)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,9 +30,13 @@ COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
|
||||
RUN chmod +x /tmp/read-secrets.sh
|
||||
|
||||
# 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=8192"
|
||||
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||
|
||||
# Target architecture - automatically provided by Docker in multi-platform builds
|
||||
# but needs explicit declaration for some build systems (like Depot)
|
||||
ARG TARGETARCH
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -57,6 +61,7 @@ RUN pnpm build --filter=@formbricks/database
|
||||
# This mounts the secrets only during this build step without storing them in layers
|
||||
RUN --mount=type=secret,id=database_url \
|
||||
--mount=type=secret,id=encryption_key \
|
||||
--mount=type=secret,id=sentry_auth_token \
|
||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||
|
||||
# Extract Prisma version
|
||||
|
||||
@@ -47,20 +47,9 @@ afterEach(() => {
|
||||
describe("LandingSidebar component", () => {
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
|
||||
const organization = { id: "o1", name: "orgOne" } as any;
|
||||
const organizations = [
|
||||
{ id: "o2", name: "betaOrg" },
|
||||
{ id: "o1", name: "alphaOrg" },
|
||||
] as any;
|
||||
|
||||
test("renders logo, avatar, and initial modal closed", () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
render(<LandingSidebar user={user} organization={organization} />);
|
||||
|
||||
// Formbricks logo
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
@@ -71,14 +60,7 @@ describe("LandingSidebar component", () => {
|
||||
});
|
||||
|
||||
test("clicking logout triggers signOut", async () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
render(<LandingSidebar user={user} organization={organization} />);
|
||||
|
||||
// Open user dropdown by clicking on avatar trigger
|
||||
const trigger = screen.getByTestId("avatar").parentElement;
|
||||
|
||||
@@ -10,48 +10,27 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
interface LandingSidebarProps {
|
||||
isMultiOrgEnabled: boolean;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
organizations: TOrganization[];
|
||||
}
|
||||
|
||||
export const LandingSidebar = ({
|
||||
isMultiOrgEnabled,
|
||||
user,
|
||||
organization,
|
||||
organizations,
|
||||
}: LandingSidebarProps) => {
|
||||
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
||||
|
||||
const { t } = useTranslate();
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
};
|
||||
|
||||
const dropdownNavigation = [
|
||||
{
|
||||
label: t("common.documentation"),
|
||||
@@ -61,13 +40,6 @@ export const LandingSidebar = ({
|
||||
},
|
||||
];
|
||||
|
||||
const currentOrganizationId = organization?.id;
|
||||
const currentOrganizationName = capitalizeFirstLetter(organization?.name);
|
||||
|
||||
const sortedOrganizations = useMemo(() => {
|
||||
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [organizations]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -81,26 +53,27 @@ export const LandingSidebar = ({
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center gap-3")}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
|
||||
aria-haspopup="menu">
|
||||
<ProfileAvatar userId={user.id} />
|
||||
<>
|
||||
<div className="grow overflow-hidden">
|
||||
<p
|
||||
title={user?.email}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p
|
||||
title={capitalizeFirstLetter(organization?.name)}
|
||||
className="truncate text-sm text-slate-500">
|
||||
{capitalizeFirstLetter(organization?.name)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
|
||||
</>
|
||||
</div>
|
||||
<div className="grow overflow-hidden">
|
||||
<p
|
||||
title={user?.email}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p
|
||||
title={capitalizeFirstLetter(organization?.name)}
|
||||
className="truncate text-sm text-slate-500">
|
||||
{capitalizeFirstLetter(organization?.name)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
@@ -112,7 +85,13 @@ export const LandingSidebar = ({
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
|
||||
<Link
|
||||
key={link.href}
|
||||
id={link.href}
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
className="flex w-full items-center">
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
@@ -121,7 +100,6 @@ export const LandingSidebar = ({
|
||||
))}
|
||||
|
||||
{/* Logout */}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOutWithAudit({
|
||||
@@ -136,45 +114,6 @@ export const LandingSidebar = ({
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Organization Switch */}
|
||||
|
||||
{(isMultiOrgEnabled || organizations.length > 1) && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="rounded-lg">
|
||||
<div>
|
||||
<p>{currentOrganizationName}</p>
|
||||
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentOrganizationId}
|
||||
onValueChange={(organizationId) =>
|
||||
handleEnvironmentChangeByOrganization(organizationId)
|
||||
}>
|
||||
{sortedOrganizations.map((organization) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={organization.id}
|
||||
className="cursor-pointer rounded-lg"
|
||||
key={organization.id}>
|
||||
{organization.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
@@ -15,6 +16,7 @@ vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
@@ -101,16 +103,32 @@ vi.mock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getPublicUrl", () => ({
|
||||
getPublicDomain: vi.fn().mockReturnValue("http://localhost:3000"),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/project-and-org-switch", () => ({
|
||||
ProjectAndOrgSwitch: () => <div data-testid="project-and-org-switch" />,
|
||||
}));
|
||||
vi.mock("@/modules/organization/lib/utils");
|
||||
vi.mock("@/lib/user/service");
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/membership/service");
|
||||
vi.mock("@/tolgee/server");
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||
usePathname: vi.fn(() => "/organizations/org1"),
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the React cache function
|
||||
@@ -142,6 +160,7 @@ describe("Page component", () => {
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
@@ -163,6 +182,7 @@ describe("Page component", () => {
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
@@ -173,10 +193,16 @@ describe("Page component", () => {
|
||||
test("renders header and sidebar for authenticated user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: { id: "org1" },
|
||||
organization: { id: "org1", billing: { plan: "free" } },
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
} as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||
typeof props === "string" ? props : props.key || ""
|
||||
);
|
||||
@@ -188,11 +214,13 @@ describe("Page component", () => {
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const element = await Page({ params: { organizationId: "org1" } });
|
||||
render(element as React.ReactElement);
|
||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("project-and-org-switch")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -22,24 +26,38 @@ const Page = async (props) => {
|
||||
|
||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
||||
|
||||
const { features } = await getEnterpriseLicense();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(membership?.role);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-row">
|
||||
<LandingSidebar
|
||||
user={user}
|
||||
organization={organization}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizations={organizations}
|
||||
/>
|
||||
<LandingSidebar user={user} organization={organization} />
|
||||
<div className="flex-1">
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title={t("organizations.landing.no_projects_warning_title")}
|
||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
||||
/>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-6">
|
||||
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
|
||||
<ProjectAndOrgSwitch
|
||||
currentOrganizationId={organization.id}
|
||||
organizations={organizations}
|
||||
projects={[]}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={0}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isLicenseActive={false}
|
||||
isOwnerOrManager={false}
|
||||
isAccessControlAllowed={false}
|
||||
isMember={isMember}
|
||||
environments={[]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title={t("organizations.landing.no_projects_warning_title")}
|
||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,11 +18,6 @@ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
||||
DevEnvironmentBanner: ({ environment }: any) => (
|
||||
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mocks for dependencies
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
@@ -58,7 +53,6 @@ describe("SurveyEditorEnvironmentLayout", () => {
|
||||
render(result);
|
||||
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
@@ -32,7 +31,6 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<div className="flex h-screen flex-col">
|
||||
<DevEnvironmentBanner environment={environment} />
|
||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||
</div>
|
||||
</EnvironmentIdBaseLayout>
|
||||
|
||||
@@ -50,7 +50,7 @@ export const ActionSettingsTab = ({
|
||||
[actionClass.id, actionClasses]
|
||||
);
|
||||
|
||||
const actionClassKeys = useActionClassKeys(actionClasses);
|
||||
const actionClassKeys = useActionClassKeys(actionClasses).filter((key) => key !== actionClass.key);
|
||||
|
||||
const form = useForm<TActionClassInput>({
|
||||
defaultValues: {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -5,9 +7,7 @@ import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
getOrganizationsByUserId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import {
|
||||
getAccessControlPermission,
|
||||
@@ -20,11 +20,7 @@ import type { Session } from "next-auth";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import {
|
||||
TOrganization,
|
||||
TOrganizationBilling,
|
||||
TOrganizationBillingPlanLimits,
|
||||
} from "@formbricks/types/organizations";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
@@ -35,16 +31,12 @@ vi.mock("@/lib/environment/service", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
getOrganizationsByUserId: vi.fn(),
|
||||
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getUserProjects: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
@@ -64,6 +56,22 @@ vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/lib/organization", () => ({
|
||||
getOrganizationsByUserId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/lib/project", () => ({
|
||||
getProjectsByUserId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
organization: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
let mockIsFormbricksCloud = false;
|
||||
let mockIsDevelopment = false;
|
||||
@@ -90,10 +98,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", ()
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
||||
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
||||
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) =>
|
||||
environment.type === "development" ? <div data-testid="dev-banner">DevEnvironmentBanner</div> : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
|
||||
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
|
||||
}));
|
||||
@@ -123,12 +127,10 @@ const mockUser = {
|
||||
const mockOrganization = {
|
||||
id: "org-1",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
|
||||
} as unknown as TOrganizationBilling,
|
||||
plan: "free",
|
||||
limits: {},
|
||||
},
|
||||
} as unknown as TOrganization;
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
@@ -191,9 +193,11 @@ describe("EnvironmentLayout", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([
|
||||
{ id: mockOrganization.id, name: mockOrganization.name },
|
||||
]);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
|
||||
vi.mocked(getProjectsByUserId).mockResolvedValue([{ id: mockProject.id, name: mockProject.name }]);
|
||||
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||
@@ -240,33 +244,6 @@ describe("EnvironmentLayout", () => {
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DevEnvironmentBanner in development environment", async () => {
|
||||
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
||||
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
||||
mockIsDevelopment = true;
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.resetModules();
|
||||
@@ -314,32 +291,6 @@ describe("EnvironmentLayout", () => {
|
||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes isAccessControlAllowed props to MainNavigation", async () => {
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
|
||||
expect(vi.mocked(getAccessControlPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
|
||||
});
|
||||
|
||||
test("handles empty organizationTeams array", async () => {
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
|
||||
vi.resetModules();
|
||||
@@ -479,7 +430,7 @@ describe("EnvironmentLayout", () => {
|
||||
});
|
||||
|
||||
test("throws error if projects, environments or organizations not found", async () => {
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectsByUserId).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
@@ -8,9 +10,7 @@ import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
getOrganizationsByUserId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import {
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
getOrganizationProjectsLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -51,8 +50,14 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
if (!currentUserMembership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
const membershipRole = currentUserMembership?.role;
|
||||
|
||||
const [projects, environments, isAccessControlAllowed] = await Promise.all([
|
||||
getUserProjects(user.id, organization.id),
|
||||
getProjectsByUserId(user.id, currentUserMembership),
|
||||
getEnvironments(environment.projectId),
|
||||
getAccessControlPermission(organization.billing.plan),
|
||||
]);
|
||||
@@ -61,8 +66,6 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const membershipRole = currentUserMembership?.role;
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||
@@ -87,10 +90,17 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
|
||||
// Find the current project from the projects array
|
||||
const project = projects.find((p) => p.id === environment.projectId);
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const { isManager, isOwner } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||
<DevEnvironmentBanner environment={environment} />
|
||||
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
<LimitsReachedBanner
|
||||
organization={organization}
|
||||
@@ -112,21 +122,25 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
<MainNavigation
|
||||
environment={environment}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
projects={projects}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membershipRole}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isLicenseActive={active}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
currentOrganizationId={organization.id}
|
||||
organizations={organizations}
|
||||
currentProjectId={project.id}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isLicenseActive={active}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
membershipRole={membershipRole}
|
||||
projectPermission={projectPermission}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
@@ -52,23 +51,6 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
||||
}));
|
||||
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
||||
ProjectSwitcher: ({
|
||||
isCollapsed,
|
||||
organizationTeams,
|
||||
isAccessControlAllowed,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
organizationTeams: TOrganizationTeam[];
|
||||
isAccessControlAllowed: boolean;
|
||||
}) => (
|
||||
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
||||
Project Switcher
|
||||
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
|
||||
<div data-testid="is-access-control-allowed">{isAccessControlAllowed.toString()}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
|
||||
}));
|
||||
@@ -177,13 +159,11 @@ describe("MainNavigation", () => {
|
||||
|
||||
test("renders expanded by default and collapses on toggle", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
const projectSwitcher = screen.getByTestId("project-switcher");
|
||||
// Assuming the toggle button is the only one initially without an accessible name
|
||||
// A more specific selector like data-testid would be better if available.
|
||||
const toggleButton = screen.getByRole("button", { name: "" });
|
||||
|
||||
// Check initial state (expanded)
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
// Check localStorage is not set initially after clear()
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
|
||||
@@ -194,7 +174,6 @@ describe("MainNavigation", () => {
|
||||
// Check state after first toggle (collapsed)
|
||||
await waitFor(() => {
|
||||
// Check that the attribute eventually becomes true
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "true");
|
||||
// Check that localStorage is updated
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
|
||||
});
|
||||
@@ -209,7 +188,6 @@ describe("MainNavigation", () => {
|
||||
// Check state after second toggle (expanded)
|
||||
await waitFor(() => {
|
||||
// Check that the attribute eventually becomes false
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
||||
// Check that localStorage is updated
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
|
||||
});
|
||||
@@ -245,8 +223,6 @@ describe("MainNavigation", () => {
|
||||
expect(screen.getByText("common.account")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("common.organization")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.license")).toBeInTheDocument(); // Not cloud, not member
|
||||
expect(screen.getByText("common.documentation")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.logout")).toBeInTheDocument();
|
||||
|
||||
@@ -267,46 +243,6 @@ describe("MainNavigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("handles organization switching", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the initial dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||
|
||||
const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor
|
||||
await userEvent.click(org2Item);
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/");
|
||||
});
|
||||
|
||||
test("opens create organization modal", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the initial dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||
|
||||
const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor
|
||||
await userEvent.click(createOrgButton);
|
||||
|
||||
expect(screen.getByTestId("create-org-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides new version banner for members or if no new version", async () => {
|
||||
// Test for member
|
||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
|
||||
@@ -334,34 +270,25 @@ describe("MainNavigation", () => {
|
||||
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows billing link and hides license link in cloud", async () => {
|
||||
render(<MainNavigation {...defaultProps} isFormbricksCloud={true} />);
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.billing")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
|
||||
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
|
||||
// Test basic navigation structure is rendered (aside element with complementary role)
|
||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("profile-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles no organizationTeams", () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
|
||||
// Test that navigation renders correctly with no teams
|
||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles isAccessControlAllowed false", () => {
|
||||
render(<MainNavigation {...defaultProps} isAccessControlAllowed={false} />);
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
|
||||
// Test that navigation renders correctly with access control disabled
|
||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,26 +2,17 @@
|
||||
|
||||
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
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 { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -30,18 +21,14 @@ import {
|
||||
BlocksIcon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
CreditCardIcon,
|
||||
KeyIcon,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
MousePointerClick,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -50,55 +37,40 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
organizations: TOrganization[];
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
projects: TProject[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
projects: { id: string; name: string }[];
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
organizationProjectsLimit: number;
|
||||
isLicenseActive: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
}
|
||||
|
||||
export const MainNavigation = ({
|
||||
environment,
|
||||
organizations,
|
||||
organization,
|
||||
user,
|
||||
projects,
|
||||
isMultiOrgEnabled,
|
||||
membershipRole,
|
||||
isFormbricksCloud,
|
||||
organizationProjectsLimit,
|
||||
isLicenseActive,
|
||||
isDevelopment,
|
||||
isAccessControlAllowed,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslate();
|
||||
const [currentOrganizationName, setCurrentOrganizationName] = useState("");
|
||||
const [currentOrganizationId, setCurrentOrganizationId] = useState("");
|
||||
const [showCreateOrganizationModal, setShowCreateOrganizationModal] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const project = projects.find((project) => project.id === environment.projectId);
|
||||
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
const isPricingDisabled = isMember;
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
@@ -119,40 +91,11 @@ export const MainNavigation = ({
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organization && organization.name !== "") {
|
||||
setCurrentOrganizationName(organization.name);
|
||||
setCurrentOrganizationId(organization.id);
|
||||
// Auto collapse project navbar on org and account settings
|
||||
if (pathname?.includes("/settings")) {
|
||||
setIsCollapsed(true);
|
||||
}
|
||||
}, [organization]);
|
||||
|
||||
const sortedOrganizations = useMemo(() => {
|
||||
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [organizations]);
|
||||
|
||||
const sortedProjects = useMemo(() => {
|
||||
const channelOrder: (string | null)[] = ["website", "app", "link", null];
|
||||
|
||||
const groupedProjects = projects.reduce(
|
||||
(acc, project) => {
|
||||
const channel = project.config.channel;
|
||||
const key = channel !== null ? channel : "null";
|
||||
acc[key] = acc[key] || [];
|
||||
acc[key].push(project);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof projects>
|
||||
);
|
||||
|
||||
Object.keys(groupedProjects).forEach((channel) => {
|
||||
groupedProjects[channel].sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
return channelOrder.flatMap((channel) => groupedProjects[channel !== null ? channel : "null"] || []);
|
||||
}, [projects]);
|
||||
|
||||
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
const mainNavigation = useMemo(
|
||||
() => [
|
||||
@@ -197,29 +140,18 @@ export const MainNavigation = ({
|
||||
href: `/environments/${environment.id}/settings/profile`,
|
||||
icon: UserCircleIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.organization"),
|
||||
href: `/environments/${environment.id}/settings/general`,
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environment.id}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.license"),
|
||||
href: `/environments/${environment.id}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isPricingDisabled,
|
||||
icon: KeyIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.documentation"),
|
||||
href: "https://formbricks.com/docs",
|
||||
target: "_blank",
|
||||
icon: ArrowUpRightIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.share_feedback"),
|
||||
href: "https://github.com/formbricks/formbricks/issues",
|
||||
target: "_blank",
|
||||
icon: ArrowUpRightIcon,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -229,7 +161,7 @@ export const MainNavigation = ({
|
||||
const latestVersionTag = res.data;
|
||||
const currentVersionTag = `v${packageJson.version}`;
|
||||
|
||||
if (currentVersionTag !== latestVersionTag) {
|
||||
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
|
||||
setLatestVersion(latestVersionTag);
|
||||
}
|
||||
}
|
||||
@@ -245,8 +177,7 @@ export const MainNavigation = ({
|
||||
<aside
|
||||
className={cn(
|
||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded",
|
||||
environment.type === "development" ? `h-[calc(100vh-1.25rem)]` : "h-screen"
|
||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
|
||||
)}>
|
||||
<div>
|
||||
{/* Logo and Toggle */}
|
||||
@@ -312,23 +243,6 @@ export const MainNavigation = ({
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Project Switch */}
|
||||
{!isBilling && (
|
||||
<ProjectSwitcher
|
||||
environmentId={environment.id}
|
||||
projects={sortedProjects}
|
||||
project={project}
|
||||
isCollapsed={isCollapsed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isTextVisible={isTextVisible}
|
||||
organization={organization}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User Switch */}
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
@@ -337,7 +251,6 @@ export const MainNavigation = ({
|
||||
id="userDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-row items-center gap-3",
|
||||
isCollapsed ? "justify-center px-2" : "px-4"
|
||||
@@ -354,11 +267,7 @@ export const MainNavigation = ({
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p
|
||||
title={capitalizeFirstLetter(organization?.name)}
|
||||
className="truncate text-sm text-slate-500">
|
||||
{capitalizeFirstLetter(organization?.name)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-700">{t("common.account")}</p>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
||||
@@ -376,24 +285,20 @@ export const MainNavigation = ({
|
||||
align="end">
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map(
|
||||
(link) =>
|
||||
!link.hidden && (
|
||||
<Link
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
className="flex w-full items-center"
|
||||
key={link.label}>
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
className="flex w-full items-center"
|
||||
key={link.label}
|
||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
{/* Logout */}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const route = await signOutWithAudit({
|
||||
@@ -409,55 +314,12 @@ export const MainNavigation = ({
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Organization Switch */}
|
||||
|
||||
{(isMultiOrgEnabled || organizations.length > 1) && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="rounded-lg">
|
||||
<div>
|
||||
<p>{currentOrganizationName}</p>
|
||||
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentOrganizationId}
|
||||
onValueChange={(organizationId) =>
|
||||
handleEnvironmentChangeByOrganization(organizationId)
|
||||
}>
|
||||
{sortedOrganizations.map((organization) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={organization.id}
|
||||
className="cursor-pointer rounded-lg"
|
||||
key={organization.id}>
|
||||
{organization.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowCreateOrganizationModal(true)}
|
||||
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
<CreateOrganizationModal
|
||||
open={showCreateOrganizationModal}
|
||||
setOpen={(val) => setShowCreateOrganizationModal(val)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TopControlBar } from "./TopControlBar";
|
||||
|
||||
// Mock the child component
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlButtons", () => ({
|
||||
TopControlButtons: vi.fn(() => <div data-testid="top-control-buttons">Mocked TopControlButtons</div>),
|
||||
}));
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments: TEnvironment[] = [
|
||||
mockEnvironment,
|
||||
{ ...mockEnvironment, id: "env2", type: "development" },
|
||||
];
|
||||
|
||||
const mockMembershipRole: TOrganizationRole = "owner";
|
||||
const mockProjectPermission = "manage";
|
||||
|
||||
describe("TopControlBar", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly and passes props to TopControlButtons", () => {
|
||||
render(
|
||||
<TopControlBar
|
||||
environment={mockEnvironment}
|
||||
environments={mockEnvironments}
|
||||
membershipRole={mockMembershipRole}
|
||||
projectPermission={mockProjectPermission}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the main div is rendered
|
||||
const mainDiv = screen.getByTestId("fb__global-top-control-bar");
|
||||
expect(mainDiv).toHaveClass("flex h-14 w-full items-center justify-end bg-slate-50 px-6");
|
||||
|
||||
// Check if the mocked child component is rendered
|
||||
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();
|
||||
|
||||
// Check if the child component received the correct props
|
||||
expect(TopControlButtons).toHaveBeenCalledWith(
|
||||
{
|
||||
environment: mockEnvironment,
|
||||
environments: mockEnvironments,
|
||||
membershipRole: mockMembershipRole,
|
||||
projectPermission: mockProjectPermission,
|
||||
},
|
||||
undefined // Updated from {} to undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,110 @@
|
||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||
"use client";
|
||||
|
||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
|
||||
interface SideBarProps {
|
||||
environment: TEnvironment;
|
||||
interface TopControlBarProps {
|
||||
environments: TEnvironment[];
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
projectPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const TopControlBar = ({
|
||||
environment,
|
||||
environments,
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentProjectId,
|
||||
projects,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
isOwnerOrManager,
|
||||
isAccessControlAllowed,
|
||||
membershipRole,
|
||||
projectPermission,
|
||||
}: SideBarProps) => {
|
||||
}: TopControlBarProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
const { environment } = useEnvironment();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-14 w-full items-center justify-end bg-slate-50 px-6"
|
||||
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
||||
data-testid="fb__global-top-control-bar">
|
||||
<div className="shadow-xs z-10">
|
||||
<div className="flex w-fit items-center space-x-2 py-2">
|
||||
<TopControlButtons
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
membershipRole={membershipRole}
|
||||
projectPermission={projectPermission}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ProjectAndOrgSwitch
|
||||
currentEnvironmentId={environment.id}
|
||||
environments={environments}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={organizations}
|
||||
currentProjectId={currentProjectId}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isMember={isMember}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className="z-50 flex items-center space-x-2">
|
||||
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
||||
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||
<Link
|
||||
href="https://github.com/formbricks/formbricks/issues"
|
||||
target="_blank"
|
||||
aria-label={t("common.share_feedback")}
|
||||
rel="noopener noreferrer">
|
||||
<BugIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer tooltipContent={t("common.account")}>
|
||||
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||
<Link href={`/environments/${environment.id}/settings/profile`} aria-label={t("common.account")}>
|
||||
<CircleUserIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
{isBilling || isReadOnly ? (
|
||||
<></>
|
||||
) : (
|
||||
<TooltipRenderer tooltipContent={t("common.new_survey")}>
|
||||
<Button variant="secondary" size="icon" className="h-fit w-fit p-1" asChild>
|
||||
<Link
|
||||
href={`/environments/${environment.id}/surveys/templates`}
|
||||
aria-label={t("common.new_survey")}>
|
||||
<PlusIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TopControlButtons } from "./TopControlButtons";
|
||||
|
||||
// Mock dependencies
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ push: mockPush })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/teams/utils/teams", () => ({
|
||||
getTeamPermissionFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch", () => ({
|
||||
EnvironmentSwitch: vi.fn(() => <div data-testid="environment-switch">EnvironmentSwitch</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, variant, size, className, asChild, ...props }: any) => {
|
||||
const Tag = asChild ? "div" : "button"; // Use div if asChild is true for Link mock
|
||||
return (
|
||||
<Tag onClick={onClick} data-testid={`button-${className}`} {...props}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipRenderer: ({ children, tooltipContent }: { children: React.ReactNode; tooltipContent: string }) => (
|
||||
<div data-testid={`tooltip-${tooltipContent.split(".").pop()}`}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
BugIcon: () => <div data-testid="bug-icon" />,
|
||||
CircleUserIcon: () => <div data-testid="circle-user-icon" />,
|
||||
PlusIcon: () => <div data-testid="plus-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
|
||||
<a href={href} target={target} data-testid="link-mock">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockEnvironmentDev: TEnvironment = {
|
||||
id: "dev-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironmentProd: TEnvironment = {
|
||||
id: "prod-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
|
||||
|
||||
describe("TopControlButtons", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mocks for access flags
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
isBilling: false,
|
||||
} as any);
|
||||
vi.mocked(getTeamPermissionFlags).mockReturnValue({
|
||||
hasReadAccess: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const renderComponent = (
|
||||
membershipRole?: TOrganizationRole,
|
||||
projectPermission: any = null,
|
||||
isBilling = false,
|
||||
hasReadAccess = false
|
||||
) => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isMember: membershipRole === "member",
|
||||
isBilling: isBilling,
|
||||
isOwner: membershipRole === "owner",
|
||||
} as any);
|
||||
vi.mocked(getTeamPermissionFlags).mockReturnValue({
|
||||
hasReadAccess: hasReadAccess,
|
||||
} as any);
|
||||
|
||||
return render(
|
||||
<TopControlButtons
|
||||
environment={mockEnvironmentDev}
|
||||
environments={mockEnvironments}
|
||||
membershipRole={membershipRole}
|
||||
projectPermission={projectPermission}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
test("renders correctly for Owner role", async () => {
|
||||
renderComponent("owner");
|
||||
|
||||
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("bug-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("circle-user-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
|
||||
// Check link
|
||||
const link = screen.getByTestId("link-mock");
|
||||
expect(link).toHaveAttribute("href", "https://github.com/formbricks/formbricks/issues");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
|
||||
// Click account button
|
||||
const accountButton = screen.getByTestId("circle-user-icon").closest("button");
|
||||
await userEvent.click(accountButton!);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/settings/profile`);
|
||||
});
|
||||
|
||||
// Click new survey button
|
||||
const newSurveyButton = screen.getByTestId("plus-icon").closest("button");
|
||||
await userEvent.click(newSurveyButton!);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/surveys/templates`);
|
||||
});
|
||||
});
|
||||
|
||||
test("hides EnvironmentSwitch for Billing role", () => {
|
||||
renderComponent(undefined, null, true); // isBilling = true
|
||||
expect(screen.queryByTestId("environment-switch")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument(); // Hidden for billing
|
||||
});
|
||||
|
||||
test("hides New Survey button for Billing role", () => {
|
||||
renderComponent(undefined, null, true); // isBilling = true
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides New Survey button for read-only Member", () => {
|
||||
renderComponent("member", null, false, true); // isMember = true, hasReadAccess = true
|
||||
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows New Survey button for Member with write access", () => {
|
||||
renderComponent("member", null, false, false); // isMember = true, hasReadAccess = false
|
||||
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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 { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
|
||||
interface TopControlButtonsProps {
|
||||
environment: TEnvironment;
|
||||
environments: TEnvironment[];
|
||||
membershipRole?: TOrganizationRole;
|
||||
projectPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const TopControlButtons = ({
|
||||
environment,
|
||||
environments,
|
||||
membershipRole,
|
||||
projectPermission,
|
||||
}: TopControlButtonsProps) => {
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
|
||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
return (
|
||||
<div className="z-50 flex items-center space-x-2">
|
||||
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
|
||||
|
||||
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
||||
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||
<Link href="https://github.com/formbricks/formbricks/issues" target="_blank">
|
||||
<BugIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer tooltipContent={t("common.account")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-fit w-fit bg-slate-50 p-1"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environment.id}/settings/profile`);
|
||||
}}>
|
||||
<CircleUserIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
{isBilling || isReadOnly ? (
|
||||
<></>
|
||||
) : (
|
||||
<TooltipRenderer tooltipContent={t("common.new_survey")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-fit w-fit p-1"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environment.id}/surveys/templates`);
|
||||
}}>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { EnvironmentBreadcrumb } from "./environment-breadcrumb";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the UI components
|
||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
||||
BreadcrumbItem: ({ children, isActive, isHighlighted, ...props }: any) => (
|
||||
<li data-testid="breadcrumb-item" data-active={isActive} data-highlighted={isHighlighted} {...props}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dropdown-menu"
|
||||
onClick={() => onOpenChange?.(true)}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dropdown-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
||||
<div
|
||||
data-testid="dropdown-checkbox-item"
|
||||
data-checked={checked}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
tabIndex={0}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
||||
<button data-testid="dropdown-trigger" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: any) => <div data-testid="tooltip-provider">{children}</div>,
|
||||
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
|
||||
TooltipTrigger: ({ children, asChild }: any) => (
|
||||
<div data-testid="tooltip-trigger" data-as-child={asChild}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
TooltipContent: ({ children, className }: any) => (
|
||||
<div data-testid="tooltip-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Lucide React icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
Code2Icon: ({ className, strokeWidth }: any) => {
|
||||
const isHeader = className?.includes("mr-2");
|
||||
return (
|
||||
<svg
|
||||
data-testid={isHeader ? "code2-header-icon" : "code2-icon"}
|
||||
className={className}
|
||||
strokeWidth={strokeWidth}>
|
||||
<title>Code2 Icon</title>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
||||
<title>ChevronDown Icon</title>
|
||||
</svg>
|
||||
),
|
||||
CircleHelpIcon: ({ className }: any) => (
|
||||
<svg data-testid="circle-help-icon" className={className}>
|
||||
<title>CircleHelp Icon</title>
|
||||
</svg>
|
||||
),
|
||||
Loader2: ({ className }: any) => (
|
||||
<svg data-testid="loader-2-icon" className={className}>
|
||||
<title>Loader2 Icon</title>
|
||||
</svg>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("EnvironmentBreadcrumb", () => {
|
||||
const mockPush = vi.fn();
|
||||
const mockRouter = {
|
||||
push: mockPush,
|
||||
replace: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
};
|
||||
|
||||
const mockProductionEnvironment: TEnvironment = {
|
||||
id: "env-prod-1",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
type: "production",
|
||||
projectId: "project-1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockDevelopmentEnvironment: TEnvironment = {
|
||||
id: "env-dev-1",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
type: "development",
|
||||
projectId: "project-1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments: TEnvironment[] = [mockProductionEnvironment, mockDevelopmentEnvironment];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders environment breadcrumb with production environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("code2-icon")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
|
||||
});
|
||||
|
||||
test("renders environment breadcrumb with development environment and shows tooltip", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("development")).toHaveLength(2); // trigger + dropdown option
|
||||
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("circle-help-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("highlights breadcrumb item for development environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "true");
|
||||
});
|
||||
|
||||
test("does not highlight breadcrumb item for production environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "false");
|
||||
});
|
||||
|
||||
test("shows chevron down icon when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId("chevron-down-icon")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders dropdown content with environment options", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.choose_environment")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders all environment options in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
expect(checkboxItems).toHaveLength(2);
|
||||
|
||||
// Check production environment option
|
||||
const productionOption = checkboxItems.find((item) => item.textContent?.includes("production"));
|
||||
expect(productionOption).toBeInTheDocument();
|
||||
expect(productionOption).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Check development environment option
|
||||
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
|
||||
expect(developmentOption).toBeInTheDocument();
|
||||
expect(developmentOption).toHaveAttribute("data-checked", "false");
|
||||
});
|
||||
|
||||
test("handles environment change when clicking dropdown option", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
|
||||
|
||||
expect(developmentOption).toBeInTheDocument();
|
||||
await user.click(developmentOption!);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/env-dev-1/");
|
||||
});
|
||||
|
||||
test("capitalizes environment type in display", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const environmentSpans = screen.getAllByText("production");
|
||||
const triggerSpan = environmentSpans.find((span) => span.className.includes("capitalize"));
|
||||
expect(triggerSpan).toHaveClass("capitalize");
|
||||
});
|
||||
|
||||
test("tooltip shows correct content for development environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const tooltipContent = screen.getByTestId("tooltip-content");
|
||||
expect(tooltipContent).toHaveClass("text-white bg-red-800 border-none mt-2");
|
||||
expect(tooltipContent).toHaveTextContent("common.development_environment_banner");
|
||||
});
|
||||
|
||||
test("renders without tooltip for production environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("circle-help-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially not active
|
||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
||||
|
||||
// Open dropdown
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
// Should be active when dropdown is open
|
||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
test("handles single environment scenario", () => {
|
||||
const singleEnvironment = [mockProductionEnvironment];
|
||||
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={singleEnvironment}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export const EnvironmentBreadcrumb = ({
|
||||
environments,
|
||||
currentEnvironmentId,
|
||||
}: {
|
||||
environments: { id: string; type: string }[];
|
||||
currentEnvironmentId: string;
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
|
||||
if (!currentEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleEnvironmentChange = (environmentId: string) => {
|
||||
setIsLoading(true);
|
||||
router.push(`/environments/${environmentId}/`);
|
||||
};
|
||||
|
||||
const developmentTooltip = () => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleHelpIcon className="h-3 w-3" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
|
||||
{t("common.development_environment_banner")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BreadcrumbItem
|
||||
isActive={isEnvironmentDropdownOpen}
|
||||
isHighlighted={currentEnvironment.type === "development"}>
|
||||
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||
id="environmentDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span className="capitalize">{currentEnvironment.type}</span>
|
||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{currentEnvironment.type === "development" && developmentTooltip()}
|
||||
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="mt-2" align="start">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Code2Icon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.choose_environment")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
{environments.map((env) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={env.id}
|
||||
checked={env.id === currentEnvironment.id}
|
||||
onClick={() => handleEnvironmentChange(env.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
<span>{env.type}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,560 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { OrganizationBreadcrumb } from "./organization-breadcrumb";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
usePathname: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open, setOpen }: any) =>
|
||||
open ? (
|
||||
<div data-testid="create-organization-modal">
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
Close Modal
|
||||
</button>
|
||||
Create Organization Modal
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Mock the UI components
|
||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
||||
BreadcrumbItem: ({ children, isActive, ...props }: any) => (
|
||||
<li data-testid="breadcrumb-item" data-active={isActive} {...props}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
||||
<div
|
||||
data-testid="dropdown-menu"
|
||||
onClick={() => onOpenChange?.(true)}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}
|
||||
role="button"
|
||||
tabIndex={0}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dropdown-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
||||
<div
|
||||
data-testid="dropdown-checkbox-item"
|
||||
data-checked={checked}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
tabIndex={0}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
||||
<button data-testid="dropdown-trigger" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
||||
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
|
||||
}));
|
||||
|
||||
// Mock Lucide React icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
BuildingIcon: ({ className, strokeWidth }: any) => {
|
||||
const isHeader = className?.includes("mr-2");
|
||||
return (
|
||||
<svg
|
||||
data-testid={isHeader ? "building-header-icon" : "building-icon"}
|
||||
className={className}
|
||||
strokeWidth={strokeWidth}>
|
||||
<title>Building Icon</title>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
||||
<title>ChevronDown Icon</title>
|
||||
</svg>
|
||||
),
|
||||
ChevronRightIcon: ({ className, strokeWidth }: any) => (
|
||||
<svg data-testid="chevron-right-icon" className={className} strokeWidth={strokeWidth}>
|
||||
<title>ChevronRight Icon</title>
|
||||
</svg>
|
||||
),
|
||||
PlusIcon: ({ className }: any) => (
|
||||
<svg data-testid="plus-icon" className={className}>
|
||||
<title>Plus Icon</title>
|
||||
</svg>
|
||||
),
|
||||
SettingsIcon: ({ className }: any) => (
|
||||
<svg data-testid="settings-icon" className={className}>
|
||||
<title>Settings Icon</title>
|
||||
</svg>
|
||||
),
|
||||
Loader2: ({ className }: any) => (
|
||||
<svg data-testid="loader-2-icon" className={className}>
|
||||
<title>Loader2 Icon</title>
|
||||
</svg>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("OrganizationBreadcrumb", () => {
|
||||
const mockPush = vi.fn();
|
||||
const mockRouter = {
|
||||
push: mockPush,
|
||||
replace: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
};
|
||||
|
||||
const mockOrganization1: TOrganization = {
|
||||
id: "org-1",
|
||||
name: "Test Organization 1",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
billing: {
|
||||
plan: "free",
|
||||
stripeCustomerId: null,
|
||||
} as unknown as TOrganizationBilling,
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockOrganization2: TOrganization = {
|
||||
id: "org-2",
|
||||
name: "Test Organization 2",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
billing: {
|
||||
plan: "startup",
|
||||
stripeCustomerId: null,
|
||||
} as unknown as TOrganizationBilling,
|
||||
isAIEnabled: true,
|
||||
};
|
||||
|
||||
const mockOrganizations = [mockOrganization1, mockOrganization2];
|
||||
const currentEnvironmentId = "env-123";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
||||
vi.mocked(usePathname).mockReturnValue("/environments/env-123/");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Single Organization Setup", () => {
|
||||
test("renders organization breadcrumb without dropdown for single org", () => {
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={[mockOrganization1]}
|
||||
isMultiOrgEnabled={false}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("building-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Organization 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows organization settings without organization switcher", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={[mockOrganization1]}
|
||||
isMultiOrgEnabled={false}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.organization_settings")).toBeInTheDocument();
|
||||
expect(screen.queryByText("common.choose_organization")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multi Organization Setup", () => {
|
||||
test("renders organization breadcrumb with dropdown for multi org", () => {
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("building-icon")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Test Organization 1")).toHaveLength(2); // trigger + dropdown option
|
||||
});
|
||||
|
||||
test("shows chevron icons correctly", () => {
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show chevron right when closed
|
||||
expect(screen.getByTestId("chevron-right-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows chevron down when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("chevron-down-icon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("renders organization selector in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByText("common.choose_organization")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
expect(checkboxItems.length).toBeGreaterThanOrEqual(2); // Organizations + create new option + settings
|
||||
});
|
||||
|
||||
test("handles organization change when clicking dropdown option", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
const org2Option = checkboxItems.find((item) => item.textContent?.includes("Test Organization 2"));
|
||||
|
||||
expect(org2Option).toBeInTheDocument();
|
||||
await user.click(org2Option!);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/organizations/org-2/");
|
||||
});
|
||||
|
||||
test("shows create new organization option when multi org is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
||||
expect(createOrgOption).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens create organization modal when clicking create new option", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
||||
await user.click(createOrgOption);
|
||||
|
||||
expect(screen.getByTestId("create-organization-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides create new organization option when multi org is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={false}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.queryByText("common.create_new_organization")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization Settings", () => {
|
||||
test("renders all organization settings options", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByText("common.organization_settings")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("settings-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.general")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.teams")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.api_keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.billing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles navigation to organization settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const generalOption = screen.getByText("common.general");
|
||||
await user.click(generalOption);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${currentEnvironmentId}/settings/general`);
|
||||
});
|
||||
|
||||
test("marks current settings page as checked", async () => {
|
||||
vi.mocked(usePathname).mockReturnValue("/environments/env-123/settings/teams");
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
const teamsOption = checkboxItems.find((item) => item.textContent?.includes("common.teams"));
|
||||
|
||||
expect(teamsOption).toBeInTheDocument();
|
||||
expect(teamsOption).toHaveAttribute("data-checked", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
test("handles single organization with multi org enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={[mockOrganization1]}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
// Should still show organization selector since multi org is enabled
|
||||
expect(screen.getByText("common.choose_organization")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.create_new_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows separator between organization switcher and settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByTestId("dropdown-separator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially not active
|
||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
||||
|
||||
// Open dropdown
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
// Should be active when dropdown is open
|
||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
test("closes create organization modal correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
||||
await user.click(createOrgOption);
|
||||
|
||||
expect(screen.getByTestId("create-organization-modal")).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByText("Close Modal");
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(screen.queryByTestId("create-organization-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
BuildingIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
Loader2,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface OrganizationBreadcrumbProps {
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
currentEnvironmentId?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
isMember: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
export const OrganizationBreadcrumb = ({
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
isMultiOrgEnabled,
|
||||
currentEnvironmentId,
|
||||
isFormbricksCloud,
|
||||
isMember,
|
||||
isOwnerOrManager,
|
||||
}: OrganizationBreadcrumbProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
|
||||
|
||||
if (!currentOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleOrganizationChange = (organizationId: string) => {
|
||||
setIsLoading(true);
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
};
|
||||
|
||||
// Hide organization dropdown for single org setups (on-premise)
|
||||
const showOrganizationDropdown = isMultiOrgEnabled || organizations.length > 1;
|
||||
|
||||
const organizationSettings = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/general`,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.teams"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||
hidden: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isMember,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<BreadcrumbItem isActive={isOrganizationDropdownOpen}>
|
||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||
id="organizationDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{currentOrganization.name}</span>
|
||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isOrganizationDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="mt-2">
|
||||
{showOrganizationDropdown && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.choose_organization")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === currentOrganization.id}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="cursor-pointer">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentEnvironmentId && (
|
||||
<div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.organization_settings")}
|
||||
</div>
|
||||
|
||||
{organizationSettings.map((setting) => {
|
||||
return setting.hidden ? null : (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={pathname.includes(setting.id)}
|
||||
hidden={setting.hidden}
|
||||
onClick={() => router.push(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{openCreateOrganizationModal && (
|
||||
<CreateOrganizationModal
|
||||
open={openCreateOrganizationModal}
|
||||
setOpen={setOpenCreateOrganizationModal}
|
||||
/>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ProjectAndOrgSwitch } from "./project-and-org-switch";
|
||||
|
||||
// Mock the individual breadcrumb components
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/organization-breadcrumb", () => ({
|
||||
OrganizationBreadcrumb: ({
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
isMultiOrgEnabled,
|
||||
currentEnvironmentId,
|
||||
}: any) => {
|
||||
const currentOrganization = organizations.find((org: any) => org.id === currentOrganizationId);
|
||||
return (
|
||||
<div data-testid="organization-breadcrumb">
|
||||
<div>Organization: {currentOrganization?.name}</div>
|
||||
<div>Organizations Count: {organizations.length}</div>
|
||||
<div>Multi Org: {isMultiOrgEnabled ? "Enabled" : "Disabled"}</div>
|
||||
<div>Environment ID: {currentEnvironmentId}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/project-breadcrumb", () => ({
|
||||
ProjectBreadcrumb: ({
|
||||
currentProjectId,
|
||||
projects,
|
||||
isOwnerOrManager,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
currentOrganizationId,
|
||||
currentEnvironmentId,
|
||||
isAccessControlAllowed,
|
||||
}: any) => {
|
||||
const currentProject = projects.find((project: any) => project.id === currentProjectId);
|
||||
return (
|
||||
<div data-testid="project-breadcrumb">
|
||||
<div>Project: {currentProject?.name}</div>
|
||||
<div>Projects Count: {projects.length}</div>
|
||||
<div>Owner/Manager: {isOwnerOrManager ? "Yes" : "No"}</div>
|
||||
<div>Project Limit: {organizationProjectsLimit}</div>
|
||||
<div>Formbricks Cloud: {isFormbricksCloud ? "Yes" : "No"}</div>
|
||||
<div>License Active: {isLicenseActive ? "Yes" : "No"}</div>
|
||||
<div>Organization ID: {currentOrganizationId}</div>
|
||||
<div>Environment ID: {currentEnvironmentId}</div>
|
||||
<div>Access Control: {isAccessControlAllowed ? "Allowed" : "Not Allowed"}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/environment-breadcrumb", () => ({
|
||||
EnvironmentBreadcrumb: ({ environments, currentEnvironmentId }: any) => {
|
||||
const currentEnvironment = environments.find((env: any) => env.id === currentEnvironmentId);
|
||||
return (
|
||||
<div data-testid="environment-breadcrumb">
|
||||
<div>Environment: {currentEnvironment?.type}</div>
|
||||
<div>Environments Count: {environments.length}</div>
|
||||
<div>Environment ID: {currentEnvironment?.id}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the UI components
|
||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
||||
Breadcrumb: ({ children }: any) => (
|
||||
<nav data-testid="breadcrumb" aria-label="breadcrumb">
|
||||
{children}
|
||||
</nav>
|
||||
),
|
||||
BreadcrumbList: ({ children, className }: any) => (
|
||||
<ol data-testid="breadcrumb-list" className={className}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ProjectAndOrgSwitch", () => {
|
||||
const mockOrganization1 = {
|
||||
id: "org-1",
|
||||
name: "Test Organization 1",
|
||||
};
|
||||
|
||||
const mockOrganization2 = {
|
||||
id: "org-2",
|
||||
name: "Test Organization 2",
|
||||
};
|
||||
|
||||
const mockProject1 = {
|
||||
id: "proj-1",
|
||||
name: "Test Project 1",
|
||||
};
|
||||
|
||||
const mockProject2 = {
|
||||
id: "proj-2",
|
||||
name: "Test Project 2",
|
||||
};
|
||||
|
||||
const mockEnvironment1: TEnvironment = {
|
||||
id: "env-1",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
type: "production",
|
||||
projectId: "proj-1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironment2: TEnvironment = {
|
||||
id: "env-2",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
type: "development",
|
||||
projectId: "proj-1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
currentOrganizationId: "org-1",
|
||||
organizations: [mockOrganization1, mockOrganization2],
|
||||
currentProjectId: "proj-1",
|
||||
projects: [mockProject1, mockProject2],
|
||||
currentEnvironmentId: "env-1",
|
||||
environments: [mockEnvironment1, mockEnvironment2],
|
||||
isMultiOrgEnabled: true,
|
||||
organizationProjectsLimit: 5,
|
||||
isFormbricksCloud: true,
|
||||
isLicenseActive: false,
|
||||
isOwnerOrManager: true,
|
||||
isAccessControlAllowed: true,
|
||||
isMember: true,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Basic Rendering", () => {
|
||||
test("renders main breadcrumb structure", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("breadcrumb-list")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("breadcrumb")).toHaveAttribute("aria-label", "breadcrumb");
|
||||
});
|
||||
|
||||
test("applies correct CSS classes to breadcrumb list", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
const breadcrumbList = screen.getByTestId("breadcrumb-list");
|
||||
expect(breadcrumbList).toHaveClass("gap-0");
|
||||
});
|
||||
|
||||
test("renders all three breadcrumb components", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("organization-breadcrumb")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("project-breadcrumb")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("environment-breadcrumb")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization Breadcrumb Integration", () => {
|
||||
test("passes correct props to organization breadcrumb", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Organization: Test Organization 1");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Organizations Count: 2");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Enabled");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
||||
});
|
||||
|
||||
test("handles single organization setup", () => {
|
||||
render(
|
||||
<ProjectAndOrgSwitch
|
||||
{...defaultProps}
|
||||
organizations={[mockOrganization1]}
|
||||
isMultiOrgEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Organizations Count: 1");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Project Breadcrumb Integration", () => {
|
||||
test("passes correct props to project breadcrumb", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Project: Test Project 1");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Projects Count: 2");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: Yes");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 5");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: Yes");
|
||||
expect(projectBreadcrumb).toHaveTextContent("License Active: No");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Organization ID: org-1");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Allowed");
|
||||
});
|
||||
|
||||
test("handles non-owner/manager user", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} isOwnerOrManager={false} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: No");
|
||||
});
|
||||
|
||||
test("handles self-hosted setup", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} isFormbricksCloud={false} isLicenseActive={true} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: No");
|
||||
expect(projectBreadcrumb).toHaveTextContent("License Active: Yes");
|
||||
});
|
||||
|
||||
test("handles access control restrictions", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} isAccessControlAllowed={false} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Not Allowed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment Breadcrumb Integration", () => {
|
||||
test("passes correct props to environment breadcrumb", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment: production");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environments Count: 2");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
||||
});
|
||||
|
||||
test("handles development environment", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} currentEnvironmentId="env-2" />);
|
||||
|
||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment: development");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-2");
|
||||
});
|
||||
|
||||
test("handles single environment", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} environments={[mockEnvironment1]} />);
|
||||
|
||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environments Count: 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Props Propagation", () => {
|
||||
test("correctly propagates organization limits", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} organizationProjectsLimit={10} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 10");
|
||||
});
|
||||
|
||||
test("correctly propagates current organization to project breadcrumb", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} currentOrganizationId="org-2" />);
|
||||
|
||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
|
||||
expect(orgBreadcrumb).toHaveTextContent("Organization: Test Organization 2");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Organization ID: org-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
test("handles zero project limit", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} organizationProjectsLimit={0} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 0");
|
||||
});
|
||||
|
||||
test("handles all boolean props as false", () => {
|
||||
render(
|
||||
<ProjectAndOrgSwitch
|
||||
{...defaultProps}
|
||||
isMultiOrgEnabled={false}
|
||||
isFormbricksCloud={false}
|
||||
isLicenseActive={false}
|
||||
isOwnerOrManager={false}
|
||||
isAccessControlAllowed={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
|
||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Disabled");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: No");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: No");
|
||||
expect(projectBreadcrumb).toHaveTextContent("License Active: No");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Not Allowed");
|
||||
});
|
||||
|
||||
test("maintains component order in DOM", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
const breadcrumbList = screen.getByTestId("breadcrumb-list");
|
||||
const children = Array.from(breadcrumbList.children);
|
||||
|
||||
expect(children[0]).toHaveAttribute("data-testid", "organization-breadcrumb");
|
||||
expect(children[1]).toHaveAttribute("data-testid", "project-breadcrumb");
|
||||
expect(children[2]).toHaveAttribute("data-testid", "environment-breadcrumb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TypeScript Props Interface", () => {
|
||||
test("accepts all required props without error", () => {
|
||||
// This test ensures the component accepts the full interface
|
||||
expect(() => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("works with minimal valid props", () => {
|
||||
const minimalProps = {
|
||||
currentOrganizationId: "org-1",
|
||||
organizations: [mockOrganization1],
|
||||
currentProjectId: "proj-1",
|
||||
projects: [mockProject1],
|
||||
currentEnvironmentId: "env-1",
|
||||
environments: [mockEnvironment1],
|
||||
isMultiOrgEnabled: false,
|
||||
organizationProjectsLimit: 1,
|
||||
isFormbricksCloud: false,
|
||||
isLicenseActive: false,
|
||||
isOwnerOrManager: false,
|
||||
isAccessControlAllowed: false,
|
||||
isMember: true,
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(<ProjectAndOrgSwitch {...minimalProps} />);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(screen.getByTestId("breadcrumb")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
||||
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ProjectAndOrgSwitchProps {
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentProjectId?: string;
|
||||
projects: { id: string; name: string }[];
|
||||
currentEnvironmentId?: string;
|
||||
environments: { id: string; type: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
isMember: boolean;
|
||||
}
|
||||
|
||||
export const ProjectAndOrgSwitch = ({
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentProjectId,
|
||||
projects,
|
||||
currentEnvironmentId,
|
||||
environments,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
isOwnerOrManager,
|
||||
isAccessControlAllowed,
|
||||
isMember,
|
||||
}: ProjectAndOrgSwitchProps) => {
|
||||
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
|
||||
const sortedOrganizations = useMemo(
|
||||
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[organizations]
|
||||
);
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className="gap-0">
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={sortedOrganizations}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isMember={isMember}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
{currentProjectId && currentEnvironmentId && (
|
||||
<ProjectBreadcrumb
|
||||
currentProjectId={currentProjectId}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
projects={sortedProjects}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
)}
|
||||
{currentEnvironmentId && (
|
||||
<EnvironmentBreadcrumb environments={environments} currentEnvironmentId={currentEnvironmentId} />
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,497 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { ProjectBreadcrumb } from "./project-breadcrumb";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/projects/components/project-limit-modal", () => ({
|
||||
ProjectLimitModal: ({ open, setOpen, buttons, projectLimit }: any) =>
|
||||
open ? (
|
||||
<div data-testid="project-limit-modal">
|
||||
<div>Project Limit: {projectLimit}</div>
|
||||
<button onClick={() => setOpen(false)}>Close Limit Modal</button>
|
||||
{buttons.map((button: any) => (
|
||||
<button key={button.text} type="button" onClick={() => button.href && window.open(button.href)}>
|
||||
{button.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/projects/components/create-project-modal", () => ({
|
||||
CreateProjectModal: ({ open, setOpen, organizationId, isAccessControlAllowed }: any) =>
|
||||
open ? (
|
||||
<div data-testid="create-project-modal">
|
||||
<div>Organization: {organizationId}</div>
|
||||
<div>Access Control: {isAccessControlAllowed ? "Allowed" : "Not Allowed"}</div>
|
||||
<button onClick={() => setOpen(false)}>Close Create Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Mock the UI components
|
||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
||||
BreadcrumbItem: ({ children, isActive, ...props }: any) => (
|
||||
<li data-testid="breadcrumb-item" data-active={isActive} {...props}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dropdown-menu"
|
||||
onClick={() => onOpenChange?.(true)}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dropdown-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
||||
<div
|
||||
data-testid="dropdown-checkbox-item"
|
||||
data-checked={checked}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
tabIndex={0}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
||||
<button data-testid="dropdown-trigger" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock Lucide React icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
FolderOpenIcon: ({ className, strokeWidth }: any) => {
|
||||
const isHeader = className?.includes("mr-2");
|
||||
return (
|
||||
<svg
|
||||
data-testid={isHeader ? "folder-open-header-icon" : "folder-open-icon"}
|
||||
className={className}
|
||||
strokeWidth={strokeWidth}>
|
||||
<title>FolderOpen Icon</title>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
||||
<title>ChevronDown Icon</title>
|
||||
</svg>
|
||||
),
|
||||
ChevronRightIcon: ({ className, strokeWidth }: any) => (
|
||||
<svg data-testid="chevron-right-icon" className={className} strokeWidth={strokeWidth}>
|
||||
<title>ChevronRight Icon</title>
|
||||
</svg>
|
||||
),
|
||||
PlusIcon: ({ className }: any) => (
|
||||
<svg data-testid="plus-icon" className={className}>
|
||||
<title>Plus Icon</title>
|
||||
</svg>
|
||||
),
|
||||
Loader2: ({ className }: any) => (
|
||||
<svg data-testid="loader-2-icon" className={className}>
|
||||
<title>Loader2 Icon</title>
|
||||
</svg>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ProjectBreadcrumb", () => {
|
||||
const mockPush = vi.fn();
|
||||
const mockRouter = {
|
||||
push: mockPush,
|
||||
replace: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
};
|
||||
|
||||
const mockProject1 = {
|
||||
id: "proj-1",
|
||||
name: "Test Project 1",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
organizationId: "org-1",
|
||||
languages: [],
|
||||
} as unknown as TProject;
|
||||
|
||||
const mockProject2 = {
|
||||
id: "proj-2",
|
||||
name: "Test Project 2",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
organizationId: "org-1",
|
||||
languages: [],
|
||||
} as unknown as TProject;
|
||||
|
||||
const mockProjects = [mockProject1, mockProject2];
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "org-1",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
billing: {
|
||||
plan: "free",
|
||||
stripeCustomerId: null,
|
||||
} as unknown as TOrganizationBilling,
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
currentProjectId: "proj-1",
|
||||
currentOrganizationId: "org-1",
|
||||
projects: mockProjects,
|
||||
isOwnerOrManager: true,
|
||||
organizationProjectsLimit: 3,
|
||||
isFormbricksCloud: true,
|
||||
isLicenseActive: false,
|
||||
currentEnvironmentId: "env-123",
|
||||
isAccessControlAllowed: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Basic Rendering", () => {
|
||||
test("renders project breadcrumb correctly", () => {
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("folder-open-icon")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Test Project 1")).toHaveLength(2); // trigger + dropdown option
|
||||
});
|
||||
|
||||
test("shows chevron icons correctly", () => {
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
// Should show chevron right when closed
|
||||
expect(screen.getByTestId("chevron-right-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows chevron down when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("chevron-down-icon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Project Selection", () => {
|
||||
test("renders dropdown content with project options", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.choose_project")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders all project options in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
|
||||
// Find project options (excluding the add new project option)
|
||||
const projectOptions = checkboxItems.filter((item) => item.textContent?.includes("Test Project"));
|
||||
expect(projectOptions).toHaveLength(2);
|
||||
|
||||
// Check current project is marked as selected
|
||||
const currentProjectOption = checkboxItems.find((item) => item.textContent?.includes("Test Project 1"));
|
||||
expect(currentProjectOption).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Check other project is not selected
|
||||
const otherProjectOption = checkboxItems.find((item) => item.textContent?.includes("Test Project 2"));
|
||||
expect(otherProjectOption).toHaveAttribute("data-checked", "false");
|
||||
});
|
||||
|
||||
test("handles project change when clicking dropdown option", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
const project2Option = checkboxItems.find((item) => item.textContent?.includes("Test Project 2"));
|
||||
|
||||
expect(project2Option).toBeInTheDocument();
|
||||
await user.click(project2Option!);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/projects/proj-2/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Add New Project", () => {
|
||||
test("shows add new project option when user is owner or manager", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByText("common.add_new_project")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides add new project option when user is not owner or manager", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} isOwnerOrManager={false} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.queryByText("common.add_new_project")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens create project modal when within project limit", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Organization: org-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Access Control: Allowed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens limit modal when exceeding project limit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
||||
organizationProjectsLimit: 3,
|
||||
};
|
||||
render(<ProjectBreadcrumb {...props} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project Limit: 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Project Limit Modal", () => {
|
||||
test("shows correct buttons for Formbricks Cloud with non-enterprise plan", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
||||
organizationProjectsLimit: 3,
|
||||
isFormbricksCloud: true,
|
||||
currentOrganization: {
|
||||
...mockOrganization,
|
||||
billing: { ...mockOrganization.billing, plan: "startup" } as unknown as TOrganizationBilling,
|
||||
},
|
||||
};
|
||||
render(<ProjectBreadcrumb {...props} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByText("environments.settings.billing.upgrade")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows correct buttons for self-hosted with active license", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
||||
organizationProjectsLimit: 3,
|
||||
isFormbricksCloud: false,
|
||||
isLicenseActive: true,
|
||||
};
|
||||
render(<ProjectBreadcrumb {...props} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByText("environments.settings.billing.upgrade")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes limit modal correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
||||
organizationProjectsLimit: 3,
|
||||
};
|
||||
render(<ProjectBreadcrumb {...props} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByText("Close Limit Modal");
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(screen.queryByTestId("project-limit-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Create Project Modal", () => {
|
||||
test("closes create project modal correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByText("Close Create Modal");
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(screen.queryByTestId("create-project-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes correct props to create project modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} isAccessControlAllowed={false} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByText("Access Control: Not Allowed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
test("handles single project scenario", () => {
|
||||
render(<ProjectBreadcrumb {...defaultProps} projects={[mockProject1]} />);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Test Project 1")).toHaveLength(2); // trigger + dropdown option
|
||||
});
|
||||
|
||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
// Initially not active
|
||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
||||
|
||||
// Open dropdown
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
// Should be active when dropdown is open
|
||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
test("handles project limit of zero", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} organizationProjectsLimit={0} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
// Should show limit modal even with 0 projects when limit is 0
|
||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project Limit: 0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles enterprise plan on Formbricks Cloud", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
||||
organizationProjectsLimit: 3,
|
||||
currentOrganization: {
|
||||
...mockOrganization,
|
||||
billing: { ...mockOrganization.billing, plan: "enterprise" } as unknown as TOrganizationBilling,
|
||||
},
|
||||
};
|
||||
render(<ProjectBreadcrumb {...props} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
// Should show self-hosted style buttons for enterprise plan
|
||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, ChevronRightIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ProjectBreadcrumbProps {
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
isOwnerOrManager: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
currentOrganizationId: string;
|
||||
currentEnvironmentId: string;
|
||||
isAccessControlAllowed: boolean;
|
||||
}
|
||||
|
||||
export const ProjectBreadcrumb = ({
|
||||
currentProjectId,
|
||||
projects,
|
||||
isOwnerOrManager,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
currentOrganizationId,
|
||||
currentEnvironmentId,
|
||||
isAccessControlAllowed,
|
||||
}: ProjectBreadcrumbProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const currentProject = projects.find((project) => project.id === currentProjectId);
|
||||
|
||||
if (!currentProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
setIsLoading(true);
|
||||
router.push(`/projects/${projectId}/`);
|
||||
};
|
||||
|
||||
const handleAddProject = () => {
|
||||
if (projects.length >= organizationProjectsLimit) {
|
||||
setOpenLimitModal(true);
|
||||
return;
|
||||
}
|
||||
setOpenCreateProjectModal(true);
|
||||
};
|
||||
|
||||
const LimitModalButtons = (): [ModalButton, ModalButton] => {
|
||||
if (isFormbricksCloud) {
|
||||
return [
|
||||
{
|
||||
text: t("environments.settings.billing.upgrade"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
||||
},
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
onClick: () => setOpenLimitModal(false),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text: t("environments.settings.billing.upgrade"),
|
||||
href: isLicenseActive
|
||||
? `/environments/${currentEnvironmentId}/settings/enterprise`
|
||||
: "https://formbricks.com/upgrade-self-hosted-license",
|
||||
},
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
onClick: () => setOpenLimitModal(false),
|
||||
},
|
||||
];
|
||||
};
|
||||
return (
|
||||
<BreadcrumbItem isActive={isProjectDropdownOpen}>
|
||||
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||
id="projectDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{currentProject.name}</span>
|
||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isProjectDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" className="mt-2">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.choose_project")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === currentProject.id}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Modals */}
|
||||
{openLimitModal && (
|
||||
<ProjectLimitModal
|
||||
open={openLimitModal}
|
||||
setOpen={setOpenLimitModal}
|
||||
buttons={LimitModalButtons()}
|
||||
projectLimit={organizationProjectsLimit}
|
||||
/>
|
||||
)}
|
||||
{openCreateProjectModal && (
|
||||
<CreateProjectModal
|
||||
open={openCreateProjectModal}
|
||||
setOpen={setOpenCreateProjectModal}
|
||||
organizationId={currentOrganizationId}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
};
|
||||
@@ -37,7 +37,7 @@ const Page = async (props) => {
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
return redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -35,7 +35,7 @@ const Page = async (props) => {
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
return redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -191,9 +191,7 @@ describe("NotionIntegrationPage", () => {
|
||||
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
|
||||
expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString());
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
|
||||
expect(screen.getByTestId("go-back")).toHaveTextContent(
|
||||
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
|
||||
);
|
||||
expect(screen.getByTestId("go-back")).toHaveTextContent("./");
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id);
|
||||
});
|
||||
|
||||
@@ -42,12 +42,12 @@ const Page = async (props) => {
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
return redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
|
||||
<GoBackButton url={"./"} />
|
||||
<PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} />
|
||||
<NotionWrapper
|
||||
enabled={enabled}
|
||||
|
||||
@@ -27,7 +27,7 @@ const Page = async (props) => {
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
return redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationsByUserId } from "./organization";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
organization: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Organization", () => {
|
||||
describe("getOrganizationsByUserId", () => {
|
||||
test("should return organizations when found", async () => {
|
||||
const mockOrganizations = [
|
||||
{ id: "org1", name: "Organization 1" },
|
||||
{ id: "org2", name: "Organization 2" },
|
||||
];
|
||||
|
||||
vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations as any);
|
||||
|
||||
const result = await getOrganizationsByUserId("user1");
|
||||
|
||||
expect(prisma.organization.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
memberships: {
|
||||
some: {
|
||||
userId: "user1",
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockOrganizations);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when organizations is null", async () => {
|
||||
vi.mocked(prisma.organization.findMany).mockResolvedValue(null as any);
|
||||
|
||||
await expect(getOrganizationsByUserId("user1")).rejects.toThrow(
|
||||
new ResourceNotFoundError("Organizations by UserId", "user1")
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.organization.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getOrganizationsByUserId("user1")).rejects.toThrow(new DatabaseError("Database error"));
|
||||
});
|
||||
|
||||
test("should re-throw unknown errors", async () => {
|
||||
const unknownError = new Error("Unknown error");
|
||||
vi.mocked(prisma.organization.findMany).mockRejectedValue(unknownError);
|
||||
|
||||
await expect(getOrganizationsByUserId("user1")).rejects.toThrow(unknownError);
|
||||
});
|
||||
|
||||
test("should validate inputs correctly", async () => {
|
||||
await expect(getOrganizationsByUserId("")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should validate userId input with invalid type", async () => {
|
||||
await expect(getOrganizationsByUserId(123 as any)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getOrganizationsByUserId = reactCache(
|
||||
async (userId: string): Promise<{ id: string; name: string }[]> => {
|
||||
validateInputs([userId, ZString]);
|
||||
|
||||
try {
|
||||
const organizations = await prisma.organization.findMany({
|
||||
where: {
|
||||
memberships: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
if (!organizations) {
|
||||
throw new ResourceNotFoundError("Organizations by UserId", userId);
|
||||
}
|
||||
return organizations;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { getProjectsByUserId } from "./project";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Project", () => {
|
||||
describe("getUserProjects", () => {
|
||||
const mockAdminMembership: TMembership = {
|
||||
role: "manager",
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
};
|
||||
|
||||
const mockMemberMembership: TMembership = {
|
||||
role: "member",
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
};
|
||||
|
||||
test("should return projects for admin role", async () => {
|
||||
const mockProjects = [
|
||||
{ id: "project1", name: "Project 1" },
|
||||
{ id: "project2", name: "Project 2" },
|
||||
];
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||
|
||||
const result = await getProjectsByUserId("user1", mockAdminMembership);
|
||||
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: "org1",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockProjects);
|
||||
});
|
||||
|
||||
test("should return projects for member role with team restrictions", async () => {
|
||||
const mockProjects = [{ id: "project1", name: "Project 1" }];
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||
|
||||
const result = await getProjectsByUserId("user1", mockMemberMembership);
|
||||
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: "org1",
|
||||
projectTeams: {
|
||||
some: {
|
||||
team: {
|
||||
teamUsers: {
|
||||
some: {
|
||||
userId: "user1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockProjects);
|
||||
});
|
||||
|
||||
test("should return empty array when no projects found", async () => {
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getProjectsByUserId("user1", mockAdminMembership);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(
|
||||
new DatabaseError("Database error")
|
||||
);
|
||||
});
|
||||
|
||||
test("should re-throw unknown errors", async () => {
|
||||
const unknownError = new Error("Unknown error");
|
||||
vi.mocked(prisma.project.findMany).mockRejectedValue(unknownError);
|
||||
|
||||
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
|
||||
});
|
||||
|
||||
test("should validate inputs correctly", async () => {
|
||||
await expect(getProjectsByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should validate membership input correctly", async () => {
|
||||
const invalidMembership = {} as TMembership;
|
||||
await expect(getProjectsByUserId("user1", invalidMembership)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should handle owner role like manager", async () => {
|
||||
const mockOwnerMembership: TMembership = {
|
||||
role: "owner",
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
};
|
||||
|
||||
const mockProjects = [{ id: "project1", name: "Project 1" }];
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||
|
||||
const result = await getProjectsByUserId("user1", mockOwnerMembership);
|
||||
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: "org1",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockProjects);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
||||
|
||||
export const getProjectsByUserId = reactCache(
|
||||
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
|
||||
validateInputs([userId, ZString], [orgMembership, ZMembership]);
|
||||
|
||||
let projectWhereClause: Prisma.ProjectWhereInput = {};
|
||||
|
||||
if (orgMembership.role === "member") {
|
||||
projectWhereClause = {
|
||||
projectTeams: {
|
||||
some: {
|
||||
team: {
|
||||
teamUsers: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
organizationId: orgMembership.organizationId,
|
||||
...projectWhereClause,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
return projects;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,82 @@
|
||||
// apps/web/lib/utils/version.test.ts
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isNewerVersion, parseVersion } from "./utils";
|
||||
|
||||
describe("Version utilities", () => {
|
||||
describe("parseVersion", () => {
|
||||
test("should parse valid semantic versions", () => {
|
||||
expect(parseVersion("1.2.3")).toEqual({
|
||||
major: 1,
|
||||
minor: 2,
|
||||
patch: 3,
|
||||
});
|
||||
|
||||
expect(parseVersion("v2.0.0-beta.1")).toEqual({
|
||||
major: 2,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: "beta.1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null for invalid versions", () => {
|
||||
expect(parseVersion("invalid")).toBeNull();
|
||||
expect(parseVersion("1.2")).toEqual({
|
||||
major: 1,
|
||||
minor: 2,
|
||||
patch: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNewerVersion", () => {
|
||||
test("should correctly identify newer versions", () => {
|
||||
expect(isNewerVersion("1.0.0", "1.0.1")).toBe(true);
|
||||
expect(isNewerVersion("1.0.0", "1.1.0")).toBe(true);
|
||||
expect(isNewerVersion("1.0.0", "2.0.0")).toBe(true);
|
||||
|
||||
expect(isNewerVersion("1.0.1", "1.0.0")).toBe(false);
|
||||
expect(isNewerVersion("1.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle version prefixes", () => {
|
||||
expect(isNewerVersion("v1.0.0", "v1.0.1")).toBe(true);
|
||||
expect(isNewerVersion("1.0.0", "v1.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle prerelease versions", () => {
|
||||
expect(isNewerVersion("1.0.0-beta.1", "1.0.0")).toBe(true);
|
||||
expect(isNewerVersion("1.0.0", "1.0.0-beta.1")).toBe(false);
|
||||
});
|
||||
|
||||
test("should correctly compare prerelease versions with numeric parts", () => {
|
||||
// Test the main issue: rc.5 vs rc.10
|
||||
expect(isNewerVersion("3.17.0-rc.5", "3.17.0-rc.10")).toBe(true);
|
||||
expect(isNewerVersion("3.17.0-rc.10", "3.17.0-rc.5")).toBe(false);
|
||||
|
||||
// Test other numeric comparisons
|
||||
expect(isNewerVersion("1.0.0-beta.1", "1.0.0-beta.2")).toBe(true);
|
||||
expect(isNewerVersion("1.0.0-alpha.9", "1.0.0-alpha.10")).toBe(true);
|
||||
expect(isNewerVersion("1.0.0-rc.99", "1.0.0-rc.100")).toBe(true);
|
||||
|
||||
// Test mixed alphanumeric comparisons
|
||||
expect(isNewerVersion("1.0.0-alpha.1", "1.0.0-beta.1")).toBe(true);
|
||||
expect(isNewerVersion("1.0.0-beta.1", "1.0.0-rc.1")).toBe(true);
|
||||
|
||||
// Test versions with different number of parts
|
||||
expect(isNewerVersion("1.0.0-beta", "1.0.0-beta.1")).toBe(true);
|
||||
expect(isNewerVersion("1.0.0-beta.1", "1.0.0-beta")).toBe(false);
|
||||
});
|
||||
|
||||
test("should treat two-part versions as patch=0 (e.g., 3.16 == 3.16.0)", () => {
|
||||
expect(isNewerVersion("3.16", "3.16.0")).toBe(false);
|
||||
expect(isNewerVersion("3.16.0", "3.16")).toBe(false);
|
||||
expect(isNewerVersion("3.16", "3.16.1")).toBe(true);
|
||||
});
|
||||
|
||||
test("should ignore build metadata for precedence", () => {
|
||||
expect(isNewerVersion("1.0.0+001", "1.0.0+002")).toBe(false);
|
||||
expect(isNewerVersion("1.0.0", "1.0.0+exp.sha.5114f85")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
apps/web/app/(app)/environments/[environmentId]/lib/utils.ts
Normal file
93
apps/web/app/(app)/environments/[environmentId]/lib/utils.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export interface VersionInfo {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
prerelease?: string;
|
||||
}
|
||||
|
||||
export const parseVersion = (version: string): VersionInfo | null => {
|
||||
// Remove 'v' prefix if present
|
||||
const cleanVersion = version.replace(/^v/, "");
|
||||
|
||||
// Regex for semantic versioning with optional patch and prerelease (no build metadata)
|
||||
// Supports both 2-part (1.2) and 3-part (1.2.3) versions
|
||||
const semverRegex = /^(\d+)\.(\d+)(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
||||
|
||||
const match = semverRegex.exec(cleanVersion);
|
||||
if (!match) {
|
||||
console.warn(`Invalid version format: ${version}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
major: parseInt(match[1], 10),
|
||||
minor: parseInt(match[2], 10),
|
||||
patch: match[3] ? parseInt(match[3], 10) : 0, // Default to 0 if patch is missing
|
||||
prerelease: match[4],
|
||||
};
|
||||
};
|
||||
|
||||
const comparePrereleaseVersions = (current: string, latest: string): number => {
|
||||
const currentParts = current.split(".");
|
||||
const latestParts = latest.split(".");
|
||||
const max = Math.max(currentParts.length, latestParts.length);
|
||||
|
||||
const isNumeric = (s: string) => /^\d+$/.test(s);
|
||||
|
||||
const comparePart = (a?: string, b?: string): number => {
|
||||
if (!a && b) return 1; // latest has more segments → newer
|
||||
if (a && !b) return -1; // current has more segments → older
|
||||
if (!a && !b) return 0;
|
||||
|
||||
const aNum = isNumeric(a!);
|
||||
const bNum = isNumeric(b!);
|
||||
if (aNum && bNum) return parseInt(b!, 10) - parseInt(a!, 10);
|
||||
return b!.localeCompare(a!);
|
||||
};
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const diff = comparePart(currentParts[i], latestParts[i]);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const compareVersions = (current: string, latest: string): number => {
|
||||
const currentVersion = parseVersion(current);
|
||||
const latestVersion = parseVersion(latest);
|
||||
|
||||
// If either version is invalid, treat as different
|
||||
if (!currentVersion || !latestVersion) {
|
||||
return current === latest ? 0 : -1;
|
||||
}
|
||||
|
||||
// Compare major.minor.patch
|
||||
const majorDiff = latestVersion.major - currentVersion.major;
|
||||
if (majorDiff !== 0) return majorDiff;
|
||||
|
||||
const minorDiff = latestVersion.minor - currentVersion.minor;
|
||||
if (minorDiff !== 0) return minorDiff;
|
||||
|
||||
const patchDiff = latestVersion.patch - currentVersion.patch;
|
||||
if (patchDiff !== 0) return patchDiff;
|
||||
|
||||
// Handle prerelease versions
|
||||
if (!currentVersion.prerelease && !latestVersion.prerelease) {
|
||||
return 0; // Both are stable, same version
|
||||
}
|
||||
|
||||
if (!currentVersion.prerelease && latestVersion.prerelease) {
|
||||
return -1; // Current is stable, latest is prerelease - current is "newer"
|
||||
}
|
||||
|
||||
if (currentVersion.prerelease && !latestVersion.prerelease) {
|
||||
return 1; // Current is prerelease, latest is stable - latest is newer
|
||||
}
|
||||
|
||||
// Both are prerelease, compare properly
|
||||
return comparePrereleaseVersions(currentVersion.prerelease!, latestVersion.prerelease!);
|
||||
};
|
||||
|
||||
export const isNewerVersion = (current: string, latest: string): boolean => {
|
||||
return compareVersions(current, latest) > 0;
|
||||
};
|
||||
@@ -23,6 +23,10 @@ vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
}));
|
||||
|
||||
describe("EnvironmentPage", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -11,7 +12,11 @@ const EnvironmentPage = async (props) => {
|
||||
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
} else {
|
||||
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect(`/environments/${params.environmentId}/surveys`);
|
||||
|
||||
@@ -34,26 +34,12 @@ export const OrganizationSettingsNavbar = ({
|
||||
current: pathname?.includes("/general"),
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
hidden: !isFormbricksCloud || loading,
|
||||
current: pathname?.includes("/billing"),
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.teams"),
|
||||
href: `/environments/${environmentId}/settings/teams`,
|
||||
current: pathname?.includes("/teams"),
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${environmentId}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isPricingDisabled,
|
||||
current: pathname?.includes("/enterprise"),
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
@@ -61,6 +47,20 @@ export const OrganizationSettingsNavbar = ({
|
||||
current: pathname?.includes("/api-keys"),
|
||||
hidden: !isOwner,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
hidden: !isFormbricksCloud || loading,
|
||||
current: pathname?.includes("/billing"),
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${environmentId}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isPricingDisabled,
|
||||
current: pathname?.includes("/enterprise"),
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
|
||||
@@ -14,7 +14,7 @@ const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
notFound();
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
|
||||
@@ -22,7 +22,7 @@ const Page = async (props) => {
|
||||
const isPricingDisabled = isMember;
|
||||
|
||||
if (isPricingDisabled) {
|
||||
notFound();
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
||||
|
||||
@@ -145,7 +145,7 @@ const mockLocale: TUserLocale = "en-US";
|
||||
|
||||
const mockSetSelectedResponseId = vi.fn();
|
||||
const mockUpdateResponse = vi.fn();
|
||||
const mockDeleteResponses = vi.fn();
|
||||
const mockUpdateResponseList = vi.fn();
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
@@ -157,7 +157,7 @@ const defaultProps = {
|
||||
user: mockUser,
|
||||
environmentTags: mockEnvironmentTags,
|
||||
updateResponse: mockUpdateResponse,
|
||||
deleteResponses: mockDeleteResponses,
|
||||
updateResponseList: mockUpdateResponseList,
|
||||
isReadOnly: false,
|
||||
open: true,
|
||||
setOpen: mockSetOpen,
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ResponseCardModalProps {
|
||||
user?: TUser;
|
||||
environmentTags: TTag[];
|
||||
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
|
||||
deleteResponses: (responseIds: string[]) => void;
|
||||
updateResponseList: (responseIds: string[]) => void;
|
||||
isReadOnly: boolean;
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -34,7 +34,7 @@ export const ResponseCardModal = ({
|
||||
user,
|
||||
environmentTags,
|
||||
updateResponse,
|
||||
deleteResponses,
|
||||
updateResponseList,
|
||||
isReadOnly,
|
||||
open,
|
||||
setOpen,
|
||||
@@ -86,7 +86,7 @@ export const ResponseCardModal = ({
|
||||
environmentTags={environmentTags}
|
||||
isReadOnly={isReadOnly}
|
||||
updateResponse={updateResponse}
|
||||
deleteResponses={deleteResponses}
|
||||
updateResponseList={updateResponseList}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
@@ -176,7 +176,7 @@ const defaultProps = {
|
||||
isReadOnly: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasMore: true,
|
||||
deleteResponses: vi.fn(),
|
||||
updateResponseList: vi.fn(),
|
||||
updateResponse: vi.fn(),
|
||||
isFetchingFirstPage: false,
|
||||
locale: mockLocale,
|
||||
@@ -264,7 +264,7 @@ describe("ResponseDataView", () => {
|
||||
expect(responseTableMock.mock.calls[0][0].environment).toEqual(mockEnvironment);
|
||||
expect(responseTableMock.mock.calls[0][0].fetchNextPage).toBe(defaultProps.fetchNextPage);
|
||||
expect(responseTableMock.mock.calls[0][0].hasMore).toBe(true);
|
||||
expect(responseTableMock.mock.calls[0][0].deleteResponses).toBe(defaultProps.deleteResponses);
|
||||
expect(responseTableMock.mock.calls[0][0].updateResponseList).toBe(defaultProps.updateResponseList);
|
||||
expect(responseTableMock.mock.calls[0][0].updateResponse).toBe(defaultProps.updateResponse);
|
||||
expect(responseTableMock.mock.calls[0][0].isFetchingFirstPage).toBe(false);
|
||||
expect(responseTableMock.mock.calls[0][0].locale).toBe(mockLocale);
|
||||
|
||||
@@ -4,24 +4,27 @@ import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import React from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse, TResponseDataValue, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseDataValue, TResponseTableData, TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface ResponseDataViewProps {
|
||||
survey: TSurvey;
|
||||
responses: TResponse[];
|
||||
responses: TResponseWithQuotas[];
|
||||
user?: TUser;
|
||||
environment: TEnvironment;
|
||||
environmentTags: TTag[];
|
||||
isReadOnly: boolean;
|
||||
fetchNextPage: () => void;
|
||||
hasMore: boolean;
|
||||
deleteResponses: (responseIds: string[]) => void;
|
||||
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
|
||||
updateResponseList: (responseIds: string[]) => void;
|
||||
updateResponse: (responseId: string, updatedResponse: TResponseWithQuotas) => void;
|
||||
isFetchingFirstPage: boolean;
|
||||
locale: TUserLocale;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
@@ -47,7 +50,7 @@ export const formatContactInfoData = (responseValue: TResponseDataValue): Record
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const extractResponseData = (response: TResponse, survey: TSurvey): Record<string, any> => {
|
||||
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
|
||||
let responseData: Record<string, any> = {};
|
||||
|
||||
survey.questions.forEach((question) => {
|
||||
@@ -78,7 +81,7 @@ export const extractResponseData = (response: TResponse, survey: TSurvey): Recor
|
||||
|
||||
// Export for testing
|
||||
export const mapResponsesToTableData = (
|
||||
responses: TResponse[],
|
||||
responses: TResponseWithQuotas[],
|
||||
survey: TSurvey,
|
||||
t: TFnType
|
||||
): TResponseTableData[] => {
|
||||
@@ -101,6 +104,7 @@ export const mapResponsesToTableData = (
|
||||
person: response.contact,
|
||||
contactAttributes: response.contactAttributes,
|
||||
meta: response.meta,
|
||||
quotas: response.quotas?.map((quota) => quota.name),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -113,10 +117,12 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
||||
isReadOnly,
|
||||
fetchNextPage,
|
||||
hasMore,
|
||||
deleteResponses,
|
||||
updateResponseList,
|
||||
updateResponse,
|
||||
isFetchingFirstPage,
|
||||
locale,
|
||||
isQuotasAllowed,
|
||||
quotas,
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
const data = mapResponsesToTableData(responses, survey, t);
|
||||
@@ -133,10 +139,12 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
||||
environment={environment}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasMore={hasMore}
|
||||
deleteResponses={deleteResponses}
|
||||
updateResponseList={updateResponseList}
|
||||
updateResponse={updateResponse}
|
||||
isFetchingFirstPage={isFetchingFirstPage}
|
||||
locale={locale}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -187,7 +187,7 @@ describe("ResponsePage", () => {
|
||||
).mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
responseDataViewProps.deleteResponses(["response1"]);
|
||||
responseDataViewProps.updateResponseList(["response1"]);
|
||||
});
|
||||
|
||||
// Check if ResponseDataView is re-rendered with updated responses
|
||||
|
||||
@@ -9,7 +9,8 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
@@ -23,6 +24,8 @@ interface ResponsePageProps {
|
||||
responsesPerPage: number;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
}
|
||||
|
||||
export const ResponsePage = ({
|
||||
@@ -34,8 +37,10 @@ export const ResponsePage = ({
|
||||
responsesPerPage,
|
||||
locale,
|
||||
isReadOnly,
|
||||
isQuotasAllowed,
|
||||
quotas,
|
||||
}: ResponsePageProps) => {
|
||||
const [responses, setResponses] = useState<TResponse[]>([]);
|
||||
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
|
||||
@@ -53,7 +58,7 @@ export const ResponsePage = ({
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
const newPage = page + 1;
|
||||
|
||||
let newResponses: TResponse[] = [];
|
||||
let newResponses: TResponseWithQuotas[] = [];
|
||||
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
@@ -70,14 +75,12 @@ export const ResponsePage = ({
|
||||
setPage(newPage);
|
||||
}, [filters, page, responses, responsesPerPage, surveyId]);
|
||||
|
||||
const deleteResponses = (responseIds: string[]) => {
|
||||
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
|
||||
const updateResponseList = (responseIds: string[]) => {
|
||||
setResponses((prev) => prev.filter((r) => !responseIds.includes(r.id)));
|
||||
};
|
||||
|
||||
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
|
||||
if (responses) {
|
||||
setResponses(responses.map((response) => (response.id === responseId ? updatedResponse : response)));
|
||||
}
|
||||
const updateResponse = (responseId: string, updatedResponse: TResponseWithQuotas) => {
|
||||
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
||||
};
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
@@ -94,7 +97,7 @@ export const ResponsePage = ({
|
||||
const fetchInitialResponses = async () => {
|
||||
try {
|
||||
setFetchingFirstPage(true);
|
||||
let responses: TResponse[] = [];
|
||||
let responses: TResponseWithQuotas[] = [];
|
||||
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
@@ -136,10 +139,12 @@ export const ResponsePage = ({
|
||||
isReadOnly={isReadOnly}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasMore={hasMore}
|
||||
deleteResponses={deleteResponses}
|
||||
updateResponseList={updateResponseList}
|
||||
updateResponse={updateResponse}
|
||||
isFetchingFirstPage={isFetchingFirstPage}
|
||||
locale={locale}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,8 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseTableData, TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
@@ -40,17 +41,19 @@ import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
interface ResponseTableProps {
|
||||
data: TResponseTableData[];
|
||||
survey: TSurvey;
|
||||
responses: TResponse[] | null;
|
||||
responses: TResponseWithQuotas[] | null;
|
||||
environment: TEnvironment;
|
||||
user?: TUser;
|
||||
environmentTags: TTag[];
|
||||
isReadOnly: boolean;
|
||||
fetchNextPage: () => void;
|
||||
hasMore: boolean;
|
||||
deleteResponses: (responseIds: string[]) => void;
|
||||
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
|
||||
updateResponseList: (responseIds: string[]) => void;
|
||||
updateResponse: (responseId: string, updatedResponse: TResponseWithQuotas) => void;
|
||||
isFetchingFirstPage: boolean;
|
||||
locale: TUserLocale;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
}
|
||||
|
||||
export const ResponseTable = ({
|
||||
@@ -63,10 +66,12 @@ export const ResponseTable = ({
|
||||
isReadOnly,
|
||||
fetchNextPage,
|
||||
hasMore,
|
||||
deleteResponses,
|
||||
updateResponseList,
|
||||
updateResponse,
|
||||
isFetchingFirstPage,
|
||||
locale,
|
||||
isQuotasAllowed,
|
||||
quotas,
|
||||
}: ResponseTableProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
@@ -78,8 +83,9 @@ export const ResponseTable = ({
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||
// Generate columns
|
||||
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t);
|
||||
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
@@ -178,8 +184,8 @@ export const ResponseTable = ({
|
||||
}
|
||||
};
|
||||
|
||||
const deleteResponse = async (responseId: string) => {
|
||||
await deleteResponseAction({ responseId });
|
||||
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
||||
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
|
||||
};
|
||||
|
||||
// Handle downloading selected responses
|
||||
@@ -221,10 +227,11 @@ export const ResponseTable = ({
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
deleteRowsAction={deleteResponses}
|
||||
updateRowList={updateResponseList}
|
||||
type="response"
|
||||
deleteAction={deleteResponse}
|
||||
downloadRowsAction={downloadSelectedRows}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
/>
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
||||
<div className="w-full overflow-x-auto">
|
||||
@@ -299,7 +306,7 @@ export const ResponseTable = ({
|
||||
environmentTags={environmentTags}
|
||||
isReadOnly={isReadOnly}
|
||||
updateResponse={updateResponse}
|
||||
deleteResponses={deleteResponses}
|
||||
updateResponseList={updateResponseList}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
selectedResponseId={selectedResponseId}
|
||||
open={selectedResponse !== null}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
@@ -31,10 +30,6 @@ vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: vi.fn((localizedString, locale) => localizedString[locale] || localizedString.default),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/responses", () => ({
|
||||
processResponseData: vi.fn((data) => (Array.isArray(data) ? data.join(", ") : String(data))),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: vi.fn((person) => person?.attributes?.email || person?.id || "Anonymous"),
|
||||
}));
|
||||
@@ -139,13 +134,13 @@ const mockSurvey = {
|
||||
questions: [
|
||||
{
|
||||
id: "q1open",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
type: "openText",
|
||||
headline: { default: "Open Text Question" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2matrix",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
type: "matrix",
|
||||
headline: { default: "Matrix Question" },
|
||||
rows: [
|
||||
{ id: "row-1", label: { default: "Row1" } },
|
||||
@@ -159,19 +154,19 @@ const mockSurvey = {
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q3address",
|
||||
type: TSurveyQuestionTypeEnum.Address,
|
||||
type: "address",
|
||||
headline: { default: "Address Question" },
|
||||
required: false,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q4contact",
|
||||
type: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
type: "contactInfo",
|
||||
headline: { default: "Contact Info Question" },
|
||||
required: false,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q5single",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
type: "multipleChoiceSingle",
|
||||
headline: { default: "Single Choice Question" },
|
||||
required: false,
|
||||
choices: [
|
||||
@@ -182,7 +177,7 @@ const mockSurvey = {
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q6multi",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
type: "multipleChoiceMulti",
|
||||
headline: { default: "Multi Choice Question" },
|
||||
required: false,
|
||||
choices: [
|
||||
@@ -251,44 +246,44 @@ describe("generateResponseTableColumns", () => {
|
||||
});
|
||||
|
||||
test("should include selection column when not read-only", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, false, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, false, t as any, false);
|
||||
expect(columns[0].id).toBe("select");
|
||||
expect(vi.mocked(getSelectionColumn)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should not include selection column when read-only", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
expect(columns[0].id).not.toBe("select");
|
||||
expect(vi.mocked(getSelectionColumn)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should include Verified Email column when survey.isVerifyEmailEnabled is true", () => {
|
||||
const surveyWithVerifiedEmail = { ...mockSurvey, isVerifyEmailEnabled: true };
|
||||
const columns = generateResponseTableColumns(surveyWithVerifiedEmail, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(surveyWithVerifiedEmail, false, true, t as any, false);
|
||||
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(true);
|
||||
});
|
||||
|
||||
test("should not include Verified Email column when survey.isVerifyEmailEnabled is false", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(false);
|
||||
});
|
||||
|
||||
test("should generate columns for variables", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const var1Col = columns.find((col) => (col as any).accessorKey === "var1");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const var1Col = columns.find((col) => (col as any).accessorKey === "VARIABLE_var1");
|
||||
expect(var1Col).toBeDefined();
|
||||
const var1Cell = (var1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
||||
expect(var1Cell.props.children).toBe("Segment A");
|
||||
|
||||
const var2Col = columns.find((col) => (col as any).accessorKey === "var2");
|
||||
const var2Col = columns.find((col) => (col as any).accessorKey === "VARIABLE_var2");
|
||||
expect(var2Col).toBeDefined();
|
||||
const var2Cell = (var2Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
||||
expect(var2Cell.props.children).toBe(100);
|
||||
});
|
||||
|
||||
test("should generate columns for hidden fields if fieldIds exist", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const hf1Col = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
|
||||
expect(hf1Col).toBeDefined();
|
||||
const hf1Cell = (hf1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
||||
expect(hf1Cell.props.children).toBe("Hidden Field 1 Value");
|
||||
@@ -296,7 +291,7 @@ describe("generateResponseTableColumns", () => {
|
||||
|
||||
test("should not generate columns for hidden fields if fieldIds is undefined", () => {
|
||||
const surveyWithoutHiddenFieldIds = { ...mockSurvey, hiddenFields: { enabled: true } };
|
||||
const columns = generateResponseTableColumns(surveyWithoutHiddenFieldIds, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(surveyWithoutHiddenFieldIds, false, true, t as any, false);
|
||||
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||
expect(hf1Col).toBeUndefined();
|
||||
});
|
||||
@@ -321,7 +316,7 @@ describe("ResponseTableColumns", () => {
|
||||
const isReadOnly = false;
|
||||
|
||||
// Act
|
||||
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
|
||||
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT, false);
|
||||
|
||||
// Assert
|
||||
const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail");
|
||||
@@ -349,7 +344,7 @@ describe("ResponseTableColumns", () => {
|
||||
const isReadOnly = false;
|
||||
|
||||
// Act
|
||||
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
|
||||
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT, false);
|
||||
|
||||
// Assert
|
||||
const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail");
|
||||
@@ -363,7 +358,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
||||
});
|
||||
|
||||
test("dateColumn renders with formatted date", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt");
|
||||
expect(dateColumn).toBeDefined();
|
||||
|
||||
@@ -381,7 +376,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
||||
});
|
||||
|
||||
test("personColumn renders anonymous when person is null", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
|
||||
expect(personColumn).toBeDefined();
|
||||
|
||||
@@ -404,7 +399,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
||||
});
|
||||
|
||||
test("personColumn renders person identifier when person exists", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
|
||||
expect(personColumn).toBeDefined();
|
||||
|
||||
@@ -425,7 +420,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
||||
});
|
||||
|
||||
test("tagsColumn returns undefined when tags is not an array", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags");
|
||||
expect(tagsColumn).toBeDefined();
|
||||
|
||||
@@ -440,10 +435,10 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
||||
});
|
||||
|
||||
test("variableColumns render variable values correctly", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
|
||||
// Find the variable column for var1
|
||||
const var1Column: any = columns.find((col) => (col as any).accessorKey === "var1");
|
||||
const var1Column: any = columns.find((col) => (col as any).accessorKey === "VARIABLE_var1");
|
||||
expect(var1Column).toBeDefined();
|
||||
|
||||
// Test the header
|
||||
@@ -460,7 +455,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
||||
expect(cellResult?.props.children).toBe("Test Value");
|
||||
|
||||
// Test with a number variable
|
||||
const var2Column: any = columns.find((col) => (col as any).accessorKey === "var2");
|
||||
const var2Column: any = columns.find((col) => (col as any).accessorKey === "VARIABLE_var2");
|
||||
expect(var2Column).toBeDefined();
|
||||
|
||||
const mockRowNumber = {
|
||||
@@ -472,10 +467,10 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
||||
});
|
||||
|
||||
test("hiddenFieldColumns render when fieldIds exist", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
|
||||
// Find the hidden field column
|
||||
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
|
||||
expect(hfColumn).toBeDefined();
|
||||
|
||||
// Test the header
|
||||
@@ -499,10 +494,10 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
||||
hiddenFields: { enabled: true }, // no fieldIds
|
||||
};
|
||||
|
||||
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any, false);
|
||||
|
||||
// Check that no hidden field columns were created
|
||||
const hfColumn = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||
const hfColumn = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
|
||||
expect(hfColumn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -517,32 +512,32 @@ describe("ResponseTableColumns - Multiple Choice Questions", () => {
|
||||
});
|
||||
|
||||
test("generates two columns for multipleChoiceSingle questions", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
|
||||
// Should have main response column
|
||||
const mainColumn = columns.find((col) => (col as any).accessorKey === "q5single");
|
||||
const mainColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
|
||||
expect(mainColumn).toBeDefined();
|
||||
|
||||
// Should have option IDs column
|
||||
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds");
|
||||
expect(optionIdsColumn).toBeDefined();
|
||||
});
|
||||
|
||||
test("generates two columns for multipleChoiceMulti questions", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
|
||||
// Should have main response column
|
||||
const mainColumn = columns.find((col) => (col as any).accessorKey === "q6multi");
|
||||
const mainColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
|
||||
expect(mainColumn).toBeDefined();
|
||||
|
||||
// Should have option IDs column
|
||||
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
|
||||
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multioptionIds");
|
||||
expect(optionIdsColumn).toBeDefined();
|
||||
});
|
||||
|
||||
test("multipleChoiceSingle main column renders RenderResponse component", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
@@ -557,8 +552,8 @@ describe("ResponseTableColumns - Multiple Choice Questions", () => {
|
||||
});
|
||||
|
||||
test("multipleChoiceMulti main column renders RenderResponse component", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
@@ -583,8 +578,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
|
||||
});
|
||||
|
||||
test("option IDs column calls extractChoiceIdsFromResponse for string response", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const optionIdsColumn: any = columns.find(
|
||||
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
|
||||
);
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
@@ -603,8 +600,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
|
||||
});
|
||||
|
||||
test("option IDs column calls extractChoiceIdsFromResponse for array response", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const optionIdsColumn: any = columns.find(
|
||||
(col) => (col as any).accessorKey === "QUESTION_q6multioptionIds"
|
||||
);
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
@@ -623,8 +622,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
|
||||
});
|
||||
|
||||
test("option IDs column renders IdBadge components for choice IDs", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const optionIdsColumn: any = columns.find(
|
||||
(col) => (col as any).accessorKey === "QUESTION_q6multioptionIds"
|
||||
);
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
@@ -645,8 +646,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
|
||||
});
|
||||
|
||||
test("option IDs column returns null for non-string/array response values", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const optionIdsColumn: any = columns.find(
|
||||
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
|
||||
);
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
@@ -662,8 +665,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
|
||||
});
|
||||
|
||||
test("option IDs column returns null when no choice IDs found", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const optionIdsColumn: any = columns.find(
|
||||
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
|
||||
);
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
@@ -681,8 +686,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
|
||||
});
|
||||
|
||||
test("option IDs column handles missing language gracefully", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const optionIdsColumn: any = columns.find(
|
||||
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
|
||||
);
|
||||
|
||||
const mockRow = {
|
||||
original: {
|
||||
@@ -711,9 +718,11 @@ describe("ResponseTableColumns - Helper Functions", () => {
|
||||
});
|
||||
|
||||
test("question headers are properly created for multiple choice questions", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
|
||||
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
|
||||
const optionIdsColumn: any = columns.find(
|
||||
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
|
||||
);
|
||||
|
||||
// Test main column header
|
||||
const mainHeader = mainColumn?.header?.();
|
||||
@@ -727,9 +736,9 @@ describe("ResponseTableColumns - Helper Functions", () => {
|
||||
});
|
||||
|
||||
test("question headers include proper icons for multiple choice questions", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
|
||||
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
|
||||
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
|
||||
|
||||
// Headers should be functions that return JSX
|
||||
expect(typeof singleChoiceColumn?.header).toBe("function");
|
||||
@@ -751,13 +760,13 @@ describe("ResponseTableColumns - Integration Tests", () => {
|
||||
});
|
||||
|
||||
test("multiple choice questions work end-to-end with real data", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
||||
|
||||
// Find all multiple choice related columns
|
||||
const singleMainCol = columns.find((col) => (col as any).accessorKey === "q5single");
|
||||
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
|
||||
const multiMainCol = columns.find((col) => (col as any).accessorKey === "q6multi");
|
||||
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
|
||||
const singleMainCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
|
||||
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds");
|
||||
const multiMainCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
|
||||
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multioptionIds");
|
||||
|
||||
expect(singleMainCol).toBeDefined();
|
||||
expect(singleIdsCol).toBeDefined();
|
||||
|
||||
@@ -34,6 +34,8 @@ const getQuestionColumnsData = (
|
||||
t: TFnType
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
||||
|
||||
// Helper function to create consistent column headers
|
||||
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
|
||||
@@ -74,7 +76,7 @@ const getQuestionColumnsData = (
|
||||
case "matrix":
|
||||
return question.rows.map((matrixRow) => {
|
||||
return {
|
||||
accessorKey: matrixRow.label.default,
|
||||
accessorKey: "QUESTION_" + question.id + "_" + matrixRow.label.default,
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -99,10 +101,9 @@ const getQuestionColumnsData = (
|
||||
});
|
||||
|
||||
case "address":
|
||||
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||
return addressFields.map((addressField) => {
|
||||
return {
|
||||
accessorKey: addressField,
|
||||
accessorKey: "QUESTION_" + question.id + "_" + addressField,
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -123,10 +124,9 @@ const getQuestionColumnsData = (
|
||||
});
|
||||
|
||||
case "contactInfo":
|
||||
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return contactInfoFields.map((contactInfoField) => {
|
||||
return {
|
||||
accessorKey: contactInfoField,
|
||||
accessorKey: "QUESTION_" + question.id + "_" + contactInfoField,
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -153,7 +153,7 @@ const getQuestionColumnsData = (
|
||||
const questionHeadline = getQuestionHeadline(question, survey);
|
||||
return [
|
||||
{
|
||||
accessorKey: question.id,
|
||||
accessorKey: "QUESTION_" + question.id,
|
||||
header: createQuestionHeader(question.type, questionHeadline),
|
||||
cell: ({ row }) => {
|
||||
const responseValue = row.original.responseData[question.id];
|
||||
@@ -171,7 +171,7 @@ const getQuestionColumnsData = (
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: question.id + "optionIds",
|
||||
accessorKey: "QUESTION_" + question.id + "optionIds",
|
||||
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
|
||||
cell: ({ row }) => {
|
||||
const responseValue = row.original.responseData[question.id];
|
||||
@@ -193,7 +193,7 @@ const getQuestionColumnsData = (
|
||||
default:
|
||||
return [
|
||||
{
|
||||
accessorKey: question.id,
|
||||
accessorKey: "QUESTION_" + question.id,
|
||||
header: () => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
@@ -233,7 +233,7 @@ const getMetadataColumnsData = (t: TFnType): ColumnDef<TResponseTableData>[] =>
|
||||
const IconComponent = COLUMNS_ICON_MAP[label];
|
||||
|
||||
metadataColumns.push({
|
||||
accessorKey: label,
|
||||
accessorKey: "METADATA_" + label,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
|
||||
@@ -257,7 +257,8 @@ export const generateResponseTableColumns = (
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
isReadOnly: boolean,
|
||||
t: TFnType
|
||||
t: TFnType,
|
||||
showQuotasColumn: boolean
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const questionColumns = survey.questions.flatMap((question) =>
|
||||
getQuestionColumnsData(question, survey, isExpanded, t)
|
||||
@@ -283,12 +284,13 @@ export const generateResponseTableColumns = (
|
||||
<TooltipTrigger>
|
||||
<CircleHelpIcon className="h-3 w-3 text-slate-500" strokeWidth={1.5} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="font-normal">
|
||||
{t("environments.surveys.responses.how_to_identify_users")}
|
||||
<TooltipContent side="bottom" className="space-x-1 font-normal">
|
||||
<span>{t("environments.surveys.responses.how_to_identify_users")}</span>
|
||||
<Link
|
||||
className="underline underline-offset-2 hover:text-slate-900"
|
||||
href="https://formbricks.com/docs/app-surveys/user-identification"
|
||||
target="_blank">
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("common.app_survey")}
|
||||
</Link>
|
||||
</TooltipContent>
|
||||
@@ -305,10 +307,21 @@ export const generateResponseTableColumns = (
|
||||
},
|
||||
};
|
||||
|
||||
const quotasColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "quota",
|
||||
header: t("common.quota"),
|
||||
cell: ({ row }) => {
|
||||
const quotas = row.original.quotas;
|
||||
const items = quotas?.map((quota) => ({ value: quota })) ?? [];
|
||||
return <ResponseBadges items={items} showId={false} />;
|
||||
},
|
||||
size: 200,
|
||||
};
|
||||
|
||||
const statusColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "status",
|
||||
size: 200,
|
||||
header: t("common.status"),
|
||||
header: () => <div className="gap-x-1.5">{t("common.status")}</div>,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status;
|
||||
return <ResponseBadges items={[{ value: status }]} showId={false} />;
|
||||
@@ -317,7 +330,7 @@ export const generateResponseTableColumns = (
|
||||
|
||||
const tagsColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "tags",
|
||||
header: t("common.tags"),
|
||||
header: () => <div className="gap-x-1.5">{t("common.tags")}</div>,
|
||||
cell: ({ row }) => {
|
||||
const tags = row.original.tags;
|
||||
if (Array.isArray(tags)) {
|
||||
@@ -336,7 +349,7 @@ export const generateResponseTableColumns = (
|
||||
|
||||
const variableColumns: ColumnDef<TResponseTableData>[] = survey.variables.map((variable) => {
|
||||
return {
|
||||
accessorKey: variable.id,
|
||||
accessorKey: "VARIABLE_" + variable.id,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{VARIABLES_ICON_MAP[variable.type]}</span>
|
||||
@@ -355,7 +368,7 @@ export const generateResponseTableColumns = (
|
||||
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
|
||||
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
|
||||
return {
|
||||
accessorKey: hiddenFieldId,
|
||||
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">
|
||||
@@ -392,6 +405,7 @@ export const generateResponseTableColumns = (
|
||||
const baseColumns = [
|
||||
personColumn,
|
||||
dateColumn,
|
||||
...(showQuotasColumn ? [quotasColumn] : []),
|
||||
statusColumn,
|
||||
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
|
||||
...questionColumns,
|
||||
|
||||
@@ -10,8 +10,11 @@ import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -102,6 +105,19 @@ vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: vi.fn(({ children }) => <div data-testid="page-content-wrapper">{children}</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/organization", () => ({
|
||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/survey", () => ({
|
||||
getOrganizationBilling: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsQuotasEnabled: vi.fn(),
|
||||
getIsContactsEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ pageTitle, children, cta }) => (
|
||||
<div data-testid="page-header">
|
||||
@@ -194,6 +210,9 @@ describe("ResponsesPage", () => {
|
||||
|
||||
test("renders correctly with all data", async () => {
|
||||
const props = { params: mockParams };
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("mock-organization-id");
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue({ plan: "scale" });
|
||||
vi.mocked(getIsQuotasEnabled).mockResolvedValue(false);
|
||||
const jsx = await Page(props);
|
||||
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -46,6 +49,19 @@ const Page = async (props) => {
|
||||
const locale = await findMatchingLocale();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
if (!organizationId) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
||||
|
||||
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
@@ -75,6 +91,8 @@ const Page = async (props) => {
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,8 @@ const baseSummary = {
|
||||
startsPercentage: 75,
|
||||
totalResponses: 4,
|
||||
ttcAverage: 65000,
|
||||
quotasCompleted: 0,
|
||||
quotasCompletedPercentage: 0,
|
||||
};
|
||||
|
||||
describe("SummaryMetadata", () => {
|
||||
@@ -38,25 +40,26 @@ describe("SummaryMetadata", () => {
|
||||
test("renders loading skeletons when isLoading=true", () => {
|
||||
const { container } = render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
tab={undefined}
|
||||
setTab={() => {}}
|
||||
surveySummary={baseSummary}
|
||||
isLoading={true}
|
||||
isQuotasAllowed={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.getElementsByClassName("animate-pulse")).toHaveLength(5);
|
||||
expect(container.getElementsByClassName("animate-pulse")).toHaveLength(6);
|
||||
});
|
||||
|
||||
test("renders all stats and formats time correctly, toggles dropOffs icon", async () => {
|
||||
const Wrapper = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<SummaryMetadata
|
||||
showDropOffs={show}
|
||||
setShowDropOffs={setShow}
|
||||
tab={"dropOffs"}
|
||||
setTab={() => {}}
|
||||
surveySummary={baseSummary}
|
||||
isLoading={false}
|
||||
isQuotasAllowed={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -83,10 +86,11 @@ describe("SummaryMetadata", () => {
|
||||
const smallSummary = { ...baseSummary, ttcAverage: 5000 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
tab="dropOffs"
|
||||
setTab={() => {}}
|
||||
surveySummary={smallSummary}
|
||||
isLoading={false}
|
||||
isQuotasAllowed={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("5.00s")).toBeInTheDocument();
|
||||
@@ -95,13 +99,13 @@ describe("SummaryMetadata", () => {
|
||||
test("renders '-' for dropOffCount=0 and still toggles icon", async () => {
|
||||
const zeroSummary = { ...baseSummary, dropOffCount: 0 };
|
||||
const Wrapper = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<SummaryMetadata
|
||||
showDropOffs={show}
|
||||
setShowDropOffs={setShow}
|
||||
tab="dropOffs"
|
||||
setTab={() => {}}
|
||||
surveySummary={zeroSummary}
|
||||
isLoading={false}
|
||||
isQuotasAllowed={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -119,10 +123,11 @@ describe("SummaryMetadata", () => {
|
||||
const dispZero = { ...baseSummary, displayCount: 0 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
tab="dropOffs"
|
||||
setTab={() => {}}
|
||||
surveySummary={dispZero}
|
||||
isLoading={false}
|
||||
isQuotasAllowed={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
@@ -132,10 +137,11 @@ describe("SummaryMetadata", () => {
|
||||
const totZero = { ...baseSummary, totalResponses: 0 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
tab="dropOffs"
|
||||
setTab={() => {}}
|
||||
surveySummary={totZero}
|
||||
isLoading={false}
|
||||
isQuotasAllowed={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
|
||||
@@ -1,44 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { InteractiveCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/interactive-card";
|
||||
import { StatCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/stat-card";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface SummaryMetadataProps {
|
||||
setShowDropOffs: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showDropOffs: boolean;
|
||||
surveySummary: TSurveySummary["meta"];
|
||||
isLoading: boolean;
|
||||
tab: "dropOffs" | "quotas" | undefined;
|
||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
||||
isQuotasAllowed: boolean;
|
||||
}
|
||||
|
||||
const StatCard = ({ label, percentage, value, tooltipText, isLoading }) => {
|
||||
return (
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<p className="flex items-center gap-1 text-sm text-slate-600">
|
||||
{label}
|
||||
{typeof percentage === "number" && !isNaN(percentage) && !isLoading && (
|
||||
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{percentage}%</span>
|
||||
)}
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-slate-800">{value}</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const formatTime = (ttc) => {
|
||||
const seconds = ttc / 1000;
|
||||
let formattedValue;
|
||||
@@ -55,10 +30,11 @@ const formatTime = (ttc) => {
|
||||
};
|
||||
|
||||
export const SummaryMetadata = ({
|
||||
setShowDropOffs,
|
||||
showDropOffs,
|
||||
surveySummary,
|
||||
isLoading,
|
||||
tab,
|
||||
setTab,
|
||||
isQuotasAllowed,
|
||||
}: SummaryMetadataProps) => {
|
||||
const {
|
||||
completedPercentage,
|
||||
@@ -69,13 +45,24 @@ export const SummaryMetadata = ({
|
||||
startsPercentage,
|
||||
totalResponses,
|
||||
ttcAverage,
|
||||
quotasCompleted,
|
||||
quotasCompletedPercentage,
|
||||
} = surveySummary;
|
||||
const { t } = useTranslate();
|
||||
const displayCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
||||
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
||||
|
||||
const handleTabChange = (val: "dropOffs" | "quotas") => {
|
||||
const change = tab === val ? undefined : val;
|
||||
setTab(change);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5 md:gap-x-2 lg:col-span-4">
|
||||
<div
|
||||
className={cn(
|
||||
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
||||
isQuotasAllowed && "2xl:grid-cols-6"
|
||||
)}>
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.impressions")}
|
||||
percentage={null}
|
||||
@@ -98,41 +85,17 @@ export const SummaryMetadata = ({
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger onClick={() => setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle">
|
||||
<div className="flex h-full cursor-pointer flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<span className="text-sm text-slate-600">
|
||||
{t("environments.surveys.summary.drop_offs")}
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
|
||||
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{`${Math.round(dropOffPercentage)}%`}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex w-full items-end justify-between">
|
||||
<span className="text-2xl font-bold text-slate-800">
|
||||
{isLoading ? (
|
||||
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
|
||||
) : (
|
||||
displayCountValue
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("environments.surveys.summary.drop_offs_tooltip")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<InteractiveCard
|
||||
key="dropOffs"
|
||||
tab="dropOffs"
|
||||
label={t("environments.surveys.summary.drop_offs")}
|
||||
percentage={dropOffPercentage}
|
||||
value={dropoffCountValue}
|
||||
tooltipText={t("environments.surveys.summary.drop_offs_tooltip")}
|
||||
isLoading={isLoading}
|
||||
onClick={() => handleTabChange("dropOffs")}
|
||||
isActive={tab === "dropOffs"}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.time_to_complete")}
|
||||
@@ -141,6 +104,20 @@ export const SummaryMetadata = ({
|
||||
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{isQuotasAllowed && (
|
||||
<InteractiveCard
|
||||
key="quotas"
|
||||
tab="quotas"
|
||||
label={t("environments.surveys.summary.quotas_completed")}
|
||||
percentage={quotasCompletedPercentage}
|
||||
value={quotasCompleted}
|
||||
tooltipText={t("environments.surveys.summary.quotas_completed_tooltip")}
|
||||
isLoading={isLoading}
|
||||
onClick={() => handleTabChange("quotas")}
|
||||
isActive={tab === "quotas"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,8 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/
|
||||
startsPercentage: 100,
|
||||
totalResponses: 50,
|
||||
ttcAverage: 120,
|
||||
quotasCompleted: 0,
|
||||
quotasCompletedPercentage: 0,
|
||||
},
|
||||
dropOff: [
|
||||
{
|
||||
@@ -67,10 +69,10 @@ vi.mock(
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata",
|
||||
() => ({
|
||||
SummaryMetadata: ({ showDropOffs, setShowDropOffs, isLoading }: any) => (
|
||||
SummaryMetadata: ({ tab, setTab, isLoading }: any) => (
|
||||
<div data-testid="summary-metadata">
|
||||
<span>Is Loading: {isLoading ? "true" : "false"}</span>
|
||||
<button onClick={() => setShowDropOffs(!showDropOffs)}>Toggle Dropoffs</button>
|
||||
<button onClick={() => setTab(tab === "dropOffs" ? "quotas" : "dropOffs")}>Toggle Dropoffs</button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
@@ -166,7 +168,7 @@ describe("SummaryPage", () => {
|
||||
expect(screen.queryByTestId("summary-drop-offs")).not.toBeInTheDocument();
|
||||
|
||||
// Toggle drop-offs
|
||||
await user.click(screen.getByText("Toggle Dropoffs"));
|
||||
await user.click(screen.getByRole("button", { name: "Toggle Dropoffs" }));
|
||||
|
||||
// Drop-offs should now be visible
|
||||
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/survey
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -25,8 +26,11 @@ const defaultSurveySummary: TSurveySummary = {
|
||||
startsPercentage: 0,
|
||||
totalResponses: 0,
|
||||
ttcAverage: 0,
|
||||
quotasCompleted: 0,
|
||||
quotasCompletedPercentage: 0,
|
||||
},
|
||||
dropOff: [],
|
||||
quotas: [],
|
||||
summary: [],
|
||||
};
|
||||
|
||||
@@ -36,6 +40,7 @@ interface SummaryPageProps {
|
||||
surveyId: string;
|
||||
locale: TUserLocale;
|
||||
initialSurveySummary?: TSurveySummary;
|
||||
isQuotasAllowed: boolean;
|
||||
}
|
||||
|
||||
export const SummaryPage = ({
|
||||
@@ -44,13 +49,15 @@ export const SummaryPage = ({
|
||||
surveyId,
|
||||
locale,
|
||||
initialSurveySummary,
|
||||
isQuotasAllowed,
|
||||
}: SummaryPageProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
||||
initialSurveySummary || defaultSurveySummary
|
||||
);
|
||||
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
|
||||
|
||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
@@ -108,11 +115,13 @@ export const SummaryPage = ({
|
||||
<>
|
||||
<SummaryMetadata
|
||||
surveySummary={surveySummary.meta}
|
||||
showDropOffs={showDropOffs}
|
||||
setShowDropOffs={setShowDropOffs}
|
||||
isLoading={isLoading}
|
||||
tab={tab}
|
||||
setTab={setTab}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
/>
|
||||
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
||||
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
||||
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
</div>
|
||||
|
||||
@@ -170,7 +170,7 @@ vi.mock("@/modules/ui/components/confirmation-modal", () => ({
|
||||
open,
|
||||
setOpen,
|
||||
title,
|
||||
text,
|
||||
body,
|
||||
buttonText,
|
||||
onConfirm,
|
||||
buttonVariant,
|
||||
@@ -182,7 +182,7 @@ vi.mock("@/modules/ui/components/confirmation-modal", () => ({
|
||||
data-loading={buttonLoading}
|
||||
data-variant={buttonVariant}>
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<div data-testid="modal-text">{text}</div>
|
||||
<div data-testid="modal-text">{body}</div>
|
||||
<button type="button" onClick={onConfirm} data-testid="confirm-button">
|
||||
{buttonText}
|
||||
</button>
|
||||
|
||||
@@ -235,7 +235,7 @@ export const SurveyAnalysisCTA = ({
|
||||
open={isResetModalOpen}
|
||||
setOpen={setIsResetModalOpen}
|
||||
title={t("environments.surveys.summary.delete_all_existing_responses_and_displays")}
|
||||
text={t("environments.surveys.summary.reset_survey_warning")}
|
||||
body={t("environments.surveys.summary.reset_survey_warning")}
|
||||
buttonText={t("environments.surveys.summary.reset_survey")}
|
||||
onConfirm={handleResetSurvey}
|
||||
buttonVariant="destructive"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { BaseCard } from "./base-card";
|
||||
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-provider">{children}</div>
|
||||
),
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
|
||||
TooltipTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
"data-testid": dataTestId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
"data-testid"?: string;
|
||||
}) => (
|
||||
<div data-testid="tooltip-trigger" onClick={onClick} data-testid-value={dataTestId}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-content">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("BaseCard", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders basic card with label and children", () => {
|
||||
render(
|
||||
<BaseCard label="Test Label" tooltipText="Test tooltip">
|
||||
<div>Test Content</div>
|
||||
</BaseCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays percentage when provided as valid number", () => {
|
||||
render(
|
||||
<BaseCard label="Test Label" percentage={75}>
|
||||
<div>Test Content</div>
|
||||
</BaseCard>
|
||||
);
|
||||
|
||||
expect(screen.getByText("75%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not display percentage when loading", () => {
|
||||
render(
|
||||
<BaseCard label="Test Label" percentage={75} isLoading={true}>
|
||||
<div>Test Content</div>
|
||||
</BaseCard>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("75%")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onClick when card is clicked", async () => {
|
||||
const handleClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<BaseCard label="Test Label" onClick={handleClick}>
|
||||
<div>Test Content</div>
|
||||
</BaseCard>
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("tooltip-trigger"));
|
||||
expect(handleClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface BaseCardProps {
|
||||
label: ReactNode;
|
||||
percentage?: number | null;
|
||||
tooltipText?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const BaseCard = ({
|
||||
label,
|
||||
percentage = null,
|
||||
tooltipText,
|
||||
isLoading = false,
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
id,
|
||||
}: BaseCardProps) => {
|
||||
const isClickable = !!onClick;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger onClick={onClick} data-testid={testId}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm",
|
||||
isClickable ? "cursor-pointer" : "cursor-default",
|
||||
className
|
||||
)}
|
||||
id={id}>
|
||||
<p className="flex items-center gap-1 text-sm text-slate-600">
|
||||
{label}
|
||||
{typeof percentage === "number" &&
|
||||
!isNaN(percentage) &&
|
||||
Number.isFinite(percentage) &&
|
||||
!isLoading && (
|
||||
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{tooltipText && (
|
||||
<TooltipContent>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { InteractiveCard } from "./interactive-card";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card",
|
||||
() => ({
|
||||
BaseCard: ({
|
||||
label,
|
||||
percentage,
|
||||
tooltipText,
|
||||
isLoading,
|
||||
onClick,
|
||||
testId,
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
percentage?: number | null;
|
||||
tooltipText?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
testId?: string;
|
||||
id?: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div data-testid="base-card" onClick={onClick}>
|
||||
<div data-testid="base-card-label">{label}</div>
|
||||
{percentage !== null && percentage !== undefined && (
|
||||
<div data-testid="base-card-percentage">{percentage}%</div>
|
||||
)}
|
||||
{tooltipText && <div data-testid="base-card-tooltip">{tooltipText}</div>}
|
||||
<div data-testid="base-card-loading">{isLoading ? "loading" : "not-loading"}</div>
|
||||
<div data-testid="base-card-testid">{testId}</div>
|
||||
<div data-testid="base-card-id">{id}</div>
|
||||
<div data-testid="base-card-children">{children}</div>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
ChevronDownIcon: ({ className }: { className: string }) => (
|
||||
<div data-testid="chevron-down-icon" className={className}>
|
||||
ChevronDown
|
||||
</div>
|
||||
),
|
||||
ChevronUpIcon: ({ className }: { className: string }) => (
|
||||
<div data-testid="chevron-up-icon" className={className}>
|
||||
ChevronUp
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("InteractiveCard", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
tab: "dropOffs" as const,
|
||||
label: "Test Label",
|
||||
percentage: 75,
|
||||
value: "Test Value",
|
||||
tooltipText: "Test tooltip",
|
||||
isLoading: false,
|
||||
onClick: vi.fn(),
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
test("renders with basic props", () => {
|
||||
render(<InteractiveCard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("base-card")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("base-card-label")).toHaveTextContent("Test Label");
|
||||
expect(screen.getByTestId("base-card-percentage")).toHaveTextContent("75%");
|
||||
expect(screen.getByTestId("base-card-tooltip")).toHaveTextContent("Test tooltip");
|
||||
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("not-loading");
|
||||
expect(screen.getByText("Test Value")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("generates correct testId and id based on tab", () => {
|
||||
render(<InteractiveCard {...defaultProps} tab="quotas" />);
|
||||
|
||||
expect(screen.getByTestId("base-card-testid")).toHaveTextContent("quotas-toggle");
|
||||
expect(screen.getByTestId("base-card-id")).toHaveTextContent("quotas-toggle");
|
||||
});
|
||||
|
||||
test("calls onClick when clicked", async () => {
|
||||
const handleClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<InteractiveCard {...defaultProps} onClick={handleClick} />);
|
||||
|
||||
await user.click(screen.getByTestId("base-card"));
|
||||
expect(handleClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("renders loading state with skeleton", () => {
|
||||
render(<InteractiveCard {...defaultProps} isLoading={true} />);
|
||||
|
||||
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("loading");
|
||||
|
||||
const skeleton = screen.getByTestId("base-card-children").querySelector(".animate-pulse");
|
||||
expect(skeleton).toHaveClass("h-6", "w-12", "animate-pulse", "rounded-full", "bg-slate-200");
|
||||
|
||||
expect(screen.queryByText("Test Value")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("chevron-up-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows chevron up icon when active", () => {
|
||||
render(<InteractiveCard {...defaultProps} isActive={true} />);
|
||||
|
||||
expect(screen.getByTestId("chevron-up-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chevron-up-icon")).toHaveClass("h-4", "w-4");
|
||||
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles zero percentage", () => {
|
||||
render(<InteractiveCard {...defaultProps} percentage={0} />);
|
||||
|
||||
expect(screen.getByTestId("base-card-percentage")).toHaveTextContent("0%");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
interface InteractiveCardProps {
|
||||
tab: "dropOffs" | "quotas";
|
||||
label: string;
|
||||
percentage: number;
|
||||
value: React.ReactNode;
|
||||
tooltipText: string;
|
||||
isLoading: boolean;
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export const InteractiveCard = ({
|
||||
tab,
|
||||
label,
|
||||
percentage,
|
||||
value,
|
||||
tooltipText,
|
||||
isLoading,
|
||||
onClick,
|
||||
isActive,
|
||||
}: InteractiveCardProps) => {
|
||||
return (
|
||||
<BaseCard
|
||||
label={label}
|
||||
percentage={percentage}
|
||||
tooltipText={tooltipText}
|
||||
isLoading={isLoading}
|
||||
onClick={onClick}
|
||||
testId={`${tab}-toggle`}
|
||||
id={`${tab}-toggle`}>
|
||||
<div className="flex w-full items-end justify-between">
|
||||
<span className="text-2xl font-bold text-slate-800">
|
||||
{isLoading ? <div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div> : value}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
|
||||
{isActive ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { StatCard } from "./stat-card";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card",
|
||||
() => ({
|
||||
BaseCard: ({
|
||||
label,
|
||||
percentage,
|
||||
tooltipText,
|
||||
isLoading,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
percentage?: number | null;
|
||||
tooltipText?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div data-testid="base-card">
|
||||
<div data-testid="base-card-label">{label}</div>
|
||||
{percentage !== null && percentage !== undefined && (
|
||||
<div data-testid="base-card-percentage">{percentage}%</div>
|
||||
)}
|
||||
{tooltipText && <div data-testid="base-card-tooltip">{tooltipText}</div>}
|
||||
<div data-testid="base-card-loading">{isLoading ? "loading" : "not-loading"}</div>
|
||||
<div data-testid="base-card-children">{children}</div>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
describe("StatCard", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders with basic props", () => {
|
||||
render(<StatCard label="Test Label" value="Test Value" />);
|
||||
|
||||
expect(screen.getByTestId("base-card")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("base-card-label")).toHaveTextContent("Test Label");
|
||||
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("not-loading");
|
||||
expect(screen.getByText("Test Value")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes percentage prop to BaseCard", () => {
|
||||
render(<StatCard label="Test Label" value="Test Value" percentage={75} />);
|
||||
|
||||
expect(screen.getByTestId("base-card-percentage")).toHaveTextContent("75%");
|
||||
});
|
||||
|
||||
test("renders loading state with skeleton", () => {
|
||||
render(<StatCard label="Test Label" value="Test Value" isLoading={true} />);
|
||||
|
||||
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("loading");
|
||||
|
||||
const skeleton = screen.getByTestId("base-card-children").querySelector("div");
|
||||
expect(skeleton).toHaveClass("h-6", "w-12", "animate-pulse", "rounded-full", "bg-slate-200");
|
||||
|
||||
expect(screen.queryByText("Test Value")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders value when not loading", () => {
|
||||
render(<StatCard label="Test Label" value="Test Value" isLoading={false} />);
|
||||
|
||||
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("not-loading");
|
||||
expect(screen.getByText("Test Value")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Value")).toHaveClass("text-2xl", "font-bold", "text-slate-800");
|
||||
});
|
||||
|
||||
test("renders dash value correctly", () => {
|
||||
const dashValue = <span>-</span>;
|
||||
render(<StatCard label="Test Label" value={dashValue} />);
|
||||
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface StatCardProps {
|
||||
label: ReactNode;
|
||||
percentage?: number | null;
|
||||
value: ReactNode;
|
||||
tooltipText?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const StatCard = ({
|
||||
label,
|
||||
percentage = null,
|
||||
value,
|
||||
tooltipText,
|
||||
isLoading = false,
|
||||
}: StatCardProps) => {
|
||||
return (
|
||||
<BaseCard label={label} percentage={percentage} tooltipText={tooltipText} isLoading={isLoading}>
|
||||
{isLoading ? (
|
||||
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-slate-800">{value}</p>
|
||||
)}
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { deleteResponsesAndDisplaysForSurvey } from "./survey";
|
||||
import { deleteResponsesAndDisplaysForSurvey, getQuotasSummary } from "./survey";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -14,6 +15,9 @@ vi.mock("@formbricks/database", () => ({
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
surveyQuota: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -81,3 +85,74 @@ describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getQuotasSummary service", () => {
|
||||
test("Returns the correct quotas summary", async () => {
|
||||
vi.mocked(prisma.surveyQuota.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "quota123",
|
||||
name: "Test Quota",
|
||||
limit: 100,
|
||||
_count: {
|
||||
quotaLinks: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await getQuotasSummary(surveyId);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "quota123",
|
||||
name: "Test Quota",
|
||||
limit: 100,
|
||||
count: 0,
|
||||
percentage: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Returns 0 percentage if limit is 0", async () => {
|
||||
vi.mocked(prisma.surveyQuota.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "quota123",
|
||||
name: "Test Quota",
|
||||
limit: 0,
|
||||
_count: {
|
||||
quotaLinks: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await getQuotasSummary(surveyId);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "quota123",
|
||||
name: "Test Quota",
|
||||
limit: 0,
|
||||
count: 0,
|
||||
percentage: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
vi.mocked(prisma.surveyQuota.findMany).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getQuotasSummary(surveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("Throws a generic Error for other exceptions", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
vi.mocked(prisma.surveyQuota.findMany).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(getQuotasSummary(surveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { convertFloatTo2Decimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
@@ -34,3 +35,47 @@ export const deleteResponsesAndDisplaysForSurvey = async (
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getQuotasSummary = async (surveyId: string) => {
|
||||
try {
|
||||
const quotas = await prisma.surveyQuota.findMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
quotaLinks: {
|
||||
where: {
|
||||
status: "screenedIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
name: true,
|
||||
limit: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return quotas.map((quota) => {
|
||||
const { _count, ...rest } = quota;
|
||||
const count = _count.quotaLinks;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
count,
|
||||
percentage: quota.limit > 0 ? convertFloatTo2Decimal((count / quota.limit) * 100) : 0,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
@@ -58,6 +59,11 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey", () => ({
|
||||
getQuotasSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./utils", () => ({
|
||||
convertFloatTo2Decimal: vi.fn((num) =>
|
||||
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
||||
@@ -138,6 +144,23 @@ const mockResponses = [
|
||||
},
|
||||
] as any;
|
||||
|
||||
const mockQuotas = [
|
||||
{
|
||||
id: "quota1",
|
||||
name: "Quota 1",
|
||||
limit: 10,
|
||||
count: 5,
|
||||
percentage: 50,
|
||||
},
|
||||
{
|
||||
id: "quota2",
|
||||
name: "Quota 2",
|
||||
limit: 20,
|
||||
count: 10,
|
||||
percentage: 50,
|
||||
},
|
||||
];
|
||||
|
||||
describe("getSurveySummaryMeta", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
||||
@@ -146,7 +169,7 @@ describe("getSurveySummaryMeta", () => {
|
||||
});
|
||||
|
||||
test("calculates meta correctly", () => {
|
||||
const meta = getSurveySummaryMeta(mockResponses, 10);
|
||||
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
|
||||
expect(meta.displayCount).toBe(10);
|
||||
expect(meta.totalResponses).toBe(3);
|
||||
expect(meta.startsPercentage).toBe(30);
|
||||
@@ -155,16 +178,18 @@ describe("getSurveySummaryMeta", () => {
|
||||
expect(meta.dropOffCount).toBe(1);
|
||||
expect(meta.dropOffPercentage).toBe(33.33); // (1/3)*100
|
||||
expect(meta.ttcAverage).toBe(125); // (100+150)/2
|
||||
expect(meta.quotasCompleted).toBe(0);
|
||||
expect(meta.quotasCompletedPercentage).toBe(0);
|
||||
});
|
||||
|
||||
test("handles zero display count", () => {
|
||||
const meta = getSurveySummaryMeta(mockResponses, 0);
|
||||
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
|
||||
expect(meta.startsPercentage).toBe(0);
|
||||
expect(meta.completedPercentage).toBe(0);
|
||||
});
|
||||
|
||||
test("handles zero responses", () => {
|
||||
const meta = getSurveySummaryMeta([], 10);
|
||||
const meta = getSurveySummaryMeta([], 10, mockQuotas);
|
||||
expect(meta.totalResponses).toBe(0);
|
||||
expect(meta.completedResponses).toBe(0);
|
||||
expect(meta.dropOffCount).toBe(0);
|
||||
@@ -702,6 +727,7 @@ describe("getSurveySummary", () => {
|
||||
// Default mocks for services
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(mockResponses.length);
|
||||
vi.mocked(getQuotasSummary).mockResolvedValue(mockQuotas);
|
||||
// For getResponsesForSummary mock, we need to ensure it's correctly used by getSurveySummary
|
||||
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
|
||||
// which is used by the actual implementation of getResponsesForSummary.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
@@ -55,7 +56,8 @@ interface TSurveySummaryResponse {
|
||||
|
||||
export const getSurveySummaryMeta = (
|
||||
responses: TSurveySummaryResponse[],
|
||||
displayCount: number
|
||||
displayCount: number,
|
||||
quotas: TSurveySummary["quotas"]
|
||||
): TSurveySummary["meta"] => {
|
||||
const completedResponses = responses.filter((response) => response.finished).length;
|
||||
|
||||
@@ -75,6 +77,9 @@ export const getSurveySummaryMeta = (
|
||||
const dropOffPercentage = responseCount > 0 ? (dropOffCount / responseCount) * 100 : 0;
|
||||
const ttcAverage = ttcResponseCount > 0 ? ttcSum / ttcResponseCount : 0;
|
||||
|
||||
const quotasCompleted = quotas.filter((quota) => quota.count >= quota.limit).length;
|
||||
const quotasCompletedPercentage = quotas.length > 0 ? (quotasCompleted / quotas.length) * 100 : 0;
|
||||
|
||||
return {
|
||||
displayCount: displayCount || 0,
|
||||
totalResponses: responseCount,
|
||||
@@ -84,6 +89,8 @@ export const getSurveySummaryMeta = (
|
||||
dropOffCount,
|
||||
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentage),
|
||||
ttcAverage: convertFloatTo2Decimal(ttcAverage),
|
||||
quotasCompleted,
|
||||
quotasCompletedPercentage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -934,18 +941,26 @@ export const getSurveySummary = reactCache(
|
||||
|
||||
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
|
||||
|
||||
const displayCount = await getDisplayCountBySurveyId(surveyId, {
|
||||
createdAt: filterCriteria?.createdAt,
|
||||
...(hasFilter && { responseIds }),
|
||||
});
|
||||
const [displayCount, quotas] = await Promise.all([
|
||||
getDisplayCountBySurveyId(surveyId, {
|
||||
createdAt: filterCriteria?.createdAt,
|
||||
...(hasFilter && { responseIds }),
|
||||
}),
|
||||
getQuotasSummary(surveyId),
|
||||
]);
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
|
||||
const [meta, questionWiseSummary] = await Promise.all([
|
||||
getSurveySummaryMeta(responses, displayCount),
|
||||
getSurveySummaryMeta(responses, displayCount, quotas),
|
||||
getQuestionSummary(survey, responses, dropOff),
|
||||
]);
|
||||
|
||||
return { meta, dropOff, summary: questionWiseSummary };
|
||||
return {
|
||||
meta,
|
||||
dropOff,
|
||||
summary: questionWiseSummary,
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -8,8 +8,11 @@ import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -114,6 +117,19 @@ vi.mock("next/navigation", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/organization", () => ({
|
||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/survey", () => ({
|
||||
getOrganizationBilling: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsQuotasEnabled: vi.fn(),
|
||||
getIsContactsEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-environment-id";
|
||||
const mockSurveyId = "test-survey-id";
|
||||
const mockUserId = "test-user-id";
|
||||
@@ -191,7 +207,10 @@ const mockSurveySummary = {
|
||||
startsPercentage: 80,
|
||||
totalResponses: 20,
|
||||
ttcAverage: 120,
|
||||
quotasCompleted: 0,
|
||||
quotasCompletedPercentage: 0,
|
||||
},
|
||||
quotas: [],
|
||||
dropOff: [],
|
||||
summary: [],
|
||||
};
|
||||
@@ -203,6 +222,9 @@ describe("SurveyPage", () => {
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("mock-organization-id");
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue({ plan: "scale" });
|
||||
vi.mocked(getIsQuotasEnabled).mockResolvedValue(false);
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||
|
||||
@@ -7,8 +7,10 @@ import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -41,6 +43,16 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
if (!organizationId) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
||||
|
||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||
|
||||
@@ -72,6 +84,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
surveyId={params.surveyId}
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
/>
|
||||
|
||||
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
|
||||
|
||||
@@ -9,9 +9,12 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -59,9 +62,11 @@ export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -75,12 +80,20 @@ export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
const [tags, { contactAttributes: attributes, meta, hiddenFields }] = await Promise.all([
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
||||
|
||||
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
|
||||
getTagsByEnvironmentId(survey.environmentId),
|
||||
getResponseFilteringValues(parsedInput.surveyId),
|
||||
isQuotasAllowed ? getQuotas(parsedInput.surveyId) : [],
|
||||
]);
|
||||
|
||||
return { environmentTags: tags, attributes, meta, hiddenFields };
|
||||
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ListOrderedIcon,
|
||||
MessageSquareTextIcon,
|
||||
MousePointerClickIcon,
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmartphoneIcon,
|
||||
StarIcon,
|
||||
@@ -48,6 +49,7 @@ export enum OptionsType {
|
||||
OTHERS = "Other Filters",
|
||||
META = "Meta",
|
||||
HIDDEN_FIELDS = "Hidden Fields",
|
||||
QUOTAS = "Quotas",
|
||||
}
|
||||
|
||||
export type QuestionOption = {
|
||||
@@ -102,6 +104,9 @@ const questionIcons = {
|
||||
|
||||
// tags
|
||||
[OptionsType.TAGS]: HashIcon,
|
||||
|
||||
// quotas
|
||||
[OptionsType.QUOTAS]: PieChartIcon,
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
@@ -122,6 +127,8 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
|
||||
return getIcon(label);
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return getIcon(OptionsType.TAGS);
|
||||
} else if (type === OptionsType.QUOTAS) {
|
||||
return getIcon(OptionsType.QUOTAS);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -133,6 +140,8 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
|
||||
return "bg-brand-dark";
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return "bg-indigo-500";
|
||||
} else if (type === OptionsType.QUOTAS) {
|
||||
return "bg-slate-500";
|
||||
} else {
|
||||
return "bg-amber-500";
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/type
|
||||
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
|
||||
export type QuestionFilterOptions = {
|
||||
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages";
|
||||
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
|
||||
filterOptions: string[];
|
||||
filterComboBoxOptions: string[];
|
||||
id: string;
|
||||
@@ -51,13 +51,14 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
|
||||
if (!surveyFilterData?.data) return;
|
||||
|
||||
const { attributes, meta, environmentTags, hiddenFields } = surveyFilterData.data;
|
||||
const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
|
||||
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
|
||||
survey,
|
||||
environmentTags,
|
||||
attributes,
|
||||
meta,
|
||||
hiddenFields
|
||||
hiddenFields,
|
||||
quotas
|
||||
);
|
||||
setSelectedOptions({ questionFilterOptions, questionOptions });
|
||||
}
|
||||
|
||||
@@ -37,5 +37,5 @@ export const GET = async (_: Request, context: { params: Promise<{ organizationI
|
||||
return redirect(`/environments/${prodEnvironment.id}/settings/billing`);
|
||||
}
|
||||
|
||||
redirect(`/environments/${prodEnvironment.id}/`);
|
||||
return redirect(`/environments/${prodEnvironment.id}/`);
|
||||
};
|
||||
|
||||
@@ -21,5 +21,5 @@ export const GET = async (_: Request, context: { params: Promise<{ projectId: st
|
||||
const environments = await getEnvironments(project.id);
|
||||
const prodEnvironment = environments.find((e) => e.type === "production");
|
||||
if (!prodEnvironment) return notFound();
|
||||
redirect(`/environments/${prodEnvironment.id}/`);
|
||||
return redirect(`/environments/${prodEnvironment.id}/`);
|
||||
};
|
||||
|
||||
@@ -66,4 +66,6 @@ export const GET = async (req: Request) => {
|
||||
if (result) {
|
||||
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/google-sheets`);
|
||||
}
|
||||
|
||||
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
|
||||
};
|
||||
|
||||
34
apps/web/app/api/lib/utils.ts
Normal file
34
apps/web/app/api/lib/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { Organization } from "@prisma/client";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const handleBillingLimitsCheck = async (
|
||||
environmentId: string,
|
||||
organizationId: string,
|
||||
organizationBilling: Organization["billing"]
|
||||
): Promise<void> => {
|
||||
if (!IS_FORMBRICKS_CLOUD) return;
|
||||
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organizationId);
|
||||
const responsesLimit = organizationBilling.limits.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organizationBilling.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: responsesLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Log error but do not throw
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { updateResponse } from "@/lib/response/service";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { updateResponseWithQuotaEvaluation } from "./response";
|
||||
|
||||
vi.mock("@/lib/response/service");
|
||||
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
||||
|
||||
const mockUpdateResponse = vi.mocked(updateResponse);
|
||||
const mockEvaluateResponseQuotas = vi.mocked(evaluateResponseQuotas);
|
||||
|
||||
type MockTx = {
|
||||
response: {
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
let mockTx: MockTx;
|
||||
|
||||
describe("updateResponseWithQuotaEvaluation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockTx = {
|
||||
response: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
|
||||
});
|
||||
|
||||
const mockResponseId = "response123";
|
||||
const mockResponseInput = {
|
||||
data: { question1: "answer1" },
|
||||
finished: true,
|
||||
};
|
||||
|
||||
const mockResponse: TResponse = {
|
||||
id: "response123",
|
||||
surveyId: "survey123",
|
||||
finished: true,
|
||||
data: { question1: "answer1" },
|
||||
meta: {},
|
||||
ttc: {},
|
||||
variables: { var1: "value1" },
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
language: "en",
|
||||
displayId: null,
|
||||
endingId: null,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
contact: null,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const mockQuotaFull: TSurveyQuota = {
|
||||
id: "quota123",
|
||||
surveyId: "survey123",
|
||||
name: "Test Quota",
|
||||
action: "endSurvey",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
countPartialSubmissions: false,
|
||||
endingCardId: null,
|
||||
limit: 100,
|
||||
logic: {
|
||||
connector: "and",
|
||||
conditions: [],
|
||||
},
|
||||
};
|
||||
|
||||
test("should return response with quotaFull when quota evaluation returns quotaFull", async () => {
|
||||
mockUpdateResponse.mockResolvedValue(mockResponse);
|
||||
mockEvaluateResponseQuotas.mockResolvedValue({
|
||||
quotaFull: mockQuotaFull,
|
||||
shouldEndSurvey: true,
|
||||
});
|
||||
|
||||
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
|
||||
|
||||
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
|
||||
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: mockResponse.surveyId,
|
||||
responseId: mockResponse.id,
|
||||
data: mockResponse.data,
|
||||
variables: mockResponse.variables,
|
||||
language: mockResponse.language,
|
||||
responseFinished: mockResponse.finished,
|
||||
tx: mockTx,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...mockResponse,
|
||||
quotaFull: mockQuotaFull,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return response without quotaFull when quota evaluation returns no quotaFull", async () => {
|
||||
mockUpdateResponse.mockResolvedValue(mockResponse);
|
||||
mockEvaluateResponseQuotas.mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
});
|
||||
|
||||
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
|
||||
|
||||
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
|
||||
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: mockResponse.surveyId,
|
||||
responseId: mockResponse.id,
|
||||
data: mockResponse.data,
|
||||
variables: mockResponse.variables,
|
||||
language: mockResponse.language,
|
||||
responseFinished: mockResponse.finished,
|
||||
tx: mockTx,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result).not.toHaveProperty("quotaFull");
|
||||
});
|
||||
|
||||
test("should use default language when response language is null", async () => {
|
||||
const responseWithNullLanguage = { ...mockResponse, language: null };
|
||||
mockUpdateResponse.mockResolvedValue(responseWithNullLanguage);
|
||||
mockEvaluateResponseQuotas.mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
});
|
||||
|
||||
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
|
||||
|
||||
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: responseWithNullLanguage.surveyId,
|
||||
responseId: responseWithNullLanguage.id,
|
||||
data: responseWithNullLanguage.data,
|
||||
variables: responseWithNullLanguage.variables,
|
||||
language: "default",
|
||||
responseFinished: responseWithNullLanguage.finished,
|
||||
tx: mockTx,
|
||||
});
|
||||
|
||||
expect(result).toEqual(responseWithNullLanguage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { updateResponse } from "@/lib/response/service";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
|
||||
export const updateResponseWithQuotaEvaluation = async (
|
||||
responseId: string,
|
||||
responseInput: TResponseUpdateInput
|
||||
): Promise<TResponseWithQuotaFull> => {
|
||||
const txResponse = await prisma.$transaction(async (tx) => {
|
||||
const response = await updateResponse(responseId, responseInput, tx);
|
||||
|
||||
const quotaResult = await evaluateResponseQuotas({
|
||||
surveyId: response.surveyId,
|
||||
responseId: response.id,
|
||||
data: response.data,
|
||||
variables: response.variables,
|
||||
language: response.language || "default",
|
||||
responseFinished: response.finished,
|
||||
tx,
|
||||
});
|
||||
|
||||
return {
|
||||
...response,
|
||||
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
|
||||
};
|
||||
});
|
||||
|
||||
return txResponse;
|
||||
};
|
||||
@@ -3,13 +3,15 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { getResponse, updateResponse } from "@/lib/response/service";
|
||||
import { getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
@@ -111,10 +113,10 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
// update response
|
||||
// update response with quota evaluation
|
||||
let updatedResponse;
|
||||
try {
|
||||
updatedResponse = await updateResponse(responseId, inputValidation.data);
|
||||
updatedResponse = await updateResponseWithQuotaEvaluation(responseId, inputValidation.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
@@ -137,13 +139,15 @@ export const PUT = withV1ApiWrapper({
|
||||
}
|
||||
}
|
||||
|
||||
const { quotaFull, ...responseData } = updatedResponse;
|
||||
|
||||
// send response update to pipeline
|
||||
// don't await to not block the response
|
||||
sendToPipeline({
|
||||
event: "responseUpdated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: updatedResponse,
|
||||
response: responseData,
|
||||
});
|
||||
|
||||
if (updatedResponse.finished) {
|
||||
@@ -153,11 +157,19 @@ export const PUT = withV1ApiWrapper({
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: updatedResponse,
|
||||
response: responseData,
|
||||
});
|
||||
}
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
id: responseData.id,
|
||||
...quotaObj,
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse({}, true),
|
||||
response: responses.successResponse(responseDataWithQuota, true),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,13 +4,15 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import { createResponse } from "./response";
|
||||
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
||||
|
||||
let mockIsFormbricksCloud = false;
|
||||
|
||||
@@ -18,6 +20,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
get IS_FORMBRICKS_CLOUD() {
|
||||
return mockIsFormbricksCloud;
|
||||
},
|
||||
ENCRYPTION_KEY: "test",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
@@ -46,6 +49,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
response: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -59,6 +63,10 @@ vi.mock("./contact", () => ({
|
||||
getContactByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/quotas/lib/evaluation-service", () => ({
|
||||
evaluateResponseQuotas: vi.fn(),
|
||||
}));
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
const surveyId = "test-survey-id";
|
||||
const organizationId = "test-organization-id";
|
||||
@@ -100,6 +108,13 @@ const mockResponsePrisma = {
|
||||
tags: [],
|
||||
};
|
||||
|
||||
type MockTx = {
|
||||
response: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
let mockTx: MockTx;
|
||||
|
||||
describe("createResponse", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -127,7 +142,7 @@ describe("createResponse", () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
|
||||
await createResponse(mockResponseInput);
|
||||
await createResponse(mockResponseInput, prisma);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
@@ -137,7 +152,7 @@ describe("createResponse", () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
|
||||
await createResponse(mockResponseInput);
|
||||
await createResponse(mockResponseInput, prisma);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
@@ -154,7 +169,7 @@ describe("createResponse", () => {
|
||||
|
||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
@@ -186,3 +201,159 @@ describe("createResponse", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResponseWithQuotaEvaluation", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockTx = {
|
||||
response: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma as any);
|
||||
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ttc);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockIsFormbricksCloud = false;
|
||||
});
|
||||
|
||||
test("should return response without quotaFull when no quota violations", async () => {
|
||||
// Mock quota evaluation to return no violations
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: undefined,
|
||||
});
|
||||
|
||||
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
|
||||
|
||||
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: mockResponseInput.surveyId,
|
||||
responseId: responseId,
|
||||
data: mockResponseInput.data,
|
||||
variables: mockResponseInput.variables,
|
||||
language: mockResponseInput.language,
|
||||
responseFinished: mockResponseInput.finished,
|
||||
tx: mockTx,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: responseId,
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
surveyId,
|
||||
finished: false,
|
||||
data: { question1: "answer1" },
|
||||
meta: { source: "web" },
|
||||
ttc: { question1: 1000 },
|
||||
variables: {},
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
language: null,
|
||||
displayId: null,
|
||||
contact: null,
|
||||
tags: [],
|
||||
});
|
||||
expect(result).not.toHaveProperty("quotaFull");
|
||||
});
|
||||
|
||||
test("should return response with quotaFull when quota is exceeded with endSurvey action", async () => {
|
||||
const mockQuotaFull: TSurveyQuota = {
|
||||
id: "quota-123",
|
||||
name: "Test Quota",
|
||||
limit: 100,
|
||||
action: "endSurvey",
|
||||
endingCardId: "ending-123",
|
||||
surveyId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
logic: {
|
||||
connector: "and",
|
||||
conditions: [],
|
||||
},
|
||||
countPartialSubmissions: true,
|
||||
};
|
||||
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: true,
|
||||
quotaFull: mockQuotaFull,
|
||||
});
|
||||
|
||||
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
|
||||
|
||||
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: mockResponseInput.surveyId,
|
||||
responseId: responseId,
|
||||
data: mockResponseInput.data,
|
||||
variables: mockResponseInput.variables,
|
||||
language: mockResponseInput.language,
|
||||
responseFinished: mockResponseInput.finished,
|
||||
tx: mockTx,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: responseId,
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
surveyId,
|
||||
finished: false,
|
||||
data: { question1: "answer1" },
|
||||
meta: { source: "web" },
|
||||
ttc: { question1: 1000 },
|
||||
variables: {},
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
language: null,
|
||||
displayId: null,
|
||||
contact: null,
|
||||
tags: [],
|
||||
quotaFull: mockQuotaFull,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return response with quotaFull when quota is exceeded with continueSurvey action", async () => {
|
||||
const mockQuotaFull: TSurveyQuota = {
|
||||
id: "quota-456",
|
||||
name: "Continue Test Quota",
|
||||
limit: 50,
|
||||
action: "continueSurvey",
|
||||
endingCardId: null,
|
||||
surveyId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
logic: {
|
||||
connector: "or",
|
||||
conditions: [],
|
||||
},
|
||||
countPartialSubmissions: false,
|
||||
};
|
||||
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: mockQuotaFull,
|
||||
});
|
||||
|
||||
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: responseId,
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
surveyId,
|
||||
finished: false,
|
||||
data: { question1: "answer1" },
|
||||
meta: { source: "web" },
|
||||
ttc: { question1: 1000 },
|
||||
variables: {},
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
language: null,
|
||||
displayId: null,
|
||||
contact: null,
|
||||
tags: [],
|
||||
quotaFull: mockQuotaFull,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import "server-only";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getContactByUserId } from "./contact";
|
||||
@@ -55,25 +53,39 @@ export const responseSelection = {
|
||||
},
|
||||
} satisfies Prisma.ResponseSelect;
|
||||
|
||||
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
|
||||
export const createResponseWithQuotaEvaluation = async (
|
||||
responseInput: TResponseInput
|
||||
): Promise<TResponseWithQuotaFull> => {
|
||||
const txResponse = await prisma.$transaction(async (tx) => {
|
||||
const response = await createResponse(responseInput, tx);
|
||||
|
||||
const quotaResult = await evaluateResponseQuotas({
|
||||
surveyId: responseInput.surveyId,
|
||||
responseId: response.id,
|
||||
data: responseInput.data,
|
||||
variables: responseInput.variables,
|
||||
language: responseInput.language,
|
||||
responseFinished: response.finished,
|
||||
tx,
|
||||
});
|
||||
|
||||
return {
|
||||
...response,
|
||||
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
|
||||
};
|
||||
});
|
||||
|
||||
return txResponse;
|
||||
};
|
||||
|
||||
export const createResponse = async (
|
||||
responseInput: TResponseInput,
|
||||
tx: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const {
|
||||
environmentId,
|
||||
language,
|
||||
userId,
|
||||
surveyId,
|
||||
displayId,
|
||||
finished,
|
||||
data,
|
||||
meta,
|
||||
singleUseId,
|
||||
variables,
|
||||
ttc: initialTtc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
} = responseInput;
|
||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
try {
|
||||
let contact: { id: string; attributes: TContactAttributes } | null = null;
|
||||
@@ -89,38 +101,16 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
const prismaData: Prisma.ResponseCreateInput = {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
finished: finished,
|
||||
data: data,
|
||||
language: language,
|
||||
...(contact?.id && {
|
||||
contact: {
|
||||
connect: {
|
||||
id: contact.id,
|
||||
},
|
||||
},
|
||||
contactAttributes: contact.attributes,
|
||||
}),
|
||||
...(meta && ({ meta } as Prisma.JsonObject)),
|
||||
singleUseId,
|
||||
...(variables && { variables }),
|
||||
ttc: ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
|
||||
|
||||
const responsePrisma = await prisma.response.create({
|
||||
const prismaClient = tx ?? prisma;
|
||||
|
||||
const responsePrisma = await prismaClient.response.create({
|
||||
data: prismaData,
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
const response: TResponse = {
|
||||
const response = {
|
||||
...responsePrisma,
|
||||
contact: contact
|
||||
? {
|
||||
@@ -131,28 +121,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: responsesLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Log error but do not throw
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
}
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,14 +6,16 @@ import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { headers } from "next/headers";
|
||||
import { NextRequest } from "next/server";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { createResponse } from "./lib/response";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { createResponseWithQuotaEvaluation } from "./lib/response";
|
||||
|
||||
interface Context {
|
||||
params: Promise<{
|
||||
@@ -121,7 +123,7 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
let response: TResponseWithQuotaFull;
|
||||
try {
|
||||
const meta: TResponseInput["meta"] = {
|
||||
source: responseInputData?.meta?.source,
|
||||
@@ -135,7 +137,7 @@ export const POST = withV1ApiWrapper({
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
response = await createResponse({
|
||||
response = await createResponseWithQuotaEvaluation({
|
||||
...responseInputData,
|
||||
meta,
|
||||
});
|
||||
@@ -152,29 +154,38 @@ export const POST = withV1ApiWrapper({
|
||||
}
|
||||
}
|
||||
|
||||
const { quotaFull, ...responseData } = response;
|
||||
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
surveyId: responseData.surveyId,
|
||||
response: responseData,
|
||||
});
|
||||
|
||||
if (responseInput.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
surveyId: responseData.surveyId,
|
||||
response: responseData,
|
||||
});
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
||||
surveyId: response.surveyId,
|
||||
surveyId: responseData.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
id: responseData.id,
|
||||
...quotaObj,
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ id: response.id }, true),
|
||||
response: responses.successResponse(responseDataWithQuota, true),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
48
apps/web/app/api/v1/lib/utils.ts
Normal file
48
apps/web/app/api/v1/lib/utils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
|
||||
export const buildPrismaResponseData = (
|
||||
responseInput: TResponseInput,
|
||||
contact: { id: string; attributes: TContactAttributes } | null,
|
||||
ttc: Record<string, number>
|
||||
): Prisma.ResponseCreateInput => {
|
||||
const {
|
||||
surveyId,
|
||||
displayId,
|
||||
finished,
|
||||
data,
|
||||
language,
|
||||
meta,
|
||||
singleUseId,
|
||||
variables,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
} = responseInput;
|
||||
|
||||
return {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
finished: finished,
|
||||
data: data,
|
||||
language: language,
|
||||
...(contact?.id && {
|
||||
contact: {
|
||||
connect: {
|
||||
id: contact.id,
|
||||
},
|
||||
},
|
||||
contactAttributes: contact.attributes,
|
||||
}),
|
||||
...(meta && ({ meta } as Prisma.JsonObject)),
|
||||
singleUseId,
|
||||
...(variables && { variables }),
|
||||
ttc: ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import { updateResponse } from "@/lib/response/service";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TResponse, TResponseInput } from "@formbricks/types/responses";
|
||||
import { updateResponseWithQuotaEvaluation } from "./response";
|
||||
|
||||
vi.mock("@/lib/response/service");
|
||||
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
||||
|
||||
const mockUpdateResponse = vi.mocked(updateResponse);
|
||||
const mockEvaluateResponseQuotas = vi.mocked(evaluateResponseQuotas);
|
||||
|
||||
type MockTx = {
|
||||
response: {
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
let mockTx: MockTx;
|
||||
|
||||
describe("updateResponseWithQuotaEvaluation", () => {
|
||||
const mockResponseId = "response123";
|
||||
const mockResponseInput: Partial<TResponseInput> = {
|
||||
data: { question1: "answer1" },
|
||||
finished: true,
|
||||
language: "en",
|
||||
};
|
||||
|
||||
const mockResponse: TResponse = {
|
||||
id: "response123",
|
||||
surveyId: "survey123",
|
||||
finished: true,
|
||||
data: { question1: "answer1" },
|
||||
meta: {},
|
||||
ttc: {},
|
||||
variables: { var1: "value1" },
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
language: "en",
|
||||
displayId: null,
|
||||
endingId: null,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
contact: {
|
||||
id: "contact123",
|
||||
userId: "user123",
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
id: "tag123",
|
||||
name: "important",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
environmentId: "env123",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockRefreshedResponse = {
|
||||
id: "response123",
|
||||
surveyId: "survey123",
|
||||
finished: true,
|
||||
data: { question1: "updated_answer" },
|
||||
meta: {},
|
||||
ttc: {},
|
||||
variables: { var1: "updated_value" },
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
language: "en",
|
||||
displayId: null,
|
||||
endingId: null,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
contactId: "contact123",
|
||||
contact: mockResponse.contact,
|
||||
tags: mockResponse.tags,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTx = {
|
||||
response: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
|
||||
});
|
||||
|
||||
test("should return original response when quota doesn't end survey", async () => {
|
||||
mockUpdateResponse.mockResolvedValue(mockResponse);
|
||||
mockEvaluateResponseQuotas.mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
});
|
||||
|
||||
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
|
||||
|
||||
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
|
||||
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: mockResponse.surveyId,
|
||||
responseId: mockResponse.id,
|
||||
data: mockResponse.data,
|
||||
variables: mockResponse.variables,
|
||||
language: mockResponse.language,
|
||||
responseFinished: mockResponse.finished,
|
||||
tx: mockTx,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test("should return refreshed response when quota ends survey and refreshedResponse exists", async () => {
|
||||
mockUpdateResponse.mockResolvedValue(mockResponse);
|
||||
mockEvaluateResponseQuotas.mockResolvedValue({
|
||||
shouldEndSurvey: true,
|
||||
refreshedResponse: mockRefreshedResponse,
|
||||
});
|
||||
|
||||
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
|
||||
|
||||
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
|
||||
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: mockResponse.surveyId,
|
||||
responseId: mockResponse.id,
|
||||
data: mockResponse.data,
|
||||
variables: mockResponse.variables,
|
||||
language: mockResponse.language,
|
||||
responseFinished: mockResponse.finished,
|
||||
tx: mockTx,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...mockRefreshedResponse,
|
||||
tags: mockResponse.tags,
|
||||
contact: mockResponse.contact,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return original response when quota ends survey but no refreshedResponse", async () => {
|
||||
mockUpdateResponse.mockResolvedValue(mockResponse);
|
||||
mockEvaluateResponseQuotas.mockResolvedValue({
|
||||
shouldEndSurvey: true,
|
||||
refreshedResponse: null,
|
||||
});
|
||||
|
||||
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
|
||||
|
||||
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
|
||||
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: mockResponse.surveyId,
|
||||
responseId: mockResponse.id,
|
||||
data: mockResponse.data,
|
||||
variables: mockResponse.variables,
|
||||
language: mockResponse.language,
|
||||
responseFinished: mockResponse.finished,
|
||||
tx: mockTx,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test("should return original response when quota ends survey but refreshedResponse is undefined", async () => {
|
||||
mockUpdateResponse.mockResolvedValue(mockResponse);
|
||||
mockEvaluateResponseQuotas.mockResolvedValue({
|
||||
shouldEndSurvey: true,
|
||||
refreshedResponse: undefined,
|
||||
});
|
||||
|
||||
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
|
||||
|
||||
expect(mockUpdateResponse).toHaveBeenCalledWith(mockResponseId, mockResponseInput, mockTx);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test("should use default language when response language is null", async () => {
|
||||
const responseWithNullLanguage = { ...mockResponse, language: null };
|
||||
mockUpdateResponse.mockResolvedValue(responseWithNullLanguage);
|
||||
mockEvaluateResponseQuotas.mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
});
|
||||
|
||||
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
|
||||
|
||||
expect(mockEvaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: responseWithNullLanguage.surveyId,
|
||||
responseId: responseWithNullLanguage.id,
|
||||
data: responseWithNullLanguage.data,
|
||||
variables: responseWithNullLanguage.variables,
|
||||
language: "default",
|
||||
responseFinished: responseWithNullLanguage.finished,
|
||||
tx: mockTx,
|
||||
});
|
||||
|
||||
expect(result).toEqual(responseWithNullLanguage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
import { updateResponse } from "@/lib/response/service";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TResponse, TResponseInput } from "@formbricks/types/responses";
|
||||
|
||||
export const updateResponseWithQuotaEvaluation = async (
|
||||
responseId: string,
|
||||
responseInput: Partial<TResponseInput>
|
||||
): Promise<TResponse> => {
|
||||
const txResponse = await prisma.$transaction(async (tx) => {
|
||||
const response = await updateResponse(responseId, responseInput, tx);
|
||||
|
||||
const quotaResult = await evaluateResponseQuotas({
|
||||
surveyId: response.surveyId,
|
||||
responseId: response.id,
|
||||
data: response.data,
|
||||
variables: response.variables,
|
||||
language: response.language || "default",
|
||||
responseFinished: response.finished,
|
||||
tx,
|
||||
});
|
||||
|
||||
if (quotaResult.shouldEndSurvey && quotaResult.refreshedResponse) {
|
||||
return {
|
||||
...quotaResult.refreshedResponse,
|
||||
tags: response.tags,
|
||||
contact: response.contact,
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
return txResponse;
|
||||
};
|
||||
@@ -3,12 +3,13 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
|
||||
import { deleteResponse, getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||
|
||||
async function fetchAndAuthorizeResponse(
|
||||
responseId: string,
|
||||
@@ -148,7 +149,7 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await updateResponse(params.responseId, inputValidation.data);
|
||||
const updated = await updateResponseWithQuotaEvaluation(params.responseId, inputValidation.data);
|
||||
auditLog.newObject = updated;
|
||||
return {
|
||||
response: responses.successResponse(updated),
|
||||
|
||||
@@ -134,9 +134,23 @@ vi.mock("@formbricks/database", () => ({
|
||||
vi.mock("@formbricks/logger");
|
||||
vi.mock("./contact");
|
||||
|
||||
type MockTx = {
|
||||
response: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
let mockTx: MockTx;
|
||||
|
||||
describe("Response Lib Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockTx = {
|
||||
response: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
|
||||
});
|
||||
|
||||
describe("createResponse", () => {
|
||||
@@ -145,16 +159,16 @@ describe("Response Lib Tests", () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(prisma.response.create).mockResolvedValue({
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue({
|
||||
...mockResponsePrisma,
|
||||
});
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
|
||||
const response = await createResponse(mockResponseInputWithUserId);
|
||||
const response = await createResponse(mockResponseInputWithUserId, mockTx);
|
||||
|
||||
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
|
||||
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, mockUserId);
|
||||
expect(prisma.response.create).toHaveBeenCalledWith(
|
||||
expect(mockTx.response.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
contact: { connect: { id: mockContact.id } },
|
||||
@@ -167,9 +181,9 @@ describe("Response Lib Tests", () => {
|
||||
|
||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
|
||||
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
|
||||
expect(prisma.response.create).not.toHaveBeenCalled();
|
||||
expect(mockTx.response.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle PrismaClientKnownRequestError", async () => {
|
||||
@@ -178,9 +192,9 @@ describe("Response Lib Tests", () => {
|
||||
clientVersion: "2.0",
|
||||
});
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
|
||||
});
|
||||
|
||||
@@ -190,18 +204,18 @@ describe("Response Lib Tests", () => {
|
||||
clientVersion: "2.0",
|
||||
});
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow("Display ID does not exist");
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(DatabaseError);
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow("Display ID does not exist");
|
||||
});
|
||||
|
||||
test("should handle generic errors", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(genericError);
|
||||
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
describe("Cloud specific tests", () => {
|
||||
@@ -214,10 +228,10 @@ describe("Response Lib Tests", () => {
|
||||
} as any;
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
||||
|
||||
await createResponse(mockResponseInput);
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
@@ -231,10 +245,10 @@ describe("Response Lib Tests", () => {
|
||||
} as any;
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached
|
||||
|
||||
await createResponse(mockResponseInput);
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
@@ -249,12 +263,12 @@ describe("Response Lib Tests", () => {
|
||||
const posthogError = new Error("Posthog error");
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
// Expecting successful response creation despite PostHog error
|
||||
const response = await createResponse(mockResponseInput);
|
||||
const response = await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import "server-only";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseContact } from "@/lib/response/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -59,25 +58,44 @@ export const responseSelection = {
|
||||
},
|
||||
} satisfies Prisma.ResponseSelect;
|
||||
|
||||
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
|
||||
export const createResponseWithQuotaEvaluation = async (
|
||||
responseInput: TResponseInput
|
||||
): Promise<TResponse> => {
|
||||
const txResponse = await prisma.$transaction(async (tx) => {
|
||||
const response = await createResponse(responseInput, tx);
|
||||
|
||||
const quotaResult = await evaluateResponseQuotas({
|
||||
surveyId: responseInput.surveyId,
|
||||
responseId: response.id,
|
||||
data: responseInput.data,
|
||||
variables: responseInput.variables,
|
||||
language: responseInput.language,
|
||||
responseFinished: response.finished,
|
||||
tx,
|
||||
});
|
||||
|
||||
if (quotaResult.shouldEndSurvey && quotaResult.refreshedResponse) {
|
||||
return {
|
||||
...quotaResult.refreshedResponse,
|
||||
tags: response.tags,
|
||||
contact: response.contact,
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
return txResponse;
|
||||
};
|
||||
|
||||
export const createResponse = async (
|
||||
responseInput: TResponseInput,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const {
|
||||
environmentId,
|
||||
language,
|
||||
userId,
|
||||
surveyId,
|
||||
displayId,
|
||||
finished,
|
||||
data,
|
||||
meta,
|
||||
singleUseId,
|
||||
variables,
|
||||
ttc: initialTtc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
} = responseInput;
|
||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
try {
|
||||
let contact: { id: string; attributes: TContactAttributes } | null = null;
|
||||
@@ -93,33 +111,11 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
const prismaData: Prisma.ResponseCreateInput = {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
finished: finished,
|
||||
data: data,
|
||||
language: language,
|
||||
...(contact?.id && {
|
||||
contact: {
|
||||
connect: {
|
||||
id: contact.id,
|
||||
},
|
||||
},
|
||||
contactAttributes: contact.attributes,
|
||||
}),
|
||||
...(meta && ({ meta } as Prisma.JsonObject)),
|
||||
singleUseId,
|
||||
...(variables && { variables }),
|
||||
ttc: ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
|
||||
|
||||
const responsePrisma = await prisma.response.create({
|
||||
const prismaClient = tx ?? prisma;
|
||||
|
||||
const responsePrisma = await prismaClient.response.create({
|
||||
data: prismaData,
|
||||
select: responseSelection,
|
||||
});
|
||||
@@ -135,28 +131,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: responsesLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Log error but do not throw
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
}
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
@@ -211,3 +186,47 @@ export const getResponsesByEnvironmentIds = reactCache(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getResponses = reactCache(
|
||||
async (surveyId: string, limit?: number, offset?: number): Promise<TResponse[]> => {
|
||||
validateInputs([surveyId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
|
||||
limit = limit ?? RESPONSES_PER_PAGE;
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) return [];
|
||||
try {
|
||||
const responses = await prisma.response.findMany({
|
||||
where: { surveyId },
|
||||
select: responseSelection,
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{
|
||||
id: "desc", // Secondary sort by ID for consistent pagination
|
||||
},
|
||||
],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
...responsePrisma,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return transformedResponses;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,14 +2,17 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
|
||||
import {
|
||||
createResponseWithQuotaEvaluation,
|
||||
getResponses,
|
||||
getResponsesByEnvironmentIds,
|
||||
} from "./lib/response";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
@@ -150,7 +153,7 @@ export const POST = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createResponse(responseInput);
|
||||
const response = await createResponseWithQuotaEvaluation(responseInput);
|
||||
auditLog.targetId = response.id;
|
||||
auditLog.newObject = response;
|
||||
return {
|
||||
|
||||
@@ -7,16 +7,18 @@ import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getContact } from "./contact";
|
||||
import { createResponse } from "./response";
|
||||
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
||||
|
||||
let mockIsFormbricksCloud = false;
|
||||
|
||||
@@ -51,6 +53,7 @@ vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/response/utils");
|
||||
vi.mock("@/lib/telemetry");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
@@ -67,7 +70,6 @@ const organizationId = "test-organization-id";
|
||||
const responseId = "test-response-id";
|
||||
const contactId = "test-contact-id";
|
||||
const userId = "test-user-id";
|
||||
const displayId = "test-display-id";
|
||||
|
||||
const mockOrganization = {
|
||||
id: organizationId,
|
||||
@@ -122,13 +124,44 @@ const expectedResponse: TResponse = {
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const mockQuota: TSurveyQuota = {
|
||||
id: "quota-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId,
|
||||
name: "Test Quota",
|
||||
limit: 100,
|
||||
logic: {
|
||||
connector: "and",
|
||||
conditions: [],
|
||||
},
|
||||
action: "endSurvey",
|
||||
endingCardId: "ending-card-id",
|
||||
countPartialSubmissions: false,
|
||||
};
|
||||
|
||||
type MockTx = {
|
||||
response: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
let mockTx: MockTx;
|
||||
|
||||
describe("createResponse V2", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockTx = {
|
||||
response: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
|
||||
|
||||
vi.mocked(validateInputs).mockImplementation(() => {});
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
|
||||
vi.mocked(getContact).mockResolvedValue(mockContact);
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma as any);
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma as any);
|
||||
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ({
|
||||
...ttc,
|
||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||
@@ -136,6 +169,10 @@ describe("createResponse V2", () => {
|
||||
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -144,7 +181,7 @@ describe("createResponse V2", () => {
|
||||
|
||||
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
await createResponse(mockResponseInput);
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -153,7 +190,7 @@ describe("createResponse V2", () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
|
||||
await createResponse(mockResponseInput);
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
@@ -170,7 +207,7 @@ describe("createResponse V2", () => {
|
||||
|
||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
@@ -178,14 +215,14 @@ describe("createResponse V2", () => {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw original error on other errors", async () => {
|
||||
const genericError = new Error("Generic database error");
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(genericError);
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
||||
@@ -194,7 +231,7 @@ describe("createResponse V2", () => {
|
||||
const posthogError = new Error("PostHog error");
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
await createResponse(mockResponseInput); // Should not throw
|
||||
await createResponse(mockResponseInput, mockTx); // Should not throw
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
@@ -209,9 +246,71 @@ describe("createResponse V2", () => {
|
||||
tags: [{ tag: mockTag }],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(prismaResponseWithTags as any);
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(prismaResponseWithTags as any);
|
||||
|
||||
const result = await createResponse(mockResponseInput);
|
||||
const result = await createResponse(mockResponseInput, mockTx);
|
||||
expect(result.tags).toEqual([mockTag]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResponseWithQuotaEvaluation V2", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockTx = {
|
||||
response: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
prisma.$transaction = vi.fn(async (cb: any) => cb(mockTx));
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
|
||||
vi.mocked(getContact).mockResolvedValue(mockContact);
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma as any);
|
||||
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ({
|
||||
...ttc,
|
||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||
}));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should create response and return it without quotaFull when no quota is full", async () => {
|
||||
const result = await createResponseWithQuotaEvaluation(mockResponseInput);
|
||||
|
||||
expect(result).toEqual(expectedResponse);
|
||||
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: mockResponseInput.surveyId,
|
||||
responseId: expectedResponse.id,
|
||||
data: mockResponseInput.data,
|
||||
variables: mockResponseInput.variables,
|
||||
language: mockResponseInput.language,
|
||||
responseFinished: expectedResponse.finished,
|
||||
tx: mockTx,
|
||||
});
|
||||
});
|
||||
|
||||
test("should include quotaFull in response when quota evaluation returns a full quota", async () => {
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: mockQuota,
|
||||
});
|
||||
|
||||
const result: TResponseWithQuotaFull = await createResponseWithQuotaEvaluation(mockResponseInput);
|
||||
|
||||
expect(result).toEqual({
|
||||
...expectedResponse,
|
||||
quotaFull: mockQuota,
|
||||
});
|
||||
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: mockResponseInput.surveyId,
|
||||
responseId: expectedResponse.id,
|
||||
data: mockResponseInput.data,
|
||||
variables: mockResponseInput.variables,
|
||||
language: mockResponseInput.language,
|
||||
responseFinished: expectedResponse.finished,
|
||||
tx: mockTx,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +1,100 @@
|
||||
import "server-only";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getContact } from "./contact";
|
||||
|
||||
export const createResponse = async (responseInput: TResponseInputV2): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
export const createResponseWithQuotaEvaluation = async (
|
||||
responseInput: TResponseInputV2
|
||||
): Promise<TResponseWithQuotaFull> => {
|
||||
const txResponse = await prisma.$transaction(async (tx) => {
|
||||
const response = await createResponse(responseInput, tx);
|
||||
|
||||
const quotaResult = await evaluateResponseQuotas({
|
||||
surveyId: responseInput.surveyId,
|
||||
responseId: response.id,
|
||||
data: responseInput.data,
|
||||
variables: responseInput.variables,
|
||||
language: responseInput.language,
|
||||
responseFinished: response.finished,
|
||||
tx,
|
||||
});
|
||||
|
||||
return {
|
||||
...response,
|
||||
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
|
||||
};
|
||||
});
|
||||
|
||||
return txResponse;
|
||||
};
|
||||
|
||||
const buildPrismaResponseData = (
|
||||
responseInput: TResponseInputV2,
|
||||
contact: { id: string; attributes: TContactAttributes } | null,
|
||||
ttc: Record<string, number>
|
||||
): Prisma.ResponseCreateInput => {
|
||||
const {
|
||||
environmentId,
|
||||
language,
|
||||
contactId,
|
||||
surveyId,
|
||||
displayId,
|
||||
endingId,
|
||||
finished,
|
||||
data,
|
||||
language,
|
||||
meta,
|
||||
singleUseId,
|
||||
variables,
|
||||
ttc: initialTtc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
} = responseInput;
|
||||
|
||||
return {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
finished: finished,
|
||||
data: data,
|
||||
language: language,
|
||||
...(contact?.id && {
|
||||
contact: {
|
||||
connect: {
|
||||
id: contact.id,
|
||||
},
|
||||
},
|
||||
contactAttributes: contact.attributes,
|
||||
}),
|
||||
...(meta && ({ meta } as Prisma.JsonObject)),
|
||||
singleUseId,
|
||||
...(variables && { variables }),
|
||||
ttc: ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const createResponse = async (
|
||||
responseInput: TResponseInputV2,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
try {
|
||||
let contact: { id: string; attributes: TContactAttributes } | null = null;
|
||||
|
||||
@@ -54,34 +109,11 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
const prismaData: Prisma.ResponseCreateInput = {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
finished,
|
||||
endingId,
|
||||
data: data,
|
||||
language: language,
|
||||
...(contact?.id && {
|
||||
contact: {
|
||||
connect: {
|
||||
id: contact.id,
|
||||
},
|
||||
},
|
||||
contactAttributes: contact.attributes,
|
||||
}),
|
||||
...(meta && ({ meta } as Prisma.JsonObject)),
|
||||
singleUseId,
|
||||
...(variables && { variables }),
|
||||
ttc: ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
|
||||
|
||||
const responsePrisma = await prisma.response.create({
|
||||
const prismaClient = tx ?? prisma;
|
||||
|
||||
const responsePrisma = await prismaClient.response.create({
|
||||
data: prismaData,
|
||||
select: responseSelection,
|
||||
});
|
||||
@@ -97,28 +129,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: responsesLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Log error but do not throw
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
}
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user