Compare commits

..

4 Commits

Author SHA1 Message Date
Piyush Gupta
fb17c22fc2 fix: lock file 2025-07-30 10:31:43 +05:30
Piyush Gupta
3d52f7b63b Merge branch 'main' of https://github.com/formbricks/formbricks into fix/form-data-dep 2025-07-30 10:31:30 +05:30
pandeymangg
17222a59ef moves the override block to repo root 2025-07-29 16:10:52 +05:30
pandeymangg
1ab856d2f0 fix: adds override rule for form-data package 2025-07-29 15:46:03 +05:30
221 changed files with 4180 additions and 12663 deletions

View File

@@ -90,7 +90,7 @@ When testing hooks that use React Context:
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [],
responseStatus: "all",
onlyComplete: false,
},
setSelectedFilter: vi.fn(),
selectedOptions: {

View File

@@ -62,12 +62,10 @@ runs:
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
shell: bash
- run: |

View File

@@ -1,23 +1,23 @@
name: "Upload Sentry Sourcemaps"
description: "Extract sourcemaps from Docker image and upload to Sentry"
name: 'Upload Sentry Sourcemaps'
description: 'Extract sourcemaps from Docker image and upload to Sentry'
inputs:
docker_image:
description: "Docker image to extract sourcemaps from"
description: 'Docker image to extract sourcemaps from'
required: true
release_version:
description: "Sentry release version (e.g., v1.2.3)"
description: 'Sentry release version (e.g., v1.2.3)'
required: true
sentry_auth_token:
description: "Sentry authentication token"
description: 'Sentry authentication token'
required: true
environment:
description: "Sentry environment (e.g., production, staging)"
description: 'Sentry environment (e.g., production, staging)'
required: false
default: "staging"
default: 'staging'
runs:
using: "composite"
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -26,12 +26,13 @@ runs:
- name: Validate Sentry auth token
shell: bash
env:
SENTRY_TOKEN: ${{ inputs.sentry_auth_token }}
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
@@ -56,23 +57,13 @@ runs:
- 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"
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
echo "Container created with ID: $CONTAINER_ID"
# Set up cleanup function to ensure container is removed on script exit
@@ -91,7 +82,7 @@ runs:
# 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
@@ -122,7 +113,7 @@ runs:
with:
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: "./extracted-next/"
sourcemaps: './extracted-next/'
- name: Clean up extracted files
shell: bash

View File

@@ -0,0 +1,82 @@
name: "Apply issue labels to PR"
on:
pull_request_target:
types:
- opened
permissions:
contents: read
jobs:
label_on_pr:
runs-on: ubuntu-latest
permissions:
contents: none
issues: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Apply labels from linked issue to PR
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
async function getLinkedIssues(owner, repo, prNumber) {
const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
closingIssuesReferences(first: 10) {
nodes {
number
labels(first: 10) {
nodes {
name
}
}
}
}
}
}
}`;
const variables = {
owner: owner,
repo: repo,
prNumber: prNumber,
};
const result = await github.graphql(query, variables);
return result.repository.pullRequest.closingIssuesReferences.nodes;
}
const pr = context.payload.pull_request;
const linkedIssues = await getLinkedIssues(
context.repo.owner,
context.repo.repo,
pr.number
);
const labelsToAdd = new Set();
for (const issue of linkedIssues) {
if (issue.labels && issue.labels.nodes) {
for (const label of issue.labels.nodes) {
labelsToAdd.add(label.name);
}
}
}
if (labelsToAdd.size) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: Array.from(labelsToAdd),
});
}

View File

@@ -6,14 +6,12 @@ on:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read

27
.github/workflows/dependency-review.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0

View File

@@ -43,16 +43,11 @@ jobs:
helmfile-deploy:
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
uses: actions/checkout@v4.2.2
- name: Tailscale
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
@@ -71,7 +66,7 @@ jobs:
env:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Production
if: inputs.ENVIRONMENT == 'production'
env:
@@ -89,7 +84,7 @@ jobs:
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Staging
if: inputs.ENVIRONMENT == 'staging'
env:
@@ -111,16 +106,15 @@ jobs:
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
run: |
# Set hostname based on environment
if [[ "$ENVIRONMENT" == "production" ]]; then
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"
fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
# Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF

View File

@@ -39,68 +39,42 @@ jobs:
--health-retries 5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
uses: actions/checkout@v4.2.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@v3
- name: Build Docker Image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
env:
GITHUB_SHA: ${{ github.sha }}
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ env.GITHUB_SHA }}
tags: formbricks-test:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Verify and Initialize PostgreSQL
- name: Verify PostgreSQL Connection
run: |
echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql with timeout and proper error handling
echo "Testing PostgreSQL connection with 30 second timeout..."
if timeout 30 bash -c 'until PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" >/dev/null 2>&1; do
echo "Waiting for PostgreSQL to be ready..."
sleep 2
done'; then
echo "✅ PostgreSQL connection successful"
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "SELECT version();"
# Enable necessary extensions that might be required by migrations
echo "Enabling required PostgreSQL extensions..."
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "CREATE EXTENSION IF NOT EXISTS vector;" || echo "Vector extension already exists or not available"
else
echo "❌ PostgreSQL connection failed after 30 seconds"
exit 1
fi
# Test connection using psql
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
# Show network configuration
echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Test Docker Image with Health Check
shell: bash
env:
GITHUB_SHA: ${{ github.sha }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
run: |
echo "🧪 Testing if the Docker image starts correctly..."
@@ -112,12 +86,29 @@ jobs:
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-d "formbricks-test:$GITHUB_SHA"
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."
MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes
# Give it more time to start up
echo "Waiting 45 seconds for application to start..."
sleep 45
# Check if the container is running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
echo "❌ Container failed to start properly!"
docker logs formbricks-test
exit 1
else
echo "✅ Container started successfully!"
fi
# Try connecting to PostgreSQL from inside the container
echo "Testing PostgreSQL connection from inside container..."
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
# Try to access the health endpoint
echo "🏥 Testing /health endpoint..."
MAX_RETRIES=10
RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false
@@ -125,32 +116,38 @@ jobs:
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
# Check if container is still running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then
echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!"
echo "📋 Container logs:"
docker logs formbricks-test
exit 1
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Show container logs before each attempt to help debugging
if [ $RETRY_COUNT -gt 1 ]; then
echo "📋 Current container logs:"
docker logs --tail 20 formbricks-test
fi
# Show progress and diagnostic info every 12 attempts (1 minute intervals)
if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then
echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..."
echo "📋 Recent container logs:"
docker logs --tail 10 formbricks-test
# Get detailed curl output for debugging
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
CURL_EXIT_CODE=$?
echo "Curl exit code: $CURL_EXIT_CODE"
echo "Curl output: $HTTP_OUTPUT"
if [ $CURL_EXIT_CODE -eq 0 ]; then
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
echo "Status code detected: $STATUS_CODE"
if [ "$STATUS_CODE" = "200" ]; then
echo "✅ Health check successful!"
HEALTH_CHECK_SUCCESS=true
break
else
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
fi
else
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
fi
# Try health endpoint with shorter timeout for faster polling
# Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx)
if curl -f -s -m 10 http://localhost:3000/health >/dev/null 2>&1; then
echo "✅ Health check successful after $((RETRY_COUNT * 5)) seconds!"
HEALTH_CHECK_SUCCESS=true
break
fi
# Wait 5 seconds before next attempt
sleep 5
echo "Waiting 15 seconds before next attempt..."
sleep 15
done
# Show full container logs for debugging
@@ -163,7 +160,7 @@ jobs:
# Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)"
echo "❌ Health check failed after $MAX_RETRIES attempts"
exit 1
fi

View File

@@ -47,13 +47,8 @@ jobs:
- 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
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0

View File

@@ -41,16 +41,14 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Generate SemVer version from branch or tag
id: generate_version
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type from environment variables
# Get reference name and type
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
@@ -174,13 +172,8 @@ jobs:
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
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0

View File

@@ -9,7 +9,7 @@ on:
workflow_call:
inputs:
IS_PRERELEASE:
description: "Whether this is a prerelease (affects latest tag)"
description: 'Whether this is a prerelease (affects latest tag)'
required: false
type: boolean
default: false
@@ -26,9 +26,6 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
@@ -55,20 +52,9 @@ jobs:
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG="$GITHUB_REF"
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
# Validate the extracted tag format
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Original ref: $GITHUB_REF"
echo "Extracted tag: $TAG"
exit 1
fi
# Safely add to environment variables
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"

View File

@@ -26,23 +26,8 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate input version
env:
INPUT_VERSION: ${{ inputs.VERSION }}
run: |
set -euo pipefail
# Validate input version format (expects clean semver without 'v' prefix)
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Expected: clean version without 'v' prefix"
echo "Provided: $INPUT_VERSION"
exit 1
fi
# Store validated version in environment variable
echo "VERSION<<EOF" >> $GITHUB_ENV
echo "$INPUT_VERSION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Extract release version
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
@@ -50,18 +35,15 @@ jobs:
version: latest
- name: Log in to GitHub Container Registry
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
run: |
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
- name: Package Helm chart
run: |
@@ -69,4 +51,4 @@ jobs:
- name: Push Helm chart to GitHub Container Registry
run: |
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts

81
.github/workflows/scorecard.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: "17 17 * * 6"
push:
branches: ["main"]
workflow_dispatch:
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Add this permission
actions: write # Required for artifact upload
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: sarif
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
sarif_file: results.sarif

View File

@@ -14,14 +14,12 @@ on:
paths:
- "infra/terraform/**"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -35,7 +33,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}

View File

@@ -27,18 +27,10 @@ jobs:
- name: Get source branch name
id: branch-name
env:
RAW_BRANCH: ${{ github.head_ref }}
run: |
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
RAW_BRANCH="${{ github.head_ref }}"
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
# Additional validation - ensure branch name is not empty after sanitization
if [[ -z "$SOURCE_BRANCH" ]]; then
echo "❌ Error: Branch name is empty after sanitization"
echo "Original branch: $RAW_BRANCH"
exit 1
fi
# Safely add to environment variables using GitHub's recommended method
# This prevents environment variable injection attacks

View File

@@ -23,26 +23,24 @@ 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
uses: actions/checkout@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 }}
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -0,0 +1,32 @@
name: "Welcome new contributors"
on:
issues:
types: opened
pull_request_target:
types: opened
permissions:
pull-requests: write
issues: write
jobs:
welcome-message:
name: Welcoming New Users
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event.action == 'opened'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you so much for making your first Pull Request and taking the time to improve Formbricks! 🚀🙏❤️
Feel free to join the conversation on [Github Discussions](https://github.com/formbricks/formbricks/discussions) if you need any help or have any questions. 😊
issue-message: |
Thank you for opening your first issue! 🙏❤️ One of our team members will review it and get back to you as soon as it possible. 😊

View File

@@ -80,25 +80,25 @@ export const LandingSidebar = ({
<DropdownMenuTrigger
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")}>
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<>
<div className="grow overflow-hidden">
<div>
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 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">
className="max-w-28 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")} />
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
</>
</div>
</DropdownMenuTrigger>

View File

@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
industry: "ind",
defaultBrandColor: "#fff",
organizationTeams: [],
isAccessControlAllowed: false,
canDoRoleManagement: false,
userProjectsCount: 0,
} as any;

View File

@@ -42,7 +42,7 @@ interface ProjectSettingsProps {
industry: TProjectConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
canDoRoleManagement: boolean;
userProjectsCount: number;
}
@@ -53,7 +53,7 @@ export const ProjectSettings = ({
industry,
defaultBrandColor,
organizationTeams,
isAccessControlAllowed = false,
canDoRoleManagement = false,
userProjectsCount,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -174,7 +174,7 @@ export const ProjectSettings = ({
)}
/>
{isAccessControlAllowed && userProjectsCount > 0 && (
{canDoRoleManagement && userProjectsCount > 0 && (
<FormField
control={form.control}
name="teamIds"

View File

@@ -1,6 +1,6 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getUserProjects } from "@/lib/project/service";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
// Mocks before component import
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
@@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
});
@@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);
@@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);

View File

@@ -2,7 +2,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getUserProjects } from "@/lib/project/service";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
@@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
@@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
canDoRoleManagement={canDoRoleManagement}
userProjectsCount={projects.length}
/>
{projects.length >= 1 && (

View File

@@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod";
@@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!isAccessControlAllowed) {
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}

View File

@@ -24,17 +24,14 @@ export const ActionClassesTable = ({
otherEnvActionClasses,
otherEnvironment,
}: ActionClassesTableProps) => {
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
const handleOpenActionDetailModalClick = (
e: React.MouseEvent<HTMLButtonElement>,
actionClass: TActionClass
) => {
const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => {
e.preventDefault();
setActiveActionClass(actionClass);
setIsActionDetailModalOpen(true);
setActionDetailModalOpen(true);
};
return (
@@ -45,7 +42,7 @@ export const ActionClassesTable = ({
{actionClasses.length > 0 ? (
actionClasses.map((actionClass, index) => (
<button
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
onClick={(e) => {
handleOpenActionDetailModalClick(e, actionClass);
}}
className="w-full"
@@ -66,7 +63,7 @@ export const ActionClassesTable = ({
environmentId={environmentId}
environment={environment}
open={isActionDetailModalOpen}
setOpen={setIsActionDetailModalOpen}
setOpen={setActionDetailModalOpen}
actionClasses={actionClasses}
actionClass={activeActionClass}
isReadOnly={isReadOnly}

View File

@@ -70,13 +70,15 @@ export const ActionDetailModal = ({
};
return (
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={typeDescription()}
/>
<>
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={typeDescription()}
/>
</>
);
};

View File

@@ -11,21 +11,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
updateActionClassAction: vi.fn(),
}));
// Mock action utils
vi.mock("@/modules/survey/editor/lib/action-utils", () => ({
useActionClassKeys: vi.fn(() => ["existing-key"]),
createActionClassZodResolver: vi.fn(() => vi.fn()),
validatePermissions: vi.fn(),
}));
// Mock action builder
vi.mock("@/modules/survey/editor/lib/action-builder", () => ({
buildActionObject: vi.fn((data, environmentId, t) => ({
...data,
environmentId,
})),
}));
// Mock utils
vi.mock("@/app/lib/actionClass/actionClass", () => ({
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
@@ -39,7 +24,6 @@ vi.mock("@/modules/ui/components/button", () => ({
</button>
),
}));
vi.mock("@/modules/ui/components/code-action-form", () => ({
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="code-action-form" data-readonly={isReadOnly}>
@@ -47,7 +31,6 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
open ? (
@@ -60,26 +43,6 @@ vi.mock("@/modules/ui/components/delete-dialog", () => ({
</div>
) : null,
}));
vi.mock("@/modules/ui/components/action-name-description-fields", () => ({
ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => (
<div data-testid="action-name-description-fields">
<input
data-testid={`name-input-${nameInputId}`}
placeholder="environments.actions.eg_clicked_download"
disabled={isReadOnly}
defaultValue="Test Action"
/>
<input
data-testid={`description-input-${descriptionInputId}`}
placeholder="environments.actions.user_clicked_download_button"
disabled={isReadOnly}
defaultValue="Test Description"
/>
</div>
),
}));
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
@@ -93,23 +56,6 @@ vi.mock("lucide-react", () => ({
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
}));
// Mock react-hook-form
const mockHandleSubmit = vi.fn();
const mockForm = {
handleSubmit: mockHandleSubmit,
control: {},
formState: { errors: {} },
};
vi.mock("react-hook-form", async () => {
const actual = await vi.importActual("react-hook-form");
return {
...actual,
useForm: vi.fn(() => mockForm),
FormProvider: ({ children }: any) => <div>{children}</div>,
};
});
const mockSetOpen = vi.fn();
const mockActionClasses: TActionClass[] = [
{
@@ -142,7 +88,6 @@ const createMockActionClass = (id: string, type: TActionClassType, name: string)
describe("ActionSettingsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHandleSubmit.mockImplementation((fn) => fn);
});
afterEach(() => {
@@ -160,9 +105,13 @@ describe("ActionSettingsTab", () => {
/>
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
expect(
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
@@ -182,104 +131,18 @@ describe("ActionSettingsTab", () => {
/>
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
});
test("renders correctly for other action types (fallback)", () => {
const actionClass = {
...createMockActionClass("auto1", "noCode", "Auto Action"),
type: "automatic" as any,
};
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(
screen.getByText(
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
)
).toBeInTheDocument();
});
test("calls utility functions on initialization", async () => {
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
});
test("handles successful form submission", async () => {
const { updateActionClassAction } = await import(
"@/app/(app)/environments/[environmentId]/actions/actions"
);
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
vi.mocked(updateActionClassAction).mockResolvedValue({ data: {} } as any);
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
// Check that utility functions were called during component initialization
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
});
test("handles permission validation error", async () => {
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
vi.mocked(actionUtilsMock.validatePermissions).mockImplementation(() => {
throw new Error("Not authorized");
});
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
const submitButton = screen.getByRole("button", { name: "common.save_changes" });
mockHandleSubmit.mockImplementation((fn) => (e) => {
e.preventDefault();
return fn({ name: "Test", type: "noCode" });
});
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Not authorized");
});
});
test("handles successful deletion", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import(
@@ -346,16 +209,17 @@ describe("ActionSettingsTab", () => {
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={true}
isReadOnly={true} // Set to read-only
/>
);
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled();
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
});
test("prevents delete when read-only", async () => {
@@ -364,6 +228,7 @@ describe("ActionSettingsTab", () => {
"@/app/(app)/environments/[environmentId]/actions/actions"
);
// Render with isReadOnly=true, but simulate a delete attempt
render(
<ActionSettingsTab
actionClass={actionClass}
@@ -373,6 +238,12 @@ describe("ActionSettingsTab", () => {
/>
);
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
// This test primarily checks the logic within handleDeleteAction if it were called.
// A better approach might be to export handleDeleteAction for direct testing,
// but for now, we assume the UI prevents calling it.
// We can assert that the delete button isn't there to prevent the flow
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(deleteActionClassAction).not.toHaveBeenCalled();
});
@@ -391,19 +262,4 @@ describe("ActionSettingsTab", () => {
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
expect(docsLink).toHaveAttribute("target", "_blank");
});
test("uses correct input IDs for ActionNameDescriptionFields", () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
});
});

View File

@@ -4,17 +4,14 @@ import {
deleteActionClassAction,
updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/actions/actions";
import { buildActionObject } from "@/modules/survey/editor/lib/action-builder";
import {
createActionClassZodResolver,
useActionClassKeys,
validatePermissions,
} from "@/modules/survey/editor/lib/action-utils";
import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields";
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
import { Button } from "@/modules/ui/components/button";
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { TrashIcon } from "lucide-react";
import Link from "next/link";
@@ -22,7 +19,8 @@ import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import { z } from "zod";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
interface ActionSettingsTabProps {
actionClass: TActionClass;
@@ -50,51 +48,63 @@ export const ActionSettingsTab = ({
[actionClass.id, actionClasses]
);
const actionClassKeys = useActionClassKeys(actionClasses);
const form = useForm<TActionClassInput>({
defaultValues: {
...restActionClass,
},
resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t),
resolver: zodResolver(
ZActionClassInput.superRefine((data, ctx) => {
if (data.name && actionClassNames.includes(data.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
});
}
})
),
mode: "onChange",
});
const { handleSubmit, control } = form;
const renderActionForm = () => {
if (actionClass.type === "code") {
return (
<>
<CodeActionForm form={form} isReadOnly={true} />
<p className="text-sm text-slate-600">
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
</p>
</>
);
}
if (actionClass.type === "noCode") {
return <NoCodeActionForm form={form} isReadOnly={isReadOnly} />;
}
return (
<p className="text-sm text-slate-600">
{t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")}
</p>
);
};
const onSubmit = async (data: TActionClassInput) => {
try {
if (isReadOnly) {
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
}
setIsUpdatingAction(true);
validatePermissions(isReadOnly, t);
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
if (data.name && actionClassNames.includes(data.name)) {
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
}
if (
data.type === "noCode" &&
data.noCodeConfig?.type === "click" &&
data.noCodeConfig.elementSelector.cssSelector &&
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
) {
throw new Error(t("environments.actions.invalid_css_selector"));
}
const updatedData: TActionClassInput = {
...data,
...(data.type === "noCode" &&
data.noCodeConfig?.type === "click" && {
noCodeConfig: {
...data.noCodeConfig,
elementSelector: {
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
},
},
}),
};
await updateActionClassAction({
actionClassId: actionClass.id,
updatedAction: updatedAction,
updatedAction: updatedData,
});
setOpen(false);
router.refresh();
@@ -113,7 +123,7 @@ export const ActionSettingsTab = ({
router.refresh();
toast.success(t("environments.actions.action_deleted_successfully"));
setOpen(false);
} catch {
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDeletingAction(false);
@@ -125,14 +135,79 @@ export const ActionSettingsTab = ({
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
<ActionNameDescriptionFields
control={control}
isReadOnly={isReadOnly}
nameInputId="actionNameSettingsInput"
descriptionInputId="actionDescriptionSettingsInput"
/>
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<FormField
control={control}
name="name"
disabled={isReadOnly}
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel htmlFor="actionNameSettingsInput">
{actionClass.type === "noCode"
? t("environments.actions.what_did_your_user_do")
: t("environments.actions.display_name")}
</FormLabel>
{renderActionForm()}
<FormControl>
<Input
type="text"
id="actionNameSettingsInput"
{...field}
placeholder={t("environments.actions.eg_clicked_download")}
isInvalid={!!error?.message}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="col-span-1">
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="actionDescriptionSettingsInput">
{t("common.description")}
</FormLabel>
<FormControl>
<Input
type="text"
id="actionDescriptionSettingsInput"
{...field}
placeholder={t("environments.actions.user_clicked_download_button")}
value={field.value ?? ""}
disabled={isReadOnly}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
{actionClass.type === "code" ? (
<>
<CodeActionForm form={form} isReadOnly={true} />
<p className="text-sm text-slate-600">
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
</p>
</>
) : actionClass.type === "noCode" ? (
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
) : (
<p className="text-sm text-slate-600">
{t(
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
)}
</p>
)}
</div>
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">

View File

@@ -10,8 +10,8 @@ import {
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
@@ -53,7 +53,7 @@ vi.mock("@/lib/membership/utils", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
getAccessControlPermission: vi.fn(),
getRoleManagementPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
@@ -79,11 +79,11 @@ vi.mock("@/lib/constants", () => ({
// Mock components
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => (
MainNavigation: ({ organizationTeams, canDoRoleManagement }: any) => (
<div data-testid="main-navigation">
MainNavigation
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed?.toString() || "false"}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement?.toString() || "false"}</div>
</div>
),
}));
@@ -202,7 +202,7 @@ describe("EnvironmentLayout", () => {
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
mockIsDevelopment = false;
mockIsFormbricksCloud = false;
});
@@ -315,7 +315,7 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
});
test("passes isAccessControlAllowed props to MainNavigation", async () => {
test("passes canDoRoleManagement props to MainNavigation", async () => {
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
@@ -337,8 +337,8 @@ describe("EnvironmentLayout", () => {
})
);
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
expect(vi.mocked(getAccessControlPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
expect(vi.mocked(getRoleManagementPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
});
test("handles empty organizationTeams array", async () => {
@@ -393,8 +393,8 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
});
test("handles isAccessControlAllowed false", async () => {
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
test("handles canDoRoleManagement false", async () => {
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
@@ -416,7 +416,7 @@ describe("EnvironmentLayout", () => {
})
);
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
});
test("throws error if user not found", async () => {

View File

@@ -14,8 +14,8 @@ import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
@@ -51,10 +51,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
throw new Error(t("common.environment_not_found"));
}
const [projects, environments, isAccessControlAllowed] = await Promise.all([
const [projects, environments, canDoRoleManagement] = await Promise.all([
getUserProjects(user.id, organization.id),
getEnvironments(environment.projectId),
getAccessControlPermission(organization.billing.plan),
getRoleManagementPermission(organization.billing.plan),
]);
if (!projects || !environments || !organizations) {
@@ -121,16 +121,16 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
<TopControlBar
environment={environment}
environments={environments}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>
<div className="flex-1 overflow-y-auto">{children}</div>
<div className="mt-14">{children}</div>
</div>
</div>
</div>

View File

@@ -56,16 +56,16 @@ vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({
isCollapsed,
organizationTeams,
isAccessControlAllowed,
canDoRoleManagement,
}: {
isCollapsed: boolean;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
canDoRoleManagement: 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 data-testid="can-do-role-management">{canDoRoleManagement.toString()}</div>
</div>
),
}));
@@ -157,7 +157,7 @@ const defaultProps = {
membershipRole: "owner" as const,
organizationProjectsLimit: 5,
isLicenseActive: true,
isAccessControlAllowed: true,
canDoRoleManagement: true,
};
describe("MainNavigation", () => {
@@ -347,11 +347,11 @@ describe("MainNavigation", () => {
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
});
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
test("passes canDoRoleManagement props to ProjectSwitcher", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
});
test("handles no organizationTeams", () => {
@@ -360,9 +360,9 @@ describe("MainNavigation", () => {
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
});
test("handles isAccessControlAllowed false", () => {
render(<MainNavigation {...defaultProps} isAccessControlAllowed={false} />);
test("handles canDoRoleManagement false", () => {
render(<MainNavigation {...defaultProps} canDoRoleManagement={false} />);
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
});
});

View File

@@ -66,7 +66,7 @@ interface NavigationProps {
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
canDoRoleManagement: boolean;
}
export const MainNavigation = ({
@@ -81,7 +81,7 @@ export const MainNavigation = ({
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
isAccessControlAllowed,
canDoRoleManagement,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -325,7 +325,7 @@ export const MainNavigation = ({
isTextVisible={isTextVisible}
organization={organization}
organizationProjectsLimit={organizationProjectsLimit}
isAccessControlAllowed={isAccessControlAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
)}
@@ -339,30 +339,27 @@ export const MainNavigation = ({
<div
tabIndex={0}
className={cn(
"flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "justify-center px-2" : "px-4"
"flex cursor-pointer flex-row items-center space-x-3",
isCollapsed ? "pl-2" : "pl-4"
)}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
{!isCollapsed && !isTextVisible && (
<>
<div
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 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">
className="max-w-28 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")}
/>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
</>
)}
</div>

View File

@@ -28,7 +28,7 @@ const TestComponent = () => {
return (
<div>
<div data-testid="responseStatus">{selectedFilter.responseStatus}</div>
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
@@ -44,7 +44,7 @@ const TestComponent = () => {
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
},
],
responseStatus: "complete",
onlyComplete: true,
})
}>
Update Filter
@@ -81,7 +81,7 @@ describe("ResponseFilterContext", () => {
</ResponseFilterProvider>
);
expect(screen.getByTestId("responseStatus").textContent).toBe("all");
expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
expect(screen.getByTestId("filterLength").textContent).toBe("0");
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
@@ -99,7 +99,7 @@ describe("ResponseFilterContext", () => {
const updateButton = screen.getByText("Update Filter");
await userEvent.click(updateButton);
expect(screen.getByTestId("responseStatus").textContent).toBe("complete");
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
expect(screen.getByTestId("filterLength").textContent).toBe("1");
});

View File

@@ -16,11 +16,9 @@ export interface FilterValue {
};
}
export type TResponseStatus = "all" | "complete" | "partial";
export interface SelectedFilterValue {
filter: FilterValue[];
responseStatus: TResponseStatus;
onlyComplete: boolean;
}
interface SelectedFilterOptions {
@@ -49,7 +47,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
// state holds the filter selected value
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
filter: [],
responseStatus: "all",
onlyComplete: false,
});
// state holds all the options of the responses fetched
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
@@ -69,7 +67,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
});
setSelectedFilter({
filter: [],
responseStatus: "all",
onlyComplete: false,
});
}, []);

View File

@@ -44,8 +44,10 @@ describe("TopControlBar", () => {
);
// 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");
const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement;
expect(mainDiv).toHaveClass(
"fixed inset-0 top-0 z-30 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();

View File

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

View File

@@ -121,9 +121,8 @@ describe("ProfilePage", () => {
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
// Check for IdBadge content
expect(screen.getByText("common.profile_id")).toBeInTheDocument();
expect(screen.getByText(mockUser.id)).toBeInTheDocument();
// Use a regex to match the text content, allowing for variable whitespace
expect(screen.getByText(new RegExp(`common\\.profile\\s*:\\s*${mockUser.id}`))).toBeInTheDocument(); // SettingsId
});
});

View File

@@ -5,9 +5,9 @@ import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/servi
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
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";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
@@ -103,7 +103,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isMultiOrgEnabled={isMultiOrgEnabled}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
<SettingsId title={t("common.profile")} id={user.id}></SettingsId>
</div>
)}
</PageContentWrapper>

View File

@@ -5,7 +5,7 @@ import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/lice
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -78,8 +78,8 @@ vi.mock("./components/DeleteOrganization", () => ({
DeleteOrganization: vi.fn(() => <div>DeleteOrganization</div>),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div>IdBadge</div>),
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div>SettingsId</div>),
}));
describe("Page", () => {
@@ -156,11 +156,10 @@ describe("Page", () => {
},
undefined
);
expect(IdBadge).toHaveBeenCalledWith(
expect(SettingsId).toHaveBeenCalledWith(
{
title: "common.organization_id",
id: mockEnvironmentAuth.organization.id,
label: "common.organization_id",
variant: "column",
},
undefined
);

View File

@@ -4,9 +4,9 @@ import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
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";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
@@ -70,7 +70,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
</PageContentWrapper>
);
};

View File

@@ -1,9 +1,10 @@
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";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { cleanup } from "@testing-library/react";
import { AnyActionArg } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import {
@@ -59,7 +60,6 @@ vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionIconMap: vi.fn(() => ({
[TSurveyQuestionTypeEnum.OpenText]: <span>OT</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: <span>MCS</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: <span>MCM</span>,
[TSurveyQuestionTypeEnum.Matrix]: <span>MX</span>,
[TSurveyQuestionTypeEnum.Address]: <span>AD</span>,
[TSurveyQuestionTypeEnum.ContactInfo]: <span>CI</span>,
@@ -104,27 +104,6 @@ vi.mock("lucide-react", () => ({
TagIcon: () => <span>Tag</span>,
}));
// Mock new dependencies
vi.mock("@/lib/response/utils", () => ({
extractChoiceIdsFromResponse: vi.fn((responseValue) => {
// Mock implementation that returns choice IDs based on response value
if (Array.isArray(responseValue)) {
return responseValue.map((_, index) => `choice-${index + 1}`);
} else if (typeof responseValue === "string") {
return [`choice-single`];
}
return [];
}),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(({ id }) => <div data-testid="id-badge">{id}</div>),
}));
vi.mock("@/modules/ui/lib/utils", () => ({
cn: vi.fn((...classes) => classes.filter(Boolean).join(" ")),
}));
const mockSurvey = {
id: "survey1",
name: "Test Survey",
@@ -157,28 +136,6 @@ const mockSurvey = {
headline: { default: "Contact Info Question" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q5single",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Single Choice Question" },
required: false,
choices: [
{ id: "choice-1", label: { default: "Option 1" } },
{ id: "choice-2", label: { default: "Option 2" } },
{ id: "choice-3", label: { default: "Option 3" } },
],
} as unknown as TSurveyQuestion,
{
id: "q6multi",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multi Choice Question" },
required: false,
choices: [
{ id: "choice-a", label: { default: "Choice A" } },
{ id: "choice-b", label: { default: "Choice B" } },
{ id: "choice-c", label: { default: "Choice C" } },
],
} as unknown as TSurveyQuestion,
],
variables: [
{ id: "var1", name: "User Segment", type: "text" } as TSurveyVariable,
@@ -216,8 +173,6 @@ const mockResponseData = {
firstName: "John",
email: "john.doe@example.com",
hf1: "Hidden Field 1 Value",
q5single: "Option 1", // Single choice response
q6multi: ["Choice A", "Choice C"], // Multi choice response
},
variables: {
var1: "Segment A",
@@ -540,281 +495,3 @@ describe("ResponseTableColumns - Column Implementations", () => {
expect(hfColumn).toBeUndefined();
});
});
describe("ResponseTableColumns - Multiple Choice Questions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("generates two columns for multipleChoiceSingle questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q5single");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("generates two columns for multipleChoiceMulti questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q6multi");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "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 mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
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 mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
});
describe("ResponseTableColumns - Choice ID Columns", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
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 mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single", type: "multipleChoiceSingle" }),
"default"
);
});
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 mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
["Choice A", "Choice C"],
expect.objectContaining({ id: "q6multi", type: "multipleChoiceMulti" }),
"default"
);
});
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 mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return specific choice IDs
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce(["choice-1", "choice-3"]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
// Should render something for choice IDs
expect(cellResult).toBeDefined();
// Verify that extractChoiceIdsFromResponse was called
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalled();
});
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 mockRow = {
original: {
responseData: { q5single: 123 }, // Invalid type
language: "default",
},
};
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
expect(vi.mocked(extractChoiceIdsFromResponse)).not.toHaveBeenCalled();
});
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 mockRow = {
original: {
responseData: { q5single: "Non-existent option" },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return empty array
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce([]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
});
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 mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: null, // No language
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single" }),
undefined
);
});
});
describe("ResponseTableColumns - Helper Functions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
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");
// Test main column header
const mainHeader = mainColumn?.header?.();
expect(mainHeader).toBeDefined();
expect(mainHeader?.props?.className).toContain("flex items-center justify-between");
// Test option IDs column header
const optionHeader = optionIdsColumn?.header?.();
expect(optionHeader).toBeDefined();
expect(optionHeader?.props?.className).toContain("flex items-center justify-between");
});
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");
// Headers should be functions that return JSX
expect(typeof singleChoiceColumn?.header).toBe("function");
expect(typeof multiChoiceColumn?.header).toBe("function");
// Call headers to ensure they don't throw
expect(() => singleChoiceColumn?.header?.()).not.toThrow();
expect(() => multiChoiceColumn?.header?.()).not.toThrow();
});
});
describe("ResponseTableColumns - Integration Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("multiple choice questions work end-to-end with real data", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// 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");
expect(singleMainCol).toBeDefined();
expect(singleIdsCol).toBeDefined();
expect(multiMainCol).toBeDefined();
expect(multiIdsCol).toBeDefined();
// Test with actual mock response data
const mockRow = { original: mockResponseData };
// Test single choice main column
const singleMainResult = (singleMainCol?.cell as any)?.({ row: mockRow });
expect(singleMainResult).toBeDefined();
// Test multi choice main column
const multiMainResult = (multiMainCol?.cell as any)?.({ row: mockRow });
expect(multiMainResult).toBeDefined();
// Test that choice ID columns exist and can be called
const singleIdsResult = (singleIdsCol?.cell as any)?.({ row: mockRow });
const multiIdsResult = (multiIdsCol?.cell as any)?.({ row: mockRow });
// Should not error when calling the cell functions
expect(() => singleIdsResult).not.toThrow();
expect(() => multiIdsResult).not.toThrow();
});
});

View File

@@ -1,7 +1,6 @@
"use client";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
@@ -9,10 +8,8 @@ import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { ColumnDef } from "@tanstack/react-table";
import { TFnType } from "@tolgee/react";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
@@ -64,42 +61,6 @@ const getQuestionColumnsData = (
t: TFnType
): ColumnDef<TResponseTableData>[] => {
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
// Helper function to create consistent column headers
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline;
const QuestionHeader = () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
<span className="truncate">{title}</span>
</div>
</div>
);
QuestionHeader.displayName = "QuestionHeader";
return QuestionHeader;
};
// Helper function to get localized question headline
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
};
// Helper function to render choice ID badges
const renderChoiceIdBadges = (choiceIds: string[], isExpanded: boolean) => {
if (choiceIds.length === 0) return null;
const containerClasses = cn("flex gap-x-1 w-full", isExpanded && "flex-wrap gap-y-1");
return (
<div className={containerClasses}>
{choiceIds.map((choiceId, index) => (
<IdBadge key={`${choiceId}-${index}`} id={choiceId} />
))}
</div>
);
};
switch (question.type) {
case "matrix":
return question.rows.map((matrixRow) => {
@@ -176,50 +137,6 @@ const getQuestionColumnsData = (
};
});
case "multipleChoiceMulti":
case "multipleChoiceSingle":
case "ranking":
case "pictureSelection": {
const questionHeadline = getQuestionHeadline(question, survey);
return [
{
accessorKey: question.id,
header: createQuestionHeader(question.type, questionHeadline),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
const language = row.original.language;
return (
<RenderResponse
question={question}
survey={survey}
responseData={responseValue}
language={language}
isExpanded={isExpanded}
showId={false}
/>
);
},
},
{
accessorKey: question.id + "optionIds",
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
// Type guard to ensure responseValue is the correct type
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
const choiceIds = extractChoiceIdsFromResponse(
responseValue,
question,
row.original.language || undefined
);
return renderChoiceIdBadges(choiceIds, isExpanded);
}
return null;
},
},
];
}
default:
return [
{
@@ -247,7 +164,6 @@ const getQuestionColumnsData = (
responseData={responseValue}
language={language}
isExpanded={isExpanded}
showId={false}
/>
);
},
@@ -314,7 +230,7 @@ export const generateResponseTableColumns = (
header: t("common.status"),
cell: ({ row }) => {
const status = row.original.status;
return <ResponseBadges items={[{ value: status }]} showId={false} />;
return <ResponseBadges items={[status]} />;
},
};
@@ -327,10 +243,9 @@ export const generateResponseTableColumns = (
const tagsArray = tags.map((tag) => tag.name);
return (
<ResponseBadges
items={tagsArray.map((tag) => ({ value: tag }))}
items={tagsArray}
isExpanded={isExpanded}
icon={<TagIcon className="h-4 w-4 text-slate-500" />}
showId={false}
/>
);
}
@@ -402,6 +317,7 @@ export const generateResponseTableColumns = (
};
// Combine the selection column with the dynamic question columns
const baseColumns = [
personColumn,
dateColumn,

View File

@@ -7,13 +7,6 @@ vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
}));
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
describe("MultipleChoiceSummary", () => {
afterEach(() => {
@@ -167,8 +160,8 @@ describe("MultipleChoiceSummary", () => {
/>
);
const btns = screen.getAllByRole("button");
expect(btns[0]).toHaveTextContent("2 - YID: other2 common.selections50%");
expect(btns[1]).toHaveTextContent("1 - XID: other1 common.selection50%");
expect(btns[0]).toHaveTextContent("2 - Y50%2 common.selections");
expect(btns[1]).toHaveTextContent("1 - X50%1 common.selection");
});
test("places choice with others after one without when reversed inputs", () => {
@@ -279,127 +272,4 @@ describe("MultipleChoiceSummary", () => {
["O5"]
);
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q6",
headline: "H6",
type: "multipleChoiceSingle",
choices: [
{ id: "choice1", label: { default: "Option A" } },
{ id: "choice2", label: { default: "Option B" } },
],
},
choices: {
"Option A": { value: "Option A", count: 5, percentage: 50, others: [] },
"Option B": { value: "Option B", count: 5, percentage: 50, others: [] },
},
type: "multipleChoiceSingle",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "choice1");
expect(idBadges[1]).toHaveAttribute("data-id", "choice2");
expect(idBadges[0]).toHaveTextContent("ID: choice1");
expect(idBadges[1]).toHaveTextContent("ID: choice2");
});
test("getChoiceIdByValue function correctly maps values to IDs", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q8",
headline: "H8",
type: "multipleChoiceMulti",
choices: [
{ id: "id-apple", label: { default: "Apple" } },
{ id: "id-banana", label: { default: "Banana" } },
{ id: "id-cherry", label: { default: "Cherry" } },
],
},
choices: {
Apple: { value: "Apple", count: 3, percentage: 30, others: [] },
Banana: { value: "Banana", count: 4, percentage: 40, others: [] },
Cherry: { value: "Cherry", count: 3, percentage: 30, others: [] },
},
type: "multipleChoiceMulti",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
// Check that each badge has the correct ID
const expectedMappings = [
{ text: "Banana", id: "id-banana" }, // Highest count appears first
{ text: "Apple", id: "id-apple" },
{ text: "Cherry", id: "id-cherry" },
];
expectedMappings.forEach(({ text, id }, index) => {
expect(screen.getByText(`${3 - index} - ${text}`)).toBeInTheDocument();
expect(idBadges[index]).toHaveAttribute("data-id", id);
});
});
test("handles choices with special characters in labels", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q9",
headline: "H9",
type: "multipleChoiceSingle",
choices: [
{ id: "special-1", label: { default: "Option & Choice" } },
{ id: "special-2", label: { default: "Choice with 'quotes'" } },
],
},
choices: {
"Option & Choice": { value: "Option & Choice", count: 2, percentage: 50, others: [] },
"Choice with 'quotes'": { value: "Choice with 'quotes'", count: 2, percentage: 50, others: [] },
},
type: "multipleChoiceSingle",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "special-1");
expect(idBadges[1]).toHaveAttribute("data-id", "special-2");
});
});

View File

@@ -1,10 +1,8 @@
"use client";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
@@ -86,95 +84,90 @@ export const MultipleChoiceSummary = ({
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<Fragment key={result.value}>
<button
type="button"
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
{choiceId && <IdBadge id={choiceId} />}
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
{results.map((result, resultsIdx) => (
<Fragment key={result.value}>
<button
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
</div>
)}
</div>
)}
</Fragment>
);
})}
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
)}
</Fragment>
))}
</div>
</div>
);

View File

@@ -1,11 +1,7 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
TSurvey,
TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { PictureChoiceSummary } from "./PictureChoiceSummary";
vi.mock("@/modules/ui/components/progress-bar", () => ({
@@ -16,19 +12,6 @@ vi.mock("@/modules/ui/components/progress-bar", () => ({
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
vi.mock("@/lib/response/utils", () => ({
getChoiceIdByValue: (value: string, question: TSurveyPictureSelectionQuestion) => {
return question.choices?.find((choice) => choice.imageUrl === value)?.id ?? "other";
},
}));
// mock next image
vi.mock("next/image", () => ({
@@ -105,73 +88,4 @@ describe("PictureChoiceSummary", () => {
expect(screen.getByTestId("header")).toBeEmptyDOMElement();
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found via imageUrl", () => {
const choices = [
{ id: "choice1", imageUrl: "https://example.com/img1.png", percentage: 50, count: 5 },
{ id: "choice2", imageUrl: "https://example.com/img2.png", percentage: 50, count: 5 },
];
const questionSummary = {
choices,
question: {
id: "q2",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "Picture Question",
allowMulti: true,
choices: [
{ id: "pic-choice-1", imageUrl: "https://example.com/img1.png" },
{ id: "pic-choice-2", imageUrl: "https://example.com/img2.png" },
],
},
selectionCount: 10,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "pic-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "pic-choice-2");
expect(idBadges[0]).toHaveTextContent("ID: pic-choice-1");
expect(idBadges[1]).toHaveTextContent("ID: pic-choice-2");
});
test("getChoiceIdByValue function correctly maps imageUrl to choice ID", () => {
const choices = [
{ id: "choice1", imageUrl: "https://cdn.example.com/photo1.jpg", percentage: 33.33, count: 2 },
{ id: "choice2", imageUrl: "https://cdn.example.com/photo2.jpg", percentage: 33.33, count: 2 },
{ id: "choice3", imageUrl: "https://cdn.example.com/photo3.jpg", percentage: 33.33, count: 2 },
];
const questionSummary = {
choices,
question: {
id: "q4",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "Photo Selection",
allowMulti: true,
choices: [
{ id: "photo-a", imageUrl: "https://cdn.example.com/photo1.jpg" },
{ id: "photo-b", imageUrl: "https://cdn.example.com/photo2.jpg" },
{ id: "photo-c", imageUrl: "https://cdn.example.com/photo3.jpg" },
],
},
selectionCount: 6,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
expect(idBadges[0]).toHaveAttribute("data-id", "photo-a");
expect(idBadges[1]).toHaveAttribute("data-id", "photo-b");
expect(idBadges[2]).toHaveAttribute("data-id", "photo-c");
// Verify the images are also rendered correctly
const images = screen.getAllByRole("img");
expect(images).toHaveLength(3);
expect(images[0]).toHaveAttribute("src", "https://cdn.example.com/photo1.jpg");
expect(images[1]).toHaveAttribute("src", "https://cdn.example.com/photo2.jpg");
expect(images[2]).toHaveAttribute("src", "https://cdn.example.com/photo3.jpg");
});
});

View File

@@ -1,7 +1,5 @@
"use client";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
@@ -31,7 +29,6 @@ interface PictureChoiceSummaryProps {
export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: PictureChoiceSummaryProps) => {
const results = questionSummary.choices;
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
@@ -47,48 +44,43 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => {
const choiceId = getChoiceIdByValue(result.imageUrl, questionSummary.question);
return (
<button
type="button"
className="w-full cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.includes_all"),
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
alt="choice-image"
layout="fill"
objectFit="cover"
className="rounded-md"
/>
</div>
<div className="self-end">{choiceId && <IdBadge id={choiceId} />}</div>
{results.map((result, index) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.includes_all"),
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
alt="choice-image"
layout="fill"
objectFit="cover"
className="rounded-md"
/>
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
<div className="self-end">
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
);
})}
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
))}
</div>
</div>
);

View File

@@ -27,10 +27,10 @@ vi.mock("@/modules/survey/lib/questions", () => ({
],
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ label, id }: { label: string; id: string }) => (
<div data-testid="id-badge">
{label}: {id}
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: { title: string; id: string }) => (
<div data-testid="settings-id">
{title}: {id}
</div>
),
}));
@@ -76,7 +76,7 @@ describe("QuestionSummaryHeader", () => {
).toBeInTheDocument();
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
expect(screen.getByTestId("id-badge")).toHaveTextContent("common.question_id: q1");
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
});

View File

@@ -3,7 +3,7 @@
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
@@ -55,7 +55,7 @@ export const QuestionSummaryHeader = ({
</div>
)}
</div>
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
<SettingsId title={t("common.question_id")} id={questionSummary.question.id}></SettingsId>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types";
import { RankingSummary } from "./RankingSummary";
// Mock dependencies
@@ -12,32 +12,17 @@ vi.mock("../lib/utils", () => ({
convertFloatToNDecimal: (value: number) => value.toFixed(2),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
describe("RankingSummary", () => {
afterEach(() => {
cleanup();
});
const survey = {} as TSurvey;
const surveyType: TSurveyType = "app";
test("renders ranking results in correct order", () => {
const questionSummary = {
question: {
id: "q1",
headline: "Rank the following",
choices: [
{ id: "choice1", label: { default: "Option A" } },
{ id: "choice2", label: { default: "Option B" } },
{ id: "choice3", label: { default: "Option C" } },
],
},
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: { value: "Option A", avgRanking: 1.5, others: [] },
option2: { value: "Option B", avgRanking: 2.3, others: [] },
@@ -45,7 +30,7 @@ describe("RankingSummary", () => {
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
@@ -66,13 +51,26 @@ describe("RankingSummary", () => {
expect(screen.getByText("#2.30")).toBeInTheDocument();
});
test("doesn't show 'User' column for link survey type", () => {
test("renders 'other values found' section when others exist", () => {
const questionSummary = {
question: {
id: "q1",
headline: "Rank the following",
choices: [{ id: "choice1", label: { default: "Option A" } }],
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
avgRanking: 1.0,
others: [{ value: "Other value", count: 2 }],
},
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeInTheDocument();
});
test("shows 'User' column in other values section for app survey type", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
@@ -82,132 +80,25 @@ describe("RankingSummary", () => {
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="app" />);
expect(screen.getByText("common.user")).toBeInTheDocument();
});
test("doesn't show 'User' column for link survey type", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
avgRanking: 1.0,
others: [{ value: "Other value", count: 1 }],
},
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="link" />);
expect(screen.queryByText("common.user")).not.toBeInTheDocument();
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found via label", () => {
const questionSummary = {
question: {
id: "q2",
headline: "Rank these options",
choices: [
{ id: "rank-choice-1", label: { default: "First Option" } },
{ id: "rank-choice-2", label: { default: "Second Option" } },
{ id: "rank-choice-3", label: { default: "Third Option" } },
],
},
choices: {
option1: { value: "First Option", avgRanking: 1.5, others: [] },
option2: { value: "Second Option", avgRanking: 2.1, others: [] },
option3: { value: "Third Option", avgRanking: 2.8, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
expect(idBadges[0]).toHaveAttribute("data-id", "rank-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "rank-choice-2");
expect(idBadges[2]).toHaveAttribute("data-id", "rank-choice-3");
expect(idBadges[0]).toHaveTextContent("ID: rank-choice-1");
expect(idBadges[1]).toHaveTextContent("ID: rank-choice-2");
expect(idBadges[2]).toHaveTextContent("ID: rank-choice-3");
});
test("getChoiceIdByValue function correctly maps ranking values to choice IDs", () => {
const questionSummary = {
question: {
id: "q4",
headline: "Rate importance",
choices: [
{ id: "importance-high", label: { default: "Very Important" } },
{ id: "importance-medium", label: { default: "Somewhat Important" } },
{ id: "importance-low", label: { default: "Not Important" } },
],
},
choices: {
option1: { value: "Very Important", avgRanking: 1.2, others: [] },
option2: { value: "Somewhat Important", avgRanking: 2.0, others: [] },
option3: { value: "Not Important", avgRanking: 2.8, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
// Should be ordered by avgRanking (ascending)
expect(screen.getByText("Very Important")).toBeInTheDocument(); // avgRanking: 1.2
expect(screen.getByText("Somewhat Important")).toBeInTheDocument(); // avgRanking: 2.0
expect(screen.getByText("Not Important")).toBeInTheDocument(); // avgRanking: 2.8
expect(idBadges[0]).toHaveAttribute("data-id", "importance-high");
expect(idBadges[1]).toHaveAttribute("data-id", "importance-medium");
expect(idBadges[2]).toHaveAttribute("data-id", "importance-low");
});
test("handles mixed choices with and without matching IDs", () => {
const questionSummary = {
question: {
id: "q5",
headline: "Mixed options",
choices: [
{ id: "valid-choice-1", label: { default: "Valid Option" } },
{ id: "valid-choice-2", label: { default: "Another Valid Option" } },
],
},
choices: {
option1: { value: "Valid Option", avgRanking: 1.5, others: [] },
option2: { value: "Unknown Option", avgRanking: 2.0, others: [] },
option3: { value: "Another Valid Option", avgRanking: 2.5, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3); // Only 2 out of 3 should have badges
// Check that all options are still displayed
expect(screen.getByText("Valid Option")).toBeInTheDocument();
expect(screen.getByText("Unknown Option")).toBeInTheDocument();
expect(screen.getByText("Another Valid Option")).toBeInTheDocument();
// Check that only the valid choices have badges
expect(idBadges[0]).toHaveAttribute("data-id", "valid-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "other");
expect(idBadges[2]).toHaveAttribute("data-id", "valid-choice-2");
});
test("handles special characters in choice labels", () => {
const questionSummary = {
question: {
id: "q6",
headline: "Special characters test",
choices: [
{ id: "special-1", label: { default: "Option with 'quotes'" } },
{ id: "special-2", label: { default: "Option & Ampersand" } },
],
},
choices: {
option1: { value: "Option with 'quotes'", avgRanking: 1.0, others: [] },
option2: { value: "Option & Ampersand", avgRanking: 2.0, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "special-1");
expect(idBadges[1]).toHaveAttribute("data-id", "special-2");
expect(screen.getByText("Option with 'quotes'")).toBeInTheDocument();
expect(screen.getByText("Option & Ampersand")).toBeInTheDocument();
});
});

View File

@@ -1,16 +1,15 @@
import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { useTranslate } from "@tolgee/react";
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface RankingSummaryProps {
questionSummary: TSurveyQuestionSummaryRanking;
surveyType: TSurveyType;
survey: TSurvey;
}
export const RankingSummary = ({ questionSummary, survey }: RankingSummaryProps) => {
export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingSummaryProps) => {
// sort by count and transform to array
const { t } = useTranslate();
const results = Object.values(questionSummary.choices).sort((a, b) => {
@@ -21,30 +20,35 @@ export const RankingSummary = ({ questionSummary, survey }: RankingSummaryProps)
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="flex w-full items-center">
<div className="flex items-center space-x-2">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
{choiceId && <IdBadge id={choiceId} />}
</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
</span>
<span>{t("environments.surveys.summary.average")}</span>
{results.map((result, resultsIdx) => (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="flex w-full items-center">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
</span>
</div>
<span>{t("environments.surveys.summary.average")}</span>
</span>
</div>
</div>
</div>
);
})}
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
);

View File

@@ -191,7 +191,7 @@ const mockSurvey = {
variables: [],
} as unknown as TSurvey;
const mockSelectedFilter = { filter: [], responseStatus: "all" };
const mockSelectedFilter = { filter: [], onlyComplete: false };
const mockSetSelectedFilter = vi.fn();
const defaultProps = {
@@ -309,13 +309,17 @@ describe("SummaryList", () => {
test("renders EmptySpaceFiller when responseCount is 0 and summary is not empty (no responses match filter)", () => {
const summaryWithItem = [createMockQuestionSummary("q1", TSurveyQuestionTypeEnum.OpenText)];
render(<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} />);
render(
<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} totalResponseCount={10} />
);
expect(screen.getByText("Mocked EmptySpaceFiller")).toBeInTheDocument();
});
test("renders EmptySpaceFiller when responseCount is 0 and totalResponseCount is 0 (no responses at all)", () => {
const summaryWithItem = [createMockQuestionSummary("q1", TSurveyQuestionTypeEnum.OpenText)];
render(<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} />);
render(
<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} totalResponseCount={0} />
);
expect(screen.getByText("Mocked EmptySpaceFiller")).toBeInTheDocument();
});
@@ -393,7 +397,7 @@ describe("SummaryList", () => {
},
},
],
responseStatus: "all",
onlyComplete: false,
});
// Ensure vi.mocked(toast.success) refers to the spy from the named export
expect(vi.mocked(toast).success).toHaveBeenCalledWith("Custom add message", { duration: 5000 });
@@ -421,7 +425,7 @@ describe("SummaryList", () => {
},
};
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: { filter: [existingFilter], responseStatus: "all" },
selectedFilter: { filter: [existingFilter], onlyComplete: false },
setSelectedFilter: mockSetSelectedFilter,
resetFilter: vi.fn(),
} as any);
@@ -450,7 +454,7 @@ describe("SummaryList", () => {
},
},
],
responseStatus: "all",
onlyComplete: false,
});
expect(vi.mocked(toast.success)).toHaveBeenCalledWith(
"environments.surveys.summary.filter_updated_successfully",

View File

@@ -92,7 +92,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
setSelectedFilter({
filter: [...filterObject.filter],
responseStatus: filterObject.responseStatus,
onlyComplete: filterObject.onlyComplete,
});
};
@@ -244,6 +244,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
<RankingSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
surveyType={survey.type}
survey={survey}
/>
);

View File

@@ -98,8 +98,8 @@ vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ children }) => <div data-testid="page-header">{children}</div>),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div data-testid="id-badge"></div>),
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div data-testid="settings-id"></div>),
}));
vi.mock("@/tolgee/server", () => ({
@@ -227,7 +227,7 @@ describe("SurveyPage", () => {
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("survey-analysis-navigation")).toBeInTheDocument();
expect(screen.getByTestId("summary-page")).toBeInTheDocument();
expect(screen.getByTestId("id-badge")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toBeInTheDocument();
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);

View File

@@ -9,9 +9,9 @@ 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 { getEnvironmentAuth } from "@/modules/environments/lib/utils";
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";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { notFound } from "next/navigation";
@@ -74,7 +74,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
initialSurveySummary={initialSurveySummary}
/>
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
<SettingsId title={t("common.survey_id")} id={surveyId} />
</PageContentWrapper>
);
};

View File

@@ -188,70 +188,4 @@ describe("CustomFilter", () => {
expect(screen.queryByTestId("calendar-mock")).not.toBeInTheDocument();
});
});
test("downloading all and filtered responses in csv and xlsx formats", async () => {
const user = userEvent.setup();
render(<CustomFilter survey={mockSurvey} />);
// Mock the action to return undefined data to avoid DOM manipulation
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValue({
data: undefined,
});
// Test CSV download
const downloadButton = screen.getByTestId("fb__custom-filter-download-responses-button");
await user.click(downloadButton);
const downloadAllCsv = screen.getByTestId("fb__custom-filter-download-all-csv");
await user.click(downloadAllCsv);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "csv",
filterCriteria: {},
});
});
// Test XLSX download
await user.click(downloadButton);
const downloadAllXlsx = screen.getByTestId("fb__custom-filter-download-all-xlsx");
await user.click(downloadAllXlsx);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "xlsx",
filterCriteria: {},
});
});
// Test filtered CSV download
await user.click(downloadButton);
const downloadFilteredCsv = screen.getByTestId("fb__custom-filter-download-filtered-csv");
await user.click(downloadFilteredCsv);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "csv",
filterCriteria: {},
});
});
// Test filtered XLSX download
await user.click(downloadButton);
const downloadFilteredXlsx = screen.getByTestId("fb__custom-filter-download-filtered-xlsx");
await user.click(downloadFilteredXlsx);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey-1",
format: "xlsx",
filterCriteria: {},
});
});
vi.restoreAllMocks();
});
});

View File

@@ -15,7 +15,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { cn } from "@/modules/ui/lib/utils";
import { TFnType, useTranslate } from "@tolgee/react";
import {
differenceInDays,
@@ -32,7 +31,7 @@ import {
subQuarters,
subYears,
} from "date-fns";
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon, Loader2Icon } from "lucide-react";
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -136,7 +135,6 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const firstMountRef = useRef(true);
@@ -238,29 +236,28 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
setSelectingDate(DateSelected.FROM);
};
const handleDownloadResponses = async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
const responseFilters = filter === FilterDownload.ALL ? {} : filters;
setIsDownloading(true);
const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: filetype,
filterCriteria: responseFilters,
});
if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a");
link.href = responsesDownloadUrlResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
const errorMessage = getFormattedErrorMessage(responsesDownloadUrlResponse);
toast.error(errorMessage);
const handleDowndloadResponses = async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
try {
const responseFilters = filter === FilterDownload.ALL ? {} : filters;
const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: filetype,
filterCriteria: responseFilters,
});
if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a");
link.href = responsesDownloadUrlResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
const errorMessage = getFormattedErrorMessage(responsesDownloadUrlResponse);
toast.error(errorMessage);
}
} catch (error) {
toast.error("Error downloading responses");
}
setIsDownloading(false);
};
useClickOutside(datePickerRef, () => handleDatePickerClose());
@@ -389,22 +386,11 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
onOpenChange={(value) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger
asChild
className={cn(
"focus:bg-muted cursor-pointer outline-none",
isDownloading && "cursor-not-allowed opacity-50"
)}
disabled={isDownloading}
data-testid="fb__custom-filter-download-responses-button">
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span>
{isDownloading ? (
<Loader2Icon className="ml-2 h-4 w-4 animate-spin" />
) : (
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
)}
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
</div>
<DownloadIcon className="block h-4 sm:hidden" />
</div>
@@ -412,30 +398,26 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
<DropdownMenuContent align="start">
<DropdownMenuItem
data-testid="fb__custom-filter-download-all-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "csv");
onClick={() => {
handleDowndloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-all-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
onClick={() => {
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-csv"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "csv");
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="fb__custom-filter-download-filtered-xlsx"
onClick={async () => {
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
</DropdownMenuItem>

View File

@@ -197,7 +197,7 @@ export const QuestionFilterComboBox = ({
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input

View File

@@ -188,7 +188,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
</button>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in absolute top-0 z-50 w-full overflow-auto rounded-md bg-white outline-none">
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (

View File

@@ -30,45 +30,6 @@ vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [[vi.fn()]],
}));
// Mock the Select components
const mockOnValueChange = vi.fn();
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children, onValueChange, defaultValue }) => {
// Store the onValueChange callback for testing
mockOnValueChange.mockImplementation(onValueChange);
return (
<div data-testid="select-root" data-default-value={defaultValue}>
{children}
</div>
);
},
SelectTrigger: ({ children, className }) => (
<div
role="combobox"
className={className}
data-testid="select-trigger"
tabIndex={0}
aria-expanded="false"
aria-haspopup="listbox">
{children}
</div>
),
SelectValue: () => <span>environments.surveys.filter.complete_and_partial_responses</span>,
SelectContent: ({ children }) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ value, children, ...props }) => (
<div
data-testid={`select-item-${value}`}
data-value={value}
onClick={() => mockOnValueChange(value)}
onKeyDown={(e) => e.key === "Enter" && mockOnValueChange(value)}
role="option"
tabIndex={0}
{...props}>
{children}
</div>
),
}));
vi.mock("./QuestionsComboBox", () => ({
QuestionsComboBox: ({ onChangeValue }) => (
<div data-testid="questions-combo-box">
@@ -106,7 +67,7 @@ describe("ResponseFilter", () => {
const mockSelectedFilter = {
filter: [],
responseStatus: "all",
onlyComplete: false,
};
const mockSelectedOptions = {
@@ -184,7 +145,7 @@ describe("ResponseFilter", () => {
expect(
screen.getByText("environments.surveys.summary.show_all_responses_that_match")
).toBeInTheDocument();
expect(screen.getByTestId("select-trigger")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.only_completed")).toBeInTheDocument();
});
test("fetches filter data when opened", async () => {
@@ -199,7 +160,7 @@ describe("ResponseFilter", () => {
test("handles adding new filter", async () => {
// Start with an empty filter
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: { filter: [], responseStatus: "all" },
selectedFilter: { filter: [], onlyComplete: false },
setSelectedFilter: mockSetSelectedFilter,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
@@ -217,38 +178,14 @@ describe("ResponseFilter", () => {
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
});
test("handles response status filter change to complete", async () => {
test("handles only complete checkbox toggle", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
// Simulate selecting "complete" by calling the mock function
mockOnValueChange("complete");
await userEvent.click(screen.getByRole("checkbox"));
await userEvent.click(screen.getByText("common.apply_filters"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith(
expect.objectContaining({
responseStatus: "complete",
})
);
});
test("handles response status filter change to partial", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
// Simulate selecting "partial" by calling the mock function
mockOnValueChange("partial");
await userEvent.click(screen.getByText("common.apply_filters"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith(
expect.objectContaining({
responseStatus: "partial",
})
);
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: true });
});
test("handles selecting question and filter options", async () => {
@@ -262,7 +199,7 @@ describe("ResponseFilter", () => {
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
},
],
responseStatus: "all",
onlyComplete: false,
},
setSelectedFilter: setSelectedFilterMock,
selectedOptions: mockSelectedOptions,
@@ -291,6 +228,6 @@ describe("ResponseFilter", () => {
await userEvent.click(screen.getByText("Filter"));
await userEvent.click(screen.getByText("common.clear_all"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], responseStatus: "all" });
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
});
});

View File

@@ -2,23 +2,17 @@
import {
SelectedFilterValue,
TResponseStatus,
useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import clsx from "clsx";
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
@@ -78,7 +72,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
)?.filterOptions[0],
},
};
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
setFilterValue({ filter: [...filterValue.filter], onlyComplete: filterValue.onlyComplete });
} else {
// Update the existing value at the specified index
filterValue.filter[index].questionType = value;
@@ -99,7 +93,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
// keep the filter if questionType is selected and filterComboBoxValue is selected
return s.questionType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
}),
responseStatus: filterValue.responseStatus,
onlyComplete: filterValue.onlyComplete,
});
};
@@ -126,8 +120,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
const handleClearAllFilters = () => {
setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" }));
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" }));
setFilterValue((filterValue) => ({ ...filterValue, filter: [] }));
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [] }));
setIsOpen(false);
};
@@ -164,8 +158,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
setFilterValue({ ...filterValue });
};
const handleResponseStatusChange = (responseStatus: TResponseStatus) => {
setFilterValue({ ...filterValue, responseStatus });
const handleCheckOnlyComplete = (checked: boolean) => {
setFilterValue({ ...filterValue, onlyComplete: checked });
};
// remove the filter which has already been selected
@@ -209,9 +203,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-8 flex flex-wrap items-start justify-between gap-2">
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]">
<div className="mb-8 flex flex-wrap items-start justify-between">
<p className="text-slate800 hidden text-lg font-semibold sm:block">
{t("environments.surveys.summary.show_all_responses_that_match")}
</p>
@@ -219,24 +212,16 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
{t("environments.surveys.summary.show_all_responses_where")}
</p>
<div className="flex items-center space-x-2">
<Select
onValueChange={(val) => {
handleResponseStatusChange(val as TResponseStatus);
<label className="text-sm font-normal text-slate-600">
{t("environments.surveys.summary.only_completed")}
</label>
<Checkbox
className={clsx("rounded-md", filterValue.onlyComplete && "bg-black text-white")}
checked={filterValue.onlyComplete}
onCheckedChange={(checked) => {
typeof checked === "boolean" && handleCheckOnlyComplete(checked);
}}
defaultValue={filterValue.responseStatus}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">
{t("environments.surveys.filter.complete_and_partial_responses")}
</SelectItem>
<SelectItem value="complete">
{t("environments.surveys.filter.complete_responses")}
</SelectItem>
<SelectItem value="partial">{t("environments.surveys.filter.partial_responses")}</SelectItem>
</SelectContent>
</Select>
/>
</div>
</div>

View File

@@ -13,7 +13,7 @@ export default function GlobalError({ error }: { error: Error & { digest?: strin
}
}, [error]);
return (
<html lang="en-US">
<html>
<body>
<NextError statusCode={0} />
</body>

View File

@@ -320,7 +320,7 @@ describe("surveys", () => {
test("should return empty filters when no selections", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [],
};
@@ -331,7 +331,7 @@ describe("surveys", () => {
test("should filter by completed responses", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "complete",
onlyComplete: true,
filter: [],
};
@@ -342,7 +342,7 @@ describe("surveys", () => {
test("should filter by date range", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [],
};
@@ -355,7 +355,7 @@ describe("surveys", () => {
test("should filter by tags", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
@@ -376,7 +376,7 @@ describe("surveys", () => {
test("should filter by open text questions", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -397,7 +397,7 @@ describe("surveys", () => {
test("should filter by address questions", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -418,7 +418,7 @@ describe("surveys", () => {
test("should filter by contact info questions", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -439,7 +439,7 @@ describe("surveys", () => {
test("should filter by ranking questions", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -460,7 +460,7 @@ describe("surveys", () => {
test("should filter by multiple choice single questions", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -481,7 +481,7 @@ describe("surveys", () => {
test("should filter by multiple choice multi questions", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -502,7 +502,7 @@ describe("surveys", () => {
test("should filter by NPS questions with different operations", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -523,7 +523,7 @@ describe("surveys", () => {
test("should filter by rating questions with less than operation", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -544,7 +544,7 @@ describe("surveys", () => {
test("should filter by CTA questions", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -565,7 +565,7 @@ describe("surveys", () => {
test("should filter by consent questions", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -586,7 +586,7 @@ describe("surveys", () => {
test("should filter by picture selection questions", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -607,7 +607,7 @@ describe("surveys", () => {
test("should filter by matrix questions", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: {
@@ -628,7 +628,7 @@ describe("surveys", () => {
test("should filter by hidden fields", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: { type: "Hidden Fields", label: "plan", id: "plan" },
@@ -644,7 +644,7 @@ describe("surveys", () => {
test("should filter by attributes", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: { type: "Attributes", label: "role", id: "role" },
@@ -660,7 +660,7 @@ describe("surveys", () => {
test("should filter by other filters", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: { type: "Other Filters", label: "Language", id: "language" },
@@ -676,7 +676,7 @@ describe("surveys", () => {
test("should filter by meta fields", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
onlyComplete: false,
filter: [
{
questionType: { type: "Meta", label: "source", id: "source" },
@@ -692,7 +692,7 @@ describe("surveys", () => {
test("should handle multiple filters together", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "complete",
onlyComplete: true,
filter: [
{
questionType: {

View File

@@ -242,10 +242,8 @@ export const getFormattedFilters = (
});
// for completed responses
if (selectedFilter.responseStatus === "complete") {
if (selectedFilter.onlyComplete) {
filters["finished"] = true;
} else if (selectedFilter.responseStatus === "partial") {
filters["finished"] = false;
}
// for date range responses

View File

@@ -521,121 +521,6 @@ const earnedAdvocacyScore = (t: TFnType): TTemplate => {
);
};
const usabilityScoreRatingSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.usability_score_name"),
role: "customerSuccess",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.usability_rating_description"),
questions: [
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_1_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_2_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_3_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_4_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_5_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_6_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_7_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_8_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_9_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_10_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
],
},
t
);
};
const improveTrialConversion = (t: TFnType): TTemplate => {
const reusableQuestionIds = [createId(), createId(), createId(), createId(), createId(), createId()];
const reusableOptionIds = [
@@ -3543,7 +3428,6 @@ export const templates = (t: TFnType): TTemplate[] => [
onboardingSegmentation(t),
churnSurvey(t),
earnedAdvocacyScore(t),
usabilityScoreRatingSurvey(t),
improveTrialConversion(t),
reviewPrompt(t),
interviewPrompt(t),

View File

@@ -224,8 +224,16 @@ export const getMonthlyActiveOrganizationPeopleCount = reactCache(
async (organizationId: string): Promise<number> => {
validateInputs([organizationId, ZId]);
// temporary solution until we have a better way to track active users
return 0;
try {
// temporary solution until we have a better way to track active users
return 0;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);

View File

@@ -1,17 +1,11 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { TResponse } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyOpenTextQuestion,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
buildWhereClause,
calculateTtcTotal,
extracMetadataKeys,
extractChoiceIdsFromResponse,
extractSurveyDetails,
generateAllPermutationsOfSubsets,
getResponseContactAttributes,
@@ -561,176 +555,3 @@ describe("Response Utils", () => {
});
});
});
describe("extractChoiceIdsFromResponse", () => {
const multipleChoiceMultiQuestion: TSurveyQuestion = {
id: "multi-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Select multiple options" },
required: false,
choices: [
{
id: "choice-1",
label: { default: "Option 1", es: "Opción 1" },
},
{
id: "choice-2",
label: { default: "Option 2", es: "Opción 2" },
},
{
id: "choice-3",
label: { default: "Option 3", es: "Opción 3" },
},
],
};
const multipleChoiceSingleQuestion: TSurveyQuestion = {
id: "single-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Select one option" },
required: false,
choices: [
{
id: "choice-a",
label: { default: "Choice A", fr: "Choix A" },
},
{
id: "choice-b",
label: { default: "Choice B", fr: "Choix B" },
},
],
};
const textQuestion: TSurveyOpenTextQuestion = {
id: "text-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "What do you think?" },
required: false,
inputType: "text",
charLimit: { enabled: false, min: 0, max: 0 },
};
describe("multipleChoiceMulti questions", () => {
test("should extract choice IDs from array response with default language", () => {
const responseValue = ["Option 1", "Option 3"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "choice-3"]);
});
test("should extract choice IDs from array response with specific language", () => {
const responseValue = ["Opción 1", "Opción 2"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
expect(result).toEqual(["choice-1", "choice-2"]);
});
test("should fall back to checking all language values when exact language match fails", () => {
const responseValue = ["Opción 1", "Option 2"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "choice-2"]);
});
test("should render other option when non-matching choice is selected", () => {
const responseValue = ["Option 1", "Non-existent option", "Option 3"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "other", "choice-3"]);
});
test("should return empty array for empty response", () => {
const responseValue: string[] = [];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
});
describe("multipleChoiceSingle questions", () => {
test("should extract choice ID from string response with default language", () => {
const responseValue = "Choice A";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual(["choice-a"]);
});
test("should extract choice ID from string response with specific language", () => {
const responseValue = "Choix B";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "fr");
expect(result).toEqual(["choice-b"]);
});
test("should fall back to checking all language values for single choice", () => {
const responseValue = "Choix A";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual(["choice-a"]);
});
test("should return empty array for empty string response", () => {
const responseValue = "";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual([]);
});
});
describe("edge cases", () => {
test("should return empty array for non-multiple choice questions", () => {
const responseValue = "Some text response";
const result = extractChoiceIdsFromResponse(responseValue, textQuestion, "default");
expect(result).toEqual([]);
});
test("should handle missing language parameter by defaulting to 'default'", () => {
const responseValue = "Option 1";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion);
expect(result).toEqual(["choice-1"]);
});
test("should handle numeric or other types by returning empty array", () => {
const responseValue = 123;
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
test("should handle object responses by returning empty array", () => {
const responseValue = { invalid: "object" };
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
});
describe("language handling", () => {
test("should use provided language parameter", () => {
const responseValue = ["Opción 1"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
expect(result).toEqual(["choice-1"]);
});
test("should handle null language parameter by defaulting to 'default'", () => {
const responseValue = ["Option 1"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, null as any);
expect(result).toEqual(["choice-1"]);
});
test("should handle undefined language parameter by defaulting to 'default'", () => {
const responseValue = ["Option 1"];
const result = extractChoiceIdsFromResponse(
responseValue,
multipleChoiceMultiQuestion,
undefined as any
);
expect(result).toEqual(["choice-1"]);
});
});
});

View File

@@ -1,103 +1,20 @@
import "server-only";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { Prisma } from "@prisma/client";
import {
TResponse,
TResponseDataValue,
TResponseFilterCriteria,
TResponseHiddenFieldsFilter,
TResponseTtc,
TSurveyContactAttributes,
TSurveyMetaFieldFilter,
} from "@formbricks/types/responses";
import {
TSurvey,
TSurveyMultipleChoiceQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestion,
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { processResponseData } from "../responses";
import { getTodaysDateTimeFormatted } from "../time";
import { getFormattedDateTimeString } from "../utils/datetime";
import { sanitizeString } from "../utils/strings";
/**
* Extracts choice IDs from response values for multiple choice questions
* @param responseValue - The response value (string for single choice, array for multi choice)
* @param question - The survey question containing choices
* @param language - The language to match against (defaults to "default")
* @returns Array of choice IDs
*/
export const extractChoiceIdsFromResponse = (
responseValue: TResponseDataValue,
question: TSurveyQuestion,
language: string = "default"
): string[] => {
// Type guard to ensure the question has choices
if (
question.type !== "multipleChoiceMulti" &&
question.type !== "multipleChoiceSingle" &&
question.type !== "ranking" &&
question.type !== "pictureSelection"
) {
return [];
}
const isPictureSelection = question.type === "pictureSelection";
if (!responseValue) {
return [];
}
// For picture selection questions, the response value is already choice ID(s)
if (isPictureSelection) {
if (Array.isArray(responseValue)) {
// Multi-selection: array of choice IDs
return responseValue.filter((id): id is string => typeof id === "string");
} else if (typeof responseValue === "string") {
// Single selection: single choice ID
return [responseValue];
}
return [];
}
const defaultLanguage = language ?? "default";
// Helper function to find choice by label - eliminates duplication
const findChoiceByLabel = (choiceLabel: string): string | null => {
const targetChoice = question.choices.find((c) => {
// Try exact language match first
if (c.label[defaultLanguage] === choiceLabel) {
return true;
}
// Fall back to checking all language values
return Object.values(c.label).includes(choiceLabel);
});
return targetChoice?.id || "other";
};
if (Array.isArray(responseValue)) {
// Multiple choice case - response is an array of selected choice labels
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
} else if (typeof responseValue === "string") {
// Single choice case - response is a single choice label
const choiceId = findChoiceByLabel(responseValue);
return choiceId ? [choiceId] : [];
}
return [];
};
export const getChoiceIdByValue = (
value: string,
question: TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion | TSurveyPictureSelectionQuestion
) => {
if (question.type === "pictureSelection") {
return question.choices.find((choice) => choice.imageUrl === value)?.id ?? "other";
}
return question.choices.find((choice) => choice.label.default === value)?.id ?? "other";
};
export const calculateTtcTotal = (ttc: TResponseTtc) => {
const result = { ...ttc };
result._total = Object.values(result).reduce((acc: number, val: number) => acc + val, 0);
@@ -573,17 +490,10 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
return question.rows.map((row) => {
return `${idx + 1}. ${headline} - ${getLocalizedValue(row, "default")}`;
});
} else if (
question.type === "multipleChoiceMulti" ||
question.type === "multipleChoiceSingle" ||
question.type === "ranking"
) {
return [`${idx + 1}. ${headline}`, `${idx + 1}. ${headline} - Option ID`];
} else {
return [`${idx + 1}. ${headline}`];
}
});
const hiddenFields = survey.hiddenFields?.fieldIds || [];
const userAttributes =
survey.type === "app"
@@ -646,19 +556,6 @@ export const getResponsesJson = (
}
}
});
} else if (
question.type === "multipleChoiceMulti" ||
question.type === "multipleChoiceSingle" ||
question.type === "ranking"
) {
// Set the main response value
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
// Set the option IDs using the reusable function
if (questionHeadline[1]) {
const choiceIds = extractChoiceIdsFromResponse(answer, question, response.language || "default");
jsonData[idx][questionHeadline[1]] = choiceIds.join(", ");
}
} else {
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
}

View File

@@ -1,74 +1,30 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
import { isStringUrl, isValidCallbackUrl, testURLmatch } from "./url";
import { isValidCallbackUrl, testURLmatch } from "./url";
afterEach(() => {
cleanup();
});
describe("testURLmatch", () => {
// Mock translation function
const mockT = (key: string): string => {
const translations: Record<string, string> = {
"environments.actions.invalid_regex": "Please use a valid regular expression.",
"environments.actions.invalid_match_type": "The option selected is not available.",
};
return translations[key] || key;
};
const testCases: [string, string, TActionClassPageUrlRule, boolean][] = [
["https://example.com", "https://example.com", "exactMatch", true],
["https://example.com", "https://different.com", "exactMatch", false],
["https://example.com/page", "example.com", "contains", true],
["https://example.com", "different.com", "contains", false],
["https://example.com/page", "https://example.com", "startsWith", true],
["https://example.com", "https://different.com", "startsWith", false],
["https://example.com/page", "page", "endsWith", true],
["https://example.com/page", "different", "endsWith", false],
["https://example.com", "https://different.com", "notMatch", true],
["https://example.com", "https://example.com", "notMatch", false],
["https://example.com", "different", "notContains", true],
["https://example.com", "example", "notContains", false],
const testCases: [string, string, TActionClassPageUrlRule, string][] = [
["https://example.com", "https://example.com", "exactMatch", "yes"],
["https://example.com", "https://example.com/page", "contains", "no"],
["https://example.com/page", "https://example.com", "startsWith", "yes"],
["https://example.com/page", "page", "endsWith", "yes"],
["https://example.com", "https://other.com", "notMatch", "yes"],
["https://example.com", "other", "notContains", "yes"],
];
test.each(testCases)("returns %s for %s with rule %s", (testUrl, pageUrlValue, pageUrlRule, expected) => {
expect(testURLmatch(testUrl, pageUrlValue, pageUrlRule, mockT)).toBe(expected);
});
describe("matchesRegex rule", () => {
test("returns true when URL matches regex pattern", () => {
expect(testURLmatch("https://example.com/user/123", "user/\\d+", "matchesRegex", mockT)).toBe(true);
expect(testURLmatch("https://example.com/dashboard", "dashboard$", "matchesRegex", mockT)).toBe(true);
expect(testURLmatch("https://app.example.com", "^https://app", "matchesRegex", mockT)).toBe(true);
});
test("returns false when URL does not match regex pattern", () => {
expect(testURLmatch("https://example.com/user/abc", "user/\\d+", "matchesRegex", mockT)).toBe(false);
expect(testURLmatch("https://example.com/settings", "dashboard$", "matchesRegex", mockT)).toBe(false);
expect(testURLmatch("https://api.example.com", "^https://app", "matchesRegex", mockT)).toBe(false);
});
test("throws error for invalid regex pattern", () => {
expect(() => testURLmatch("https://example.com", "[invalid-regex", "matchesRegex", mockT)).toThrow(
"Please use a valid regular expression."
);
expect(() => testURLmatch("https://example.com", "*invalid", "matchesRegex", mockT)).toThrow(
"Please use a valid regular expression."
);
});
expect(testURLmatch(testUrl, pageUrlValue, pageUrlRule)).toBe(expected);
});
test("throws an error for invalid match type", () => {
expect(() =>
testURLmatch(
"https://example.com",
"https://example.com",
"invalidRule" as TActionClassPageUrlRule,
mockT
)
).toThrow("The option selected is not available.");
testURLmatch("https://example.com", "https://example.com", "invalidRule" as TActionClassPageUrlRule)
).toThrow("Invalid match type");
});
});
@@ -91,13 +47,3 @@ describe("isValidCallbackUrl", () => {
expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false);
});
});
describe("isStringUrl", () => {
test("returns true for valid URL", () => {
expect(isStringUrl("https://example.com")).toBe(true);
});
test("returns false for invalid URL", () => {
expect(isStringUrl("not-a-valid-url")).toBe(false);
});
});

View File

@@ -3,34 +3,23 @@ import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
export const testURLmatch = (
testUrl: string,
pageUrlValue: string,
pageUrlRule: TActionClassPageUrlRule,
t: (key: string) => string
): boolean => {
let regex: RegExp;
pageUrlRule: TActionClassPageUrlRule
): string => {
switch (pageUrlRule) {
case "exactMatch":
return testUrl === pageUrlValue;
return testUrl === pageUrlValue ? "yes" : "no";
case "contains":
return testUrl.includes(pageUrlValue);
return testUrl.includes(pageUrlValue) ? "yes" : "no";
case "startsWith":
return testUrl.startsWith(pageUrlValue);
return testUrl.startsWith(pageUrlValue) ? "yes" : "no";
case "endsWith":
return testUrl.endsWith(pageUrlValue);
return testUrl.endsWith(pageUrlValue) ? "yes" : "no";
case "notMatch":
return testUrl !== pageUrlValue;
return testUrl !== pageUrlValue ? "yes" : "no";
case "notContains":
return !testUrl.includes(pageUrlValue);
case "matchesRegex":
try {
regex = new RegExp(pageUrlValue);
} catch {
throw new Error(t("environments.actions.invalid_regex"));
}
return regex.test(testUrl);
return !testUrl.includes(pageUrlValue) ? "yes" : "no";
default:
throw new Error(t("environments.actions.invalid_match_type"));
throw new Error("Invalid match type");
}
};
@@ -49,12 +38,3 @@ export const isValidCallbackUrl = (url: string, WEBAPP_URL: string): boolean =>
return false;
}
};
export const isStringUrl = (url: string): boolean => {
try {
new URL(url);
return true;
} catch {
return false;
}
};

View File

@@ -124,7 +124,6 @@
"add_action": "Aktion hinzufügen",
"add_filter": "Filter hinzufügen",
"add_logo": "Logo hinzufügen",
"add_member": "Mitglied hinzufügen",
"add_project": "Projekt hinzufügen",
"add_to_team": "Zum Team hinzufügen",
"all": "Alle",
@@ -280,8 +279,6 @@
"on": "An",
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
"option_id": "Option-ID",
"option_ids": "Option-IDs",
"or": "oder",
"organization": "Organisation",
"organization_id": "Organisations-ID",
@@ -308,7 +305,6 @@
"privacy": "Datenschutz",
"product_manager": "Produktmanager",
"profile": "Profil",
"profile_id": "Profil-ID",
"project_configuration": "Projektkonfiguration",
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"project_id": "Projekt-ID",
@@ -389,7 +385,6 @@
"targeting": "Targeting",
"team": "Team",
"team_access": "Teamzugriff",
"team_id": "Team-ID",
"team_name": "Teamname",
"teams": "Zugriffskontrolle",
"teams_not_found": "Teams nicht gefunden",
@@ -508,21 +503,21 @@
"action_with_key_already_exists": "Aktion mit dem Schlüssel {key} existiert bereits",
"action_with_name_already_exists": "Aktion mit dem Namen {name} existiert bereits",
"add_css_class_or_id": "CSS-Klasse oder ID hinzufügen",
"add_regular_expression_here": "Fügen Sie hier einen regulären Ausdruck hinzu",
"add_url": "URL hinzufügen",
"click": "Klicken",
"contains": "enthält",
"create_action": "Aktion erstellen",
"css_selector": "CSS-Selektor",
"delete_action_text": "Bist Du sicher, dass Du diese Aktion löschen möchtest? Dadurch wird diese Aktion auch als Auslöser aus all deinen Umfragen entfernt.",
"display_name": "Anzeigename",
"does_not_contain": "Enthält nicht",
"does_not_exactly_match": "Stimmt nicht genau überein",
"eg_clicked_download": "z.B. 'Herunterladen' geklickt",
"eg_download_cta_click_on_home": "z.B. Download-CTA-Klick auf der Startseite",
"eg_install_app": "z.B. App installieren",
"eg_user_clicked_download_button": "z.B. Benutzer hat auf 'Herunterladen' geklickt",
"ends_with": "endet mit",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Teste eine URL, um zu sehen, ob der Nutzer deine Umfrage sehen würde.",
"enter_url": "z.B. https://app.com/dashboard",
"exactly_matches": "Stimmt exakt überein",
"exit_intent": "Will Seite verlassen",
"fifty_percent_scroll": "50% Scroll",
@@ -531,14 +526,9 @@
"if_a_user_clicks_a_button_with_a_specific_text": "Wenn ein Benutzer auf einen Button mit einem bestimmten Text klickt",
"in_your_code_read_more_in_our": "in deinem Code. Lies mehr in unserem",
"inner_text": "Innerer Text",
"invalid_action_type_code": "Ungültiger Aktionstyp für Code-Aktion",
"invalid_action_type_no_code": "Ungültiger Aktionstyp für NoCode-Aktion",
"invalid_css_selector": "Ungültiger CSS-Selektor",
"invalid_match_type": "Die ausgewählte Option ist nicht verfügbar.",
"invalid_regex": "Bitte verwenden Sie einen gültigen regulären Ausdruck.",
"limit_the_pages_on_which_this_action_gets_captured": "Begrenze die Seiten, auf denen diese Aktion erfasst wird",
"limit_to_specific_pages": "Auf bestimmte Seiten beschränken",
"matches_regex": "Entspricht Regex",
"on_all_pages": "Auf allen Seiten",
"page_filter": "Seitenfilter",
"page_view": "Seitenansicht",
@@ -558,9 +548,7 @@
"user_clicked_download_button": "Benutzer hat auf 'Herunterladen' geklickt",
"what_did_your_user_do": "Was hat dein Nutzer gemacht?",
"what_is_the_user_doing": "Was macht der Nutzer?",
"you_can_track_code_action_anywhere_in_your_app_using": "Du kannst Code-Aktionen überall in deiner App tracken mit",
"your_survey_would_be_shown_on_this_url": "Ihre Umfrage wäre unter dieser URL angezeigt.",
"your_survey_would_not_be_shown": "Ihre Umfrage wäre nicht angezeigt."
"you_can_track_code_action_anywhere_in_your_app_using": "Du kannst Code-Aktionen überall in deiner App tracken mit"
},
"connect": {
"congrats": "Glückwunsch!",
@@ -1291,7 +1279,6 @@
"change_anyway": "Trotzdem ändern",
"change_background": "Hintergrund ändern",
"change_question_type": "Fragetyp ändern",
"change_survey_type": "Die Änderung des Umfragetypen kann vorhandenen Zugriff beeinträchtigen",
"change_the_background_color_of_the_card": "Hintergrundfarbe der Karte ändern.",
"change_the_background_color_of_the_input_fields": "Hintergrundfarbe der Eingabefelder ändern.",
"change_the_background_to_a_color_image_or_animation": "Hintergrund zu einer Farbe, einem Bild oder einer Animation ändern.",
@@ -1302,7 +1289,6 @@
"change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.",
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
"changes_saved": "Änderungen gespeichert.",
"changing_survey_type_will_remove_existing_distribution_channels": "\"Das Ändern des Umfragetypen beeinflusst, wie er geteilt werden kann. Wenn Teilnehmer bereits Zugriffslinks für den aktuellen Typ haben, könnten sie das Zugriffsrecht nach dem Wechsel verlieren.\"",
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
"checkbox_label": "Checkbox-Beschriftung",
@@ -1316,7 +1302,7 @@
"columns": "Spalten",
"company": "Firma",
"company_logo": "Firmenlogo",
"completed_responses": "unvollständige oder vollständige Antworten.",
"completed_responses": "abgeschlossene Antworten",
"concat": "Verketten +",
"conditional_logic": "Bedingte Logik",
"confirm_default_language": "Standardsprache bestätigen",
@@ -1621,11 +1607,6 @@
"zip": "Postleitzahl"
},
"error_deleting_survey": "Beim Löschen der Umfrage ist ein Fehler aufgetreten",
"filter": {
"complete_and_partial_responses": "Vollständige und Teilantworten",
"complete_responses": "Vollständige Antworten",
"partial_responses": "Teilantworten"
},
"new_survey": "Neue Umfrage",
"no_surveys_created_yet": "Noch keine Umfragen erstellt",
"open_options": "Optionen öffnen",
@@ -1644,7 +1625,6 @@
"company": "Firma",
"completed": "Erledigt ✅",
"country": "Land",
"delete_response_confirmation": "Dies wird die Umfrageantwort einschließlich aller Antworten, Notizen, Tags, angehängter Dokumente und Antwortmetadaten löschen.",
"device": "Gerät",
"device_info": "Geräteinfo",
"email": "E-Mail",
@@ -1819,6 +1799,7 @@
"last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr",
"no_responses_found": "Keine Antworten gefunden",
"only_completed": "Nur vollständige Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
"qr_code": "QR-Code",
@@ -2790,8 +2771,6 @@
"star_rating_survey_question_3_placeholder": "Schreib hier deine Antwort...",
"star_rating_survey_question_3_subheader": "Hilf uns, deine Erfahrung zu verbessern.",
"statement_call_to_action": "Aussage (Call-to-Action)",
"strongly_agree": "Stimme voll und ganz zu",
"strongly_disagree": "Stimme überhaupt nicht zu",
"supportive_work_culture_survey_description": "Bewerte die Wahrnehmung der Mitarbeiter bezüglich Führungsunterstützung, Kommunikation und des gesamten Arbeitsumfelds.",
"supportive_work_culture_survey_name": "Unterstützende Arbeitskultur",
"supportive_work_culture_survey_question_1_headline": "Mein Vorgesetzter bietet mir die Unterstützung, die ich zur Erledigung meiner Arbeit benötige.",
@@ -2847,18 +2826,6 @@
"understand_purchase_intention_question_2_headline": "Verstanden. Was ist dein Hauptgrund für den heutigen Besuch?",
"understand_purchase_intention_question_2_placeholder": "Tippe deine Antwort hier...",
"understand_purchase_intention_question_3_headline": "Was, wenn überhaupt, hält Dich heute davon ab, einen Kauf zu tätigen?",
"understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier...",
"usability_question_10_headline": "Ich musste viel lernen, bevor ich das System richtig benutzen konnte.",
"usability_question_1_headline": "Ich würde dieses System wahrscheinlich häufig verwenden.",
"usability_question_2_headline": "Das System wirkte komplizierter als nötig.",
"usability_question_3_headline": "Das System war leicht zu verstehen.",
"usability_question_4_headline": "Ich glaube, ich bräuchte Unterstützung von einem Technik-Experten, um dieses System zu nutzen.",
"usability_question_5_headline": "Alles im System schien gut zusammenzuarbeiten.",
"usability_question_6_headline": "Das System fühlte sich inkonsistent an, wie die Dinge funktionierten.",
"usability_question_7_headline": "Ich glaube, die meisten Menschen könnten schnell lernen, dieses System zu benutzen.",
"usability_question_8_headline": "Die Nutzung des Systems fühlte sich wie eine Belastung an.",
"usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.",
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
"usability_score_name": "System Usability Score Survey (SUS)"
"understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier..."
}
}

View File

@@ -124,7 +124,6 @@
"add_action": "Add action",
"add_filter": "Add filter",
"add_logo": "Add logo",
"add_member": "Add member",
"add_project": "Add project",
"add_to_team": "Add to team",
"all": "All",
@@ -280,8 +279,6 @@
"on": "On",
"only_one_file_allowed": "Only one file is allowed",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
"option_id": "Option ID",
"option_ids": "Option IDs",
"or": "or",
"organization": "Organization",
"organization_id": "Organization ID",
@@ -308,7 +305,6 @@
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"profile": "Profile",
"profile_id": "Profile ID",
"project_configuration": "Project's Configuration",
"project_creation_description": "Organize surveys in projects for better access control.",
"project_id": "Project ID",
@@ -389,7 +385,6 @@
"targeting": "Targeting",
"team": "Team",
"team_access": "Team Access",
"team_id": "Team ID",
"team_name": "Team name",
"teams": "Access Control",
"teams_not_found": "Teams not found",
@@ -508,21 +503,21 @@
"action_with_key_already_exists": "Action with key {key} already exists",
"action_with_name_already_exists": "Action with name {name} already exists",
"add_css_class_or_id": "Add CSS class or id",
"add_regular_expression_here": "Add a regular expression here",
"add_url": "Add URL",
"click": "Click",
"contains": "Contains",
"create_action": "Create action",
"css_selector": "CSS Selector",
"delete_action_text": "Are you sure you want to delete this action? This also removes this action as a trigger from all your surveys.",
"display_name": "Display name",
"does_not_contain": "Does not contain",
"does_not_exactly_match": "Does not exactly match",
"eg_clicked_download": "E.g. Clicked Download",
"eg_download_cta_click_on_home": "e.g. download_cta_click_on_home",
"eg_install_app": "E.g. Install App",
"eg_user_clicked_download_button": "E.g. User clicked Download Button",
"ends_with": "Ends with",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Enter a URL to see if a user visiting it would be tracked.",
"enter_url": "e.g. https://app.com/dashboard",
"exactly_matches": "Exactly matches",
"exit_intent": "Exit Intent",
"fifty_percent_scroll": "50% Scroll",
@@ -531,14 +526,9 @@
"if_a_user_clicks_a_button_with_a_specific_text": "If a user clicks a button with a specific text",
"in_your_code_read_more_in_our": "in your code. Read more in our",
"inner_text": "Inner Text",
"invalid_action_type_code": "Invalid action type for code action.",
"invalid_action_type_no_code": "Invalid action type for noCode action.",
"invalid_css_selector": "Invalid CSS Selector",
"invalid_match_type": "The option selected is not available.",
"invalid_regex": "Please use a valid regular expression.",
"limit_the_pages_on_which_this_action_gets_captured": "Limit the pages on which this action gets captured",
"limit_to_specific_pages": "Limit to specific pages",
"matches_regex": "Matches regex",
"on_all_pages": "On all pages",
"page_filter": "Page filter",
"page_view": "Page View",
@@ -558,9 +548,7 @@
"user_clicked_download_button": "User clicked Download Button",
"what_did_your_user_do": "What did your user do?",
"what_is_the_user_doing": "What is the user doing?",
"you_can_track_code_action_anywhere_in_your_app_using": "You can track code action anywhere in your app using",
"your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.",
"your_survey_would_not_be_shown": "Your survey would not be shown."
"you_can_track_code_action_anywhere_in_your_app_using": "You can track code action anywhere in your app using"
},
"connect": {
"congrats": "Congrats!",
@@ -1291,7 +1279,6 @@
"change_anyway": "Change anyway",
"change_background": "Change background",
"change_question_type": "Change question type",
"change_survey_type": "Switching survey type affects existing access",
"change_the_background_color_of_the_card": "Change the background color of the card.",
"change_the_background_color_of_the_input_fields": "Change the background color of the input fields.",
"change_the_background_to_a_color_image_or_animation": "Change the background to a color, image or animation.",
@@ -1302,7 +1289,6 @@
"change_the_placement_of_this_survey": "Change the placement of this survey.",
"change_the_question_color_of_the_survey": "Change the question color of the survey.",
"changes_saved": "Changes saved.",
"changing_survey_type_will_remove_existing_distribution_channels": "Changing the survey type will affect how it can be shared. If respondents already have access links for the current type, they may lose access after the switch.",
"character_limit_toggle_description": "Limit how short or long an answer can be.",
"character_limit_toggle_title": "Add character limits",
"checkbox_label": "Checkbox Label",
@@ -1316,7 +1302,7 @@
"columns": "Columns",
"company": "Company",
"company_logo": "Company logo",
"completed_responses": "partial or completed responses.",
"completed_responses": "completed responses.",
"concat": "Concat +",
"conditional_logic": "Conditional Logic",
"confirm_default_language": "Confirm default language",
@@ -1621,11 +1607,6 @@
"zip": "Zip"
},
"error_deleting_survey": "An error occured while deleting survey",
"filter": {
"complete_and_partial_responses": "Complete and partial responses",
"complete_responses": "Complete responses",
"partial_responses": "Partial responses"
},
"new_survey": "New Survey",
"no_surveys_created_yet": "No surveys created yet",
"open_options": "Open options",
@@ -1644,7 +1625,6 @@
"company": "Company",
"completed": "Completed ✅",
"country": "Country",
"delete_response_confirmation": "This will delete the survey response, including all answers, notes, tags, attached documents, and response metadata.",
"device": "Device",
"device_info": "Device info",
"email": "Email",
@@ -1819,6 +1799,7 @@
"last_quarter": "Last quarter",
"last_year": "Last year",
"no_responses_found": "No responses found",
"only_completed": "Only completed",
"other_values_found": "Other values found",
"overall": "Overall",
"qr_code": "QR code",
@@ -2790,8 +2771,6 @@
"star_rating_survey_question_3_placeholder": "Type your answer here...",
"star_rating_survey_question_3_subheader": "Help us improve your experience.",
"statement_call_to_action": "Statement (Call to Action)",
"strongly_agree": "Strongly Agree",
"strongly_disagree": "Strongly Disagree",
"supportive_work_culture_survey_description": "Assess employee perceptions of leadership support, communication, and the overall work environment.",
"supportive_work_culture_survey_name": "Supportive Work Culture",
"supportive_work_culture_survey_question_1_headline": "My manager provides me with the support I need to complete my work.",
@@ -2847,18 +2826,6 @@
"understand_purchase_intention_question_2_headline": "Got it. What's your primary reason for visiting today?",
"understand_purchase_intention_question_2_placeholder": "Type your answer here...",
"understand_purchase_intention_question_3_headline": "What, if anything, is holding you back from making a purchase today?",
"understand_purchase_intention_question_3_placeholder": "Type your answer here...",
"usability_question_10_headline": " I had to learn a lot before I could start using the system properly.",
"usability_question_1_headline": "Id probably use this system often.",
"usability_question_2_headline": "The system felt more complicated than it needed to be.",
"usability_question_3_headline": "The system was easy to figure out.",
"usability_question_4_headline": "I think Id need help from a tech expert to use this system.",
"usability_question_5_headline": "Everything in the system seemed to work well together.",
"usability_question_6_headline": "The system felt inconsistent in how things worked.",
"usability_question_7_headline": "I think most people could learn to use this system quickly.",
"usability_question_8_headline": "Using the system felt like a hassle.",
"usability_question_9_headline": "I felt confident while using the system.",
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
"understand_purchase_intention_question_3_placeholder": "Type your answer here..."
}
}

View File

@@ -124,7 +124,6 @@
"add_action": "Ajouter une action",
"add_filter": "Ajouter un filtre",
"add_logo": "Ajouter un logo",
"add_member": "Ajouter un membre",
"add_project": "Ajouter un projet",
"add_to_team": "Ajouter à l'équipe",
"all": "Tout",
@@ -280,8 +279,6 @@
"on": "Sur",
"only_one_file_allowed": "Un seul fichier est autorisé",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
"option_id": "Identifiant de l'option",
"option_ids": "Identifiants des options",
"or": "ou",
"organization": "Organisation",
"organization_id": "ID de l'organisation",
@@ -308,7 +305,6 @@
"privacy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"profile": "Profil",
"profile_id": "Identifiant de profil",
"project_configuration": "Configuration du projet",
"project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
"project_id": "ID de projet",
@@ -389,7 +385,6 @@
"targeting": "Ciblage",
"team": "Équipe",
"team_access": "Accès Équipe",
"team_id": "Équipe ID",
"team_name": "Nom de l'équipe",
"teams": "Contrôle d'accès",
"teams_not_found": "Équipes non trouvées",
@@ -508,21 +503,21 @@
"action_with_key_already_exists": "L'action avec la clé '{'key'}' existe déjà",
"action_with_name_already_exists": "L'action avec le nom '{'name'}' existe déjà",
"add_css_class_or_id": "Ajouter une classe ou un identifiant CSS",
"add_regular_expression_here": "Ajoutez une expression régulière ici",
"add_url": "Ajouter une URL",
"click": "Cliquez",
"contains": "Contient",
"create_action": "Créer une action",
"css_selector": "Sélecteur CSS",
"delete_action_text": "Êtes-vous sûr de vouloir supprimer cette action ? Cela supprime également cette action en tant que déclencheur de toutes vos enquêtes.",
"display_name": "Nom d'affichage",
"does_not_contain": "Ne contient pas",
"does_not_exactly_match": "Ne correspond pas exactement",
"eg_clicked_download": "Par exemple, cliqué sur Télécharger",
"eg_download_cta_click_on_home": "Par exemple, cliquez sur le CTA de téléchargement sur la page d'accueil",
"eg_install_app": "Par exemple, installer l'application",
"eg_user_clicked_download_button": "Par exemple, l'utilisateur a cliqué sur le bouton de téléchargement.",
"ends_with": "Se termine par",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Saisissez une URL pour voir si un utilisateur la visitant serait suivi.",
"enter_url": "par exemple https://app.com/dashboard",
"exactly_matches": "Correspondance exacte",
"exit_intent": "Intention de sortie",
"fifty_percent_scroll": "50% Défilement",
@@ -531,14 +526,9 @@
"if_a_user_clicks_a_button_with_a_specific_text": "Si un utilisateur clique sur un bouton avec un texte spécifique",
"in_your_code_read_more_in_our": "dans votre code. En savoir plus dans notre",
"inner_text": "Texte interne",
"invalid_action_type_code": "Type d'action invalide pour action code",
"invalid_action_type_no_code": "Type d'action invalide pour action noCode",
"invalid_css_selector": "Sélecteur CSS invalide",
"invalid_match_type": "L'option sélectionnée n'est pas disponible.",
"invalid_regex": "Veuillez utiliser une expression régulière valide.",
"limit_the_pages_on_which_this_action_gets_captured": "Limiter les pages sur lesquelles cette action est capturée",
"limit_to_specific_pages": "Limiter à des pages spécifiques",
"matches_regex": "Correspond à l'expression régulière",
"on_all_pages": "Sur toutes les pages",
"page_filter": "Filtre de page",
"page_view": "Vue de page",
@@ -558,9 +548,7 @@
"user_clicked_download_button": "L'utilisateur a cliqué sur le bouton de téléchargement",
"what_did_your_user_do": "Que fait votre utilisateur ?",
"what_is_the_user_doing": "Que fait l'utilisateur ?",
"you_can_track_code_action_anywhere_in_your_app_using": "Vous pouvez suivre l'action du code partout dans votre application en utilisant",
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
"your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée."
"you_can_track_code_action_anywhere_in_your_app_using": "Vous pouvez suivre l'action du code partout dans votre application en utilisant"
},
"connect": {
"congrats": "Félicitations !",
@@ -1207,7 +1195,7 @@
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques:",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
"add": "Ajouter +",
"add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête",
"add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.",
@@ -1291,7 +1279,6 @@
"change_anyway": "Changer de toute façon",
"change_background": "Changer l'arrière-plan",
"change_question_type": "Changer le type de question",
"change_survey_type": "Le changement de type de sondage affecte l'accès existant",
"change_the_background_color_of_the_card": "Changez la couleur de fond de la carte.",
"change_the_background_color_of_the_input_fields": "Changez la couleur de fond des champs de saisie.",
"change_the_background_to_a_color_image_or_animation": "Changez l'arrière-plan en une couleur, une image ou une animation.",
@@ -1302,7 +1289,6 @@
"change_the_placement_of_this_survey": "Changez le placement de cette enquête.",
"change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.",
"changes_saved": "Modifications enregistrées.",
"changing_survey_type_will_remove_existing_distribution_channels": "Le changement du type de sondage affectera la façon dont il peut être partagé. Si les répondants ont déjà des liens d'accès pour le type actuel, ils peuvent perdre l'accès après le changement.",
"character_limit_toggle_description": "Limitez la longueur des réponses.",
"character_limit_toggle_title": "Ajouter des limites de caractères",
"checkbox_label": "Étiquette de case à cocher",
@@ -1316,7 +1302,7 @@
"columns": "Colonnes",
"company": "Société",
"company_logo": "Logo de l'entreprise",
"completed_responses": "des réponses partielles ou complètes.",
"completed_responses": "réponses complètes.",
"concat": "Concat +",
"conditional_logic": "Logique conditionnelle",
"confirm_default_language": "Confirmer la langue par défaut",
@@ -1621,11 +1607,6 @@
"zip": "Zip"
},
"error_deleting_survey": "Une erreur est survenue lors de la suppression de l'enquête.",
"filter": {
"complete_and_partial_responses": "Réponses complètes et partielles",
"complete_responses": "Réponses complètes",
"partial_responses": "Réponses partielles"
},
"new_survey": "Nouveau Sondage",
"no_surveys_created_yet": "Aucun sondage créé pour le moment",
"open_options": "Ouvrir les options",
@@ -1644,7 +1625,6 @@
"company": "Société",
"completed": "Terminé ✅",
"country": "Pays",
"delete_response_confirmation": "\"Cela supprimera la réponse au sondage, y compris toutes les réponses, notes, étiquettes, documents joints et métadonnées de réponse.\"",
"device": "Dispositif",
"device_info": "Informations sur l'appareil",
"email": "Email",
@@ -1819,6 +1799,7 @@
"last_quarter": "dernier trimestre",
"last_year": "l'année dernière",
"no_responses_found": "Aucune réponse trouvée",
"only_completed": "Uniquement terminé",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"qr_code": "Code QR",
@@ -2790,8 +2771,6 @@
"star_rating_survey_question_3_placeholder": "Tapez votre réponse ici...",
"star_rating_survey_question_3_subheader": "Aidez-nous à améliorer votre expérience.",
"statement_call_to_action": "Déclaration (Appel à l'action)",
"strongly_agree": "Tout à fait d'accord",
"strongly_disagree": "Fortement en désaccord",
"supportive_work_culture_survey_description": "Évaluer les perceptions des employés concernant le soutien des dirigeants, la communication et l'environnement de travail global.",
"supportive_work_culture_survey_name": "Culture de travail bienveillante",
"supportive_work_culture_survey_question_1_headline": "Mon manager me fournit le soutien dont j'ai besoin pour accomplir mon travail.",
@@ -2847,18 +2826,6 @@
"understand_purchase_intention_question_2_headline": "Compris. Quelle est votre raison principale de visite aujourd'hui ?",
"understand_purchase_intention_question_2_placeholder": "Entrez votre réponse ici...",
"understand_purchase_intention_question_3_headline": "Qu'est-ce qui vous empêche de faire un achat aujourd'hui, s'il y a quelque chose ?",
"understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici...",
"usability_question_10_headline": "J'ai dû beaucoup apprendre avant de pouvoir utiliser correctement le système.",
"usability_question_1_headline": "Je pourrais probablement utiliser ce système souvent.",
"usability_question_2_headline": "Le système semblait plus compliqué qu'il ne devait l'être.",
"usability_question_3_headline": "Le système était facile à comprendre.",
"usability_question_4_headline": "Je pense que j'aurais besoin de l'aide d'un expert en technologie pour utiliser ce système.",
"usability_question_5_headline": "Tout dans le système semblait bien fonctionner ensemble.",
"usability_question_6_headline": "Le système semblait incohérent dans la façon dont les choses fonctionnaient.",
"usability_question_7_headline": "Je pense que la plupart des gens pourraient apprendre à utiliser ce système rapidement.",
"usability_question_8_headline": "Utiliser le système semblait être une corvée.",
"usability_question_9_headline": "Je me suis senti confiant en utilisant le système.",
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
"understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici..."
}
}

View File

@@ -124,7 +124,6 @@
"add_action": "Adicionar ação",
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logo",
"add_member": "Adicionar membro",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipe",
"all": "Todos",
@@ -280,8 +279,6 @@
"on": "ligado",
"only_one_file_allowed": "É permitido apenas um arquivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
"option_id": "ID da opção",
"option_ids": "IDs da Opção",
"or": "ou",
"organization": "organização",
"organization_id": "ID da Organização",
@@ -308,7 +305,6 @@
"privacy": "Política de Privacidade",
"product_manager": "Gerente de Produto",
"profile": "Perfil",
"profile_id": "ID de Perfil",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
"project_id": "ID do Projeto",
@@ -389,7 +385,6 @@
"targeting": "mirando",
"team": "Time",
"team_access": "Acesso da equipe",
"team_id": "ID da Equipe",
"team_name": "Nome da equipe",
"teams": "Controle de Acesso",
"teams_not_found": "Equipes não encontradas",
@@ -508,21 +503,21 @@
"action_with_key_already_exists": "Ação com a chave {key} já existe",
"action_with_name_already_exists": "Ação com o nome {name} já existe",
"add_css_class_or_id": "Adicionar classe ou id CSS",
"add_regular_expression_here": "Adicionar uma expressão regular aqui",
"add_url": "Adicionar URL",
"click": "Clica",
"contains": "contém",
"create_action": "criar ação",
"css_selector": "Seletor CSS",
"delete_action_text": "Tem certeza de que quer deletar essa ação? Isso também vai remover essa ação como gatilho de todas as suas pesquisas.",
"display_name": "Nome de exibição",
"does_not_contain": "não contém",
"does_not_exactly_match": "Não bate exatamente",
"eg_clicked_download": "Por exemplo, clicou em baixar",
"eg_download_cta_click_on_home": "e.g. download_cta_click_on_home",
"eg_install_app": "Ex: Instalar App",
"eg_user_clicked_download_button": "Por exemplo, usuário clicou no botão de download",
"ends_with": "Termina com",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Digite uma URL para ver se um usuário que a visita seria rastreado.",
"enter_url": "ex.: https://app.com/dashboard",
"exactly_matches": "Combina exatamente",
"exit_intent": "Intenção de Saída",
"fifty_percent_scroll": "Rolar 50%",
@@ -531,14 +526,9 @@
"if_a_user_clicks_a_button_with_a_specific_text": "Se um usuário clicar em um botão com um texto específico",
"in_your_code_read_more_in_our": "no seu código. Leia mais em nosso",
"inner_text": "Texto Interno",
"invalid_action_type_code": "Tipo de ação inválido para ação com código",
"invalid_action_type_no_code": "Tipo de ação inválido para ação noCode",
"invalid_css_selector": "Seletor CSS Inválido",
"invalid_match_type": "A opção selecionada não está disponível.",
"invalid_regex": "Por favor, use uma expressão regular válida.",
"limit_the_pages_on_which_this_action_gets_captured": "Limite as páginas nas quais essa ação é capturada",
"limit_to_specific_pages": "Limitar a páginas específicas",
"matches_regex": "Correspondência regex",
"on_all_pages": "Em todas as páginas",
"page_filter": "filtro de página",
"page_view": "Visualização de Página",
@@ -558,9 +548,7 @@
"user_clicked_download_button": "Usuário clicou no botão de download",
"what_did_your_user_do": "O que seu usuário fez?",
"what_is_the_user_doing": "O que o usuário tá fazendo?",
"you_can_track_code_action_anywhere_in_your_app_using": "Você pode rastrear ações de código em qualquer lugar do seu app usando",
"your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.",
"your_survey_would_not_be_shown": "Sua pesquisa não seria exibida."
"you_can_track_code_action_anywhere_in_your_app_using": "Você pode rastrear ações de código em qualquer lugar do seu app usando"
},
"connect": {
"congrats": "Parabéns!",
@@ -1291,7 +1279,6 @@
"change_anyway": "Mudar mesmo assim",
"change_background": "Mudar fundo",
"change_question_type": "Mudar tipo de pergunta",
"change_survey_type": "Alterar o tipo de pesquisa afeta o acesso existente",
"change_the_background_color_of_the_card": "Muda a cor de fundo do cartão.",
"change_the_background_color_of_the_input_fields": "Mude a cor de fundo dos campos de entrada.",
"change_the_background_to_a_color_image_or_animation": "Mude o fundo para uma cor, imagem ou animação.",
@@ -1302,7 +1289,6 @@
"change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.",
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
"changes_saved": "Mudanças salvas.",
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de pesquisa afetará a forma como ela pode ser compartilhada. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
"checkbox_label": "Rótulo da Caixa de Seleção",
@@ -1316,7 +1302,7 @@
"columns": "colunas",
"company": "empresa",
"company_logo": "Logo da empresa",
"completed_responses": "respostas parciais ou completas.",
"completed_responses": "respostas completas",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",
@@ -1621,11 +1607,6 @@
"zip": "Fecho éclair"
},
"error_deleting_survey": "Ocorreu um erro ao deletar a pesquisa",
"filter": {
"complete_and_partial_responses": "Respostas completas e parciais",
"complete_responses": "Respostas completas",
"partial_responses": "Respostas parciais"
},
"new_survey": "Nova Pesquisa",
"no_surveys_created_yet": "Ainda não foram criadas pesquisas",
"open_options": "Abre opções",
@@ -1644,7 +1625,6 @@
"company": "empresa",
"completed": "Concluído ✅",
"country": "País",
"delete_response_confirmation": "Isso excluirá a resposta da pesquisa, incluindo todas as respostas, notas, etiquetas, documentos anexados e metadados da resposta.",
"device": "dispositivo",
"device_info": "Informações do dispositivo",
"email": "Email",
@@ -1819,6 +1799,7 @@
"last_quarter": "Último trimestre",
"last_year": "Último ano",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Somente concluído",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"qr_code": "Código QR",
@@ -2790,8 +2771,6 @@
"star_rating_survey_question_3_placeholder": "Digite sua resposta aqui...",
"star_rating_survey_question_3_subheader": "Ajude-nos a melhorar sua experiência.",
"statement_call_to_action": "Declaração (Chamada para Ação)",
"strongly_agree": "Concordo totalmente",
"strongly_disagree": "Discordo totalmente",
"supportive_work_culture_survey_description": "Avalie a percepção dos funcionários sobre o suporte da liderança, comunicação e ambiente geral de trabalho.",
"supportive_work_culture_survey_name": "Cultura de Trabalho de Apoio",
"supportive_work_culture_survey_question_1_headline": "Meu gestor me oferece o suporte necessário para realizar meu trabalho.",
@@ -2847,18 +2826,6 @@
"understand_purchase_intention_question_2_headline": "Entendi. Qual é o principal motivo da sua visita hoje?",
"understand_purchase_intention_question_2_placeholder": "Digite sua resposta aqui...",
"understand_purchase_intention_question_3_headline": "O que, se é que tem algo, está te impedindo de fazer a compra hoje?",
"understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui...",
"usability_question_10_headline": "Tive que aprender muito antes de poder começar a usar o sistema corretamente.",
"usability_question_1_headline": "Provavelmente eu usaria este sistema frequentemente.",
"usability_question_2_headline": "O sistema parecia mais complicado do que precisava ser.",
"usability_question_3_headline": "O sistema foi fácil de entender.",
"usability_question_4_headline": "Acho que precisaria da ajuda de um especialista em tecnologia para usar este sistema.",
"usability_question_5_headline": "Tudo no sistema parecia funcionar bem juntos.",
"usability_question_6_headline": "O sistema parecia inconsistente em como as coisas funcionavam.",
"usability_question_7_headline": "Eu acho que a maioria das pessoas poderia aprender a usar este sistema rapidamente.",
"usability_question_8_headline": "Usar o sistema foi uma dor de cabeça.",
"usability_question_9_headline": "Me senti confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
"understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui..."
}
}

View File

@@ -124,7 +124,6 @@
"add_action": "Adicionar ação",
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logótipo",
"add_member": "Adicionar membro",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipa",
"all": "Todos",
@@ -280,8 +279,6 @@
"on": "Ligado",
"only_one_file_allowed": "Apenas um ficheiro é permitido",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
"option_id": "ID de Opção",
"option_ids": "IDs de Opção",
"or": "ou",
"organization": "Organização",
"organization_id": "ID da Organização",
@@ -308,7 +305,6 @@
"privacy": "Política de Privacidade",
"product_manager": "Gestor de Produto",
"profile": "Perfil",
"profile_id": "ID do Perfil",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
"project_id": "ID do Projeto",
@@ -389,7 +385,6 @@
"targeting": "Segmentação",
"team": "Equipa",
"team_access": "Acesso da Equipa",
"team_id": "ID da Equipa",
"team_name": "Nome da equipa",
"teams": "Controlo de Acesso",
"teams_not_found": "Equipas não encontradas",
@@ -508,21 +503,21 @@
"action_with_key_already_exists": "Ação com a chave {key} já existe",
"action_with_name_already_exists": "Ação com o nome {name} já existe",
"add_css_class_or_id": "Adicionar classe ou id CSS",
"add_regular_expression_here": "Adicione uma expressão regular aqui",
"add_url": "Adicionar URL",
"click": "Clique",
"contains": "Contém",
"create_action": "Criar ação",
"css_selector": "Seletor CSS",
"delete_action_text": "Tem a certeza de que deseja eliminar esta ação? Isto também remove esta ação como um gatilho de todos os seus inquéritos.",
"display_name": "Nome de exibição",
"does_not_contain": "Não contém",
"does_not_exactly_match": "Não corresponde exatamente",
"eg_clicked_download": "Por exemplo, Clicou em Descarregar",
"eg_download_cta_click_on_home": "por exemplo, descarregar_cta_clicar_em_home",
"eg_install_app": "Ex. Instalar App",
"eg_user_clicked_download_button": "Por exemplo, Utilizador clicou no Botão Descarregar",
"ends_with": "Termina com",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Introduza um URL para ver se um utilizador que o visita seria rastreado.",
"enter_url": "por exemplo, https://app.com/dashboard",
"exactly_matches": "Corresponde exatamente",
"exit_intent": "Intenção de Saída",
"fifty_percent_scroll": "Rolar 50%",
@@ -531,14 +526,9 @@
"if_a_user_clicks_a_button_with_a_specific_text": "Se um utilizador clicar num botão com um texto específico",
"in_your_code_read_more_in_our": "no seu código. Leia mais no nosso",
"inner_text": "Texto Interno",
"invalid_action_type_code": "Tipo de ação inválido para ação de código",
"invalid_action_type_no_code": "Tipo de ação inválido para ação noCode",
"invalid_css_selector": "Seletor CSS inválido",
"invalid_match_type": "A opção selecionada não está disponível.",
"invalid_regex": "Por favor, utilize uma expressão regular válida.",
"limit_the_pages_on_which_this_action_gets_captured": "Limitar as páginas nas quais esta ação é capturada",
"limit_to_specific_pages": "Limitar a páginas específicas",
"matches_regex": "Coincide com regex",
"on_all_pages": "Em todas as páginas",
"page_filter": "Filtro de página",
"page_view": "Visualização de Página",
@@ -558,9 +548,7 @@
"user_clicked_download_button": "Utilizador clicou no Botão Descarregar",
"what_did_your_user_do": "O que fez o seu utilizador?",
"what_is_the_user_doing": "O que está o utilizador a fazer?",
"you_can_track_code_action_anywhere_in_your_app_using": "Pode rastrear a ação do código em qualquer lugar na sua aplicação usando",
"your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.",
"your_survey_would_not_be_shown": "O seu inquérito não seria mostrado."
"you_can_track_code_action_anywhere_in_your_app_using": "Pode rastrear a ação do código em qualquer lugar na sua aplicação usando"
},
"connect": {
"congrats": "Parabéns!",
@@ -1291,7 +1279,6 @@
"change_anyway": "Alterar mesmo assim",
"change_background": "Alterar fundo",
"change_question_type": "Alterar tipo de pergunta",
"change_survey_type": "Alterar o tipo de inquérito afeta o acesso existente",
"change_the_background_color_of_the_card": "Alterar a cor de fundo do cartão",
"change_the_background_color_of_the_input_fields": "Alterar a cor de fundo dos campos de entrada",
"change_the_background_to_a_color_image_or_animation": "Altere o fundo para uma cor, imagem ou animação",
@@ -1302,7 +1289,6 @@
"change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.",
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
"changes_saved": "Alterações guardadas.",
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de inquérito afetará como ele pode ser partilhado. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
"checkbox_label": "Rótulo da Caixa de Seleção",
@@ -1316,7 +1302,7 @@
"columns": "Colunas",
"company": "Empresa",
"company_logo": "Logotipo da empresa",
"completed_responses": "respostas parciais ou completas",
"completed_responses": "respostas concluídas",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",
@@ -1621,11 +1607,6 @@
"zip": "Comprimir"
},
"error_deleting_survey": "Ocorreu um erro ao eliminar o questionário",
"filter": {
"complete_and_partial_responses": "Respostas completas e parciais",
"complete_responses": "Respostas completas",
"partial_responses": "Respostas parciais"
},
"new_survey": "Novo inquérito",
"no_surveys_created_yet": "Ainda não foram criados questionários",
"open_options": "Abrir opções",
@@ -1644,7 +1625,6 @@
"company": "Empresa",
"completed": "Concluído ✅",
"country": "País",
"delete_response_confirmation": "Isto irá eliminar a resposta ao questionário, incluindo todas as respostas, notas, etiquetas, documentos anexados e metadados da resposta.",
"device": "Dispositivo",
"device_info": "Informações do dispositivo",
"email": "Email",
@@ -1819,6 +1799,7 @@
"last_quarter": "Último trimestre",
"last_year": "Ano passado",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Apenas concluído",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"qr_code": "Código QR",
@@ -2790,8 +2771,6 @@
"star_rating_survey_question_3_placeholder": "Escreva a sua resposta aqui...",
"star_rating_survey_question_3_subheader": "Ajude-nos a melhorar a sua experiência.",
"statement_call_to_action": "Declaração (Chamada para Ação)",
"strongly_agree": "Concordo totalmente",
"strongly_disagree": "Discordo totalmente",
"supportive_work_culture_survey_description": "Avaliar as perceções dos funcionários sobre o apoio da liderança, comunicação e o ambiente de trabalho geral.",
"supportive_work_culture_survey_name": "Cultura de Trabalho de Apoio",
"supportive_work_culture_survey_question_1_headline": "O meu gestor fornece-me o apoio de que preciso para concluir o meu trabalho.",
@@ -2847,18 +2826,6 @@
"understand_purchase_intention_question_2_headline": "Entendido. Qual é a sua principal razão para visitar hoje?",
"understand_purchase_intention_question_2_placeholder": "Escreva a sua resposta aqui...",
"understand_purchase_intention_question_3_headline": "O que, se alguma coisa, o está a impedir de fazer uma compra hoje?",
"understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui...",
"usability_question_10_headline": "Tive que aprender muito antes de poder começar a usar o sistema corretamente.",
"usability_question_1_headline": "Provavelmente usaria este sistema com frequência.",
"usability_question_2_headline": "O sistema parecia mais complicado do que precisava ser.",
"usability_question_3_headline": "O sistema foi fácil de entender.",
"usability_question_4_headline": "Acho que precisaria de ajuda de um especialista em tecnologia para utilizar este sistema.",
"usability_question_5_headline": "Tudo no sistema parecia funcionar bem em conjunto.",
"usability_question_6_headline": "O sistema parecia inconsistente na forma como as coisas funcionavam.",
"usability_question_7_headline": "Acho que a maioria das pessoas poderia aprender a usar este sistema rapidamente.",
"usability_question_8_headline": "Usar o sistema pareceu complicado.",
"usability_question_9_headline": "Eu senti-me confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
"understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui..."
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -124,7 +124,6 @@
"add_action": "新增操作",
"add_filter": "新增篩選器",
"add_logo": "新增標誌",
"add_member": "新增成員",
"add_project": "新增專案",
"add_to_team": "新增至團隊",
"all": "全部",
@@ -280,8 +279,6 @@
"on": "開啟",
"only_one_file_allowed": "僅允許一個檔案",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
"option_id": "選項 ID",
"option_ids": "選項 IDs",
"or": "或",
"organization": "組織",
"organization_id": "組織 ID",
@@ -308,7 +305,6 @@
"privacy": "隱私權政策",
"product_manager": "產品經理",
"profile": "個人資料",
"profile_id": "個人資料 ID",
"project_configuration": "專案組態",
"project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
"project_id": "專案 ID",
@@ -389,7 +385,6 @@
"targeting": "目標設定",
"team": "團隊",
"team_access": "團隊存取權限",
"team_id": "團隊 ID",
"team_name": "團隊名稱",
"teams": "存取控制",
"teams_not_found": "找不到團隊",
@@ -508,21 +503,21 @@
"action_with_key_already_exists": "金鑰為 '{'key'}' 的操作已存在",
"action_with_name_already_exists": "名稱為 '{'name'}' 的操作已存在",
"add_css_class_or_id": "新增 CSS 類別或 ID",
"add_regular_expression_here": "新增正則表達式在此",
"add_url": "新增網址",
"click": "點擊",
"contains": "包含",
"create_action": "建立操作",
"css_selector": "CSS 選取器",
"delete_action_text": "您確定要刪除此操作嗎?這也會從您的所有問卷中移除此操作作為觸發器。",
"display_name": "顯示名稱",
"does_not_contain": "不包含",
"does_not_exactly_match": "不完全相符",
"eg_clicked_download": "例如,點擊下載",
"eg_download_cta_click_on_home": "例如download_cta_click_on_home",
"eg_install_app": "例如,安裝應用程式",
"eg_user_clicked_download_button": "例如,使用者點擊了下載按鈕",
"ends_with": "結尾為",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "輸入網址以查看造訪該網址的使用者是否會被追蹤。",
"enter_url": "例如 https://app.com/dashboard",
"exactly_matches": "完全相符",
"exit_intent": "離開意圖",
"fifty_percent_scroll": "50% 捲動",
@@ -531,14 +526,9 @@
"if_a_user_clicks_a_button_with_a_specific_text": "如果使用者點擊具有特定文字的按鈕",
"in_your_code_read_more_in_our": "在您的程式碼中。在我們的文件中閱讀更多內容",
"inner_text": "內部文字",
"invalid_action_type_code": "對程式碼操作的操作類型無效",
"invalid_action_type_no_code": "使用無程式碼操作的操作類型無效",
"invalid_css_selector": "無效的 CSS 選取器",
"invalid_match_type": "所選擇的選項不適用。",
"invalid_regex": "請使用有效的正規表示式。",
"limit_the_pages_on_which_this_action_gets_captured": "限制擷取此操作的頁面",
"limit_to_specific_pages": "限制為特定頁面",
"matches_regex": "符合 正則 表達式",
"on_all_pages": "在所有頁面上",
"page_filter": "頁面篩選器",
"page_view": "頁面檢視",
@@ -558,9 +548,7 @@
"user_clicked_download_button": "使用者點擊了下載按鈕",
"what_did_your_user_do": "您的使用者做了什麼?",
"what_is_the_user_doing": "使用者正在做什麼?",
"you_can_track_code_action_anywhere_in_your_app_using": "您可以使用以下方式在您的應用程式中的任何位置追蹤程式碼操作",
"your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。",
"your_survey_would_not_be_shown": "您的問卷將不會顯示。"
"you_can_track_code_action_anywhere_in_your_app_using": "您可以使用以下方式在您的應用程式中的任何位置追蹤程式碼操作"
},
"connect": {
"congrats": "恭喜!",
@@ -1291,7 +1279,6 @@
"change_anyway": "仍然變更",
"change_background": "變更背景",
"change_question_type": "變更問題類型",
"change_survey_type": "切換問卷類型會影響現有訪問",
"change_the_background_color_of_the_card": "變更卡片的背景顏色。",
"change_the_background_color_of_the_input_fields": "變更輸入欄位的背景顏色。",
"change_the_background_to_a_color_image_or_animation": "將背景變更為顏色、圖片或動畫。",
@@ -1302,7 +1289,6 @@
"change_the_placement_of_this_survey": "變更此問卷的位置。",
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
"changes_saved": "已儲存變更。",
"changing_survey_type_will_remove_existing_distribution_channels": "更改問卷類型會影響其共享方式。如果受訪者已擁有當前類型的存取連結,則在切換後可能會失去存取權限。",
"character_limit_toggle_description": "限制答案的長度或短度。",
"character_limit_toggle_title": "新增字元限制",
"checkbox_label": "核取方塊標籤",
@@ -1316,7 +1302,7 @@
"columns": "欄位",
"company": "公司",
"company_logo": "公司標誌",
"completed_responses": "部分或完整答复。",
"completed_responses": "完成的回應。",
"concat": "串連 +",
"conditional_logic": "條件邏輯",
"confirm_default_language": "確認預設語言",
@@ -1621,11 +1607,6 @@
"zip": "郵遞區號"
},
"error_deleting_survey": "刪除問卷時發生錯誤",
"filter": {
"complete_and_partial_responses": "完整 和 部分 回應",
"complete_responses": "完整回應",
"partial_responses": "部分回應"
},
"new_survey": "新增問卷",
"no_surveys_created_yet": "尚未建立任何問卷",
"open_options": "開啟選項",
@@ -1644,7 +1625,6 @@
"company": "公司",
"completed": "已完成 ✅",
"country": "國家/地區",
"delete_response_confirmation": "這將刪除調查回覆,包括所有答案、註解、標籤、附加文件和回覆元數據。",
"device": "裝置",
"device_info": "裝置資訊",
"email": "電子郵件",
@@ -1819,6 +1799,7 @@
"last_quarter": "上一季",
"last_year": "去年",
"no_responses_found": "找不到回應",
"only_completed": "僅已完成",
"other_values_found": "找到其他值",
"overall": "整體",
"qr_code": "QR 碼",
@@ -2790,8 +2771,6 @@
"star_rating_survey_question_3_placeholder": "在此輸入您的答案...",
"star_rating_survey_question_3_subheader": "協助我們改善您的體驗。",
"statement_call_to_action": "陳述(行動呼籲)",
"strongly_agree": "非常同意",
"strongly_disagree": "非常不同意",
"supportive_work_culture_survey_description": "評估員工對領導層支援、溝通和整體工作環境的看法。",
"supportive_work_culture_survey_name": "支援性工作文化",
"supportive_work_culture_survey_question_1_headline": "我的經理為我提供了完成工作所需的支援。",
@@ -2847,18 +2826,6 @@
"understand_purchase_intention_question_2_headline": "瞭解了。您今天來訪的主要原因是什麼?",
"understand_purchase_intention_question_2_placeholder": "在此輸入您的答案...",
"understand_purchase_intention_question_3_headline": "有什麼阻礙您今天進行購買嗎?",
"understand_purchase_intention_question_3_placeholder": "在此輸入您的答案...",
"usability_question_10_headline": "我 必須 學習 很多 東西 才能 正確 使用 該 系統。",
"usability_question_1_headline": "我可能會經常使用這個系統。",
"usability_question_2_headline": "系統感覺起來比實際需要的更複雜。",
"usability_question_3_headline": "系統很容易理解。",
"usability_question_4_headline": "我 認為 我 需要 技術 專家 的 幫助 才能 使用 這個 系統。",
"usability_question_5_headline": "系統中 的 所有 元素 看起來 都能 很好 地 運作。",
"usability_question_6_headline": "系統在運作上給人不一致的感覺。",
"usability_question_7_headline": "我認為大多數人可以快速 學會 使用 這個 系統。",
"usability_question_8_headline": "使用系統 感覺 令人 困擾。",
"usability_question_9_headline": "使用 系統 時,我 感到 有 信心。",
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
"usability_score_name": "系統 可用性 分數 (SUS)"
"understand_purchase_intention_question_3_placeholder": "在此輸入您的答案..."
}
}

View File

@@ -12,10 +12,9 @@ vi.mock("@/modules/ui/components/file-upload-response", () => ({
),
}));
vi.mock("@/modules/ui/components/picture-selection-response", () => ({
PictureSelectionResponse: ({ selected, isExpanded, showId }: any) => (
<div data-testid="PictureSelectionResponse" data-show-id={showId}>
PictureSelection: {selected.join(",")} ({isExpanded ? "expanded" : "collapsed"}) showId:{" "}
{String(showId)}
PictureSelectionResponse: ({ selected, isExpanded }: any) => (
<div data-testid="PictureSelectionResponse">
PictureSelection: {selected.join(",")} ({isExpanded ? "expanded" : "collapsed"})
</div>
),
}));
@@ -23,28 +22,10 @@ vi.mock("@/modules/ui/components/array-response", () => ({
ArrayResponse: ({ value }: any) => <div data-testid="ArrayResponse">{value.join(",")}</div>,
}));
vi.mock("@/modules/ui/components/response-badges", () => ({
ResponseBadges: ({ items, showId }: any) => (
<div data-testid="ResponseBadges" data-show-id={showId}>
{Array.isArray(items)
? items
.map((item) => (typeof item === "object" ? `${item.value}:${item.id || "no-id"}` : item))
.join(",")
: items}{" "}
showId: {String(showId)}
</div>
),
ResponseBadges: ({ items }: any) => <div data-testid="ResponseBadges">{items.join(",")}</div>,
}));
vi.mock("@/modules/ui/components/ranking-response", () => ({
RankingResponse: ({ value, showId }: any) => (
<div data-testid="RankingResponse" data-show-id={showId}>
{Array.isArray(value)
? value
.map((item) => (typeof item === "object" ? `${item.value}:${item.id || "no-id"}` : item))
.join(",")
: value}{" "}
showId: {String(showId)}
</div>
),
RankingResponse: ({ value }: any) => <div data-testid="RankingResponse">{value.join(",")}</div>,
}));
vi.mock("@/modules/analysis/utils", () => ({
renderHyperlinkedContent: vi.fn((text: string) => "hyper:" + text),
@@ -69,14 +50,7 @@ describe("RenderResponse", () => {
});
const defaultSurvey = { languages: [] } as any;
const defaultQuestion = {
id: "q1",
type: "Unknown",
choices: [
{ id: "choice1", label: { default: "Option 1" } },
{ id: "choice2", label: { default: "Option 2" } },
],
} as any;
const defaultQuestion = { id: "q1", type: "Unknown" } as any;
const dummyLanguage = "default";
test("returns '-' for empty responseData (string)", () => {
@@ -86,7 +60,6 @@ describe("RenderResponse", () => {
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(container.textContent).toBe("-");
@@ -99,7 +72,6 @@ describe("RenderResponse", () => {
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(container.textContent).toBe("-");
@@ -112,7 +84,6 @@ describe("RenderResponse", () => {
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(container.textContent).toBe("-");
@@ -121,13 +92,7 @@ describe("RenderResponse", () => {
test("renders RatingResponse for 'Rating' question with number", () => {
const question = { ...defaultQuestion, type: "rating", scale: 5, range: [1, 5] };
render(
<RenderResponse
responseData={4}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
<RenderResponse responseData={4} question={question} survey={defaultSurvey} language={dummyLanguage} />
);
expect(screen.getByTestId("RatingResponse")).toHaveTextContent("Rating: 4");
});
@@ -141,7 +106,6 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByText(/formatted_/)).toBeInTheDocument();
@@ -155,7 +119,6 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("PictureSelectionResponse")).toHaveTextContent(
@@ -171,7 +134,6 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("FileUploadResponse")).toHaveTextContent("FileUpload: file1,file2");
@@ -187,7 +149,6 @@ describe("RenderResponse", () => {
question={question}
survey={{ languages: [] } as any}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByText("row1:processed:answer1")).toBeInTheDocument();
@@ -202,7 +163,6 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ArrayResponse")).toHaveTextContent("addr1,addr2");
@@ -216,7 +176,6 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Value");
@@ -225,13 +184,7 @@ describe("RenderResponse", () => {
test("renders ResponseBadges for 'Consent' question (number)", () => {
const question = { ...defaultQuestion, type: "consent" };
render(
<RenderResponse
responseData={5}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
<RenderResponse responseData={5} question={question} survey={defaultSurvey} language={dummyLanguage} />
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("5");
});
@@ -244,67 +197,56 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Click");
});
test("renders ResponseBadges for 'MultipleChoiceSingle' question (string)", () => {
const question = { ...defaultQuestion, type: "multipleChoiceSingle", choices: [] };
const question = { ...defaultQuestion, type: "multipleChoiceSingle" };
render(
<RenderResponse
responseData={"option1"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("option1");
});
test("renders ResponseBadges for 'MultipleChoiceMulti' question (array)", () => {
const question = { ...defaultQuestion, type: "multipleChoiceMulti", choices: [] };
const question = { ...defaultQuestion, type: "multipleChoiceMulti" };
render(
<RenderResponse
responseData={["opt1", "opt2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("opt1:other,opt2:other");
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("opt1,opt2");
});
test("renders ResponseBadges for 'NPS' question (number)", () => {
const question = { ...defaultQuestion, type: "nps" };
render(
<RenderResponse
responseData={9}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
<RenderResponse responseData={9} question={question} survey={defaultSurvey} language={dummyLanguage} />
);
// NPS questions render as simple text, not ResponseBadges
expect(screen.getByText("9")).toBeInTheDocument();
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("9");
});
test("renders RankingResponse for 'Ranking' question", () => {
const question = { ...defaultQuestion, type: "ranking", choices: [] };
const question = { ...defaultQuestion, type: "ranking" };
render(
<RenderResponse
responseData={["first", "second"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByTestId("RankingResponse")).toHaveTextContent("first:other,second:other showId: false");
expect(screen.getByTestId("RankingResponse")).toHaveTextContent("first,second");
});
test("renders default branch for unknown question type with string", () => {
@@ -315,7 +257,6 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByText("hyper:some text")).toBeInTheDocument();
@@ -329,178 +270,8 @@ describe("RenderResponse", () => {
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
expect(screen.getByText("a, b")).toBeInTheDocument();
});
// New tests for showId functionality
test("passes showId prop to PictureSelectionResponse", () => {
const question = {
...defaultQuestion,
type: "pictureSelection",
choices: [{ id: "choice1", imageUrl: "url1" }],
};
render(
<RenderResponse
responseData={["choice1"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("PictureSelectionResponse");
expect(component).toHaveAttribute("data-show-id", "true");
expect(component).toHaveTextContent("showId: true");
});
test("passes showId prop to RankingResponse with choice ID extraction", () => {
const question = {
...defaultQuestion,
type: "ranking",
choices: [
{ id: "choice1", label: { default: "Option 1" } },
{ id: "choice2", label: { default: "Option 2" } },
],
};
render(
<RenderResponse
responseData={["Option 1", "Option 2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("RankingResponse");
expect(component).toHaveAttribute("data-show-id", "true");
expect(component).toHaveTextContent("showId: true");
// Should extract choice IDs and pass them as value objects
expect(component).toHaveTextContent("Option 1:choice1,Option 2:choice2");
});
test("handles ranking response with missing choice IDs", () => {
const question = {
...defaultQuestion,
type: "ranking",
choices: [
{ id: "choice1", label: { default: "Option 1" } },
{ id: "choice2", label: { default: "Option 2" } },
],
};
render(
<RenderResponse
responseData={["Option 1", "Unknown Option"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("RankingResponse");
expect(component).toHaveTextContent("Option 1:choice1,Unknown Option:other");
});
test("passes showId prop to ResponseBadges for multiple choice single", () => {
const question = {
...defaultQuestion,
type: "multipleChoiceSingle",
choices: [{ id: "choice1", label: { default: "Option 1" } }],
};
render(
<RenderResponse
responseData={"Option 1"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("ResponseBadges");
expect(component).toHaveAttribute("data-show-id", "true");
expect(component).toHaveTextContent("showId: true");
expect(component).toHaveTextContent("Option 1:choice1");
});
test("passes showId prop to ResponseBadges for multiple choice multi", () => {
const question = {
...defaultQuestion,
type: "multipleChoiceMulti",
choices: [
{ id: "choice1", label: { default: "Option 1" } },
{ id: "choice2", label: { default: "Option 2" } },
],
};
render(
<RenderResponse
responseData={["Option 1", "Option 2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("ResponseBadges");
expect(component).toHaveAttribute("data-show-id", "true");
expect(component).toHaveTextContent("showId: true");
expect(component).toHaveTextContent("Option 1:choice1,Option 2:choice2");
});
test("handles multiple choice with missing choice IDs", () => {
const question = {
...defaultQuestion,
type: "multipleChoiceMulti",
choices: [{ id: "choice1", label: { default: "Option 1" } }],
};
render(
<RenderResponse
responseData={["Option 1", "Unknown Option"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("ResponseBadges");
expect(component).toHaveTextContent("Option 1:choice1,Unknown Option:other");
});
test("passes showId=false to components when showId is false", () => {
const question = {
...defaultQuestion,
type: "multipleChoiceMulti",
choices: [{ id: "choice1", label: { default: "Option 1" } }],
};
render(
<RenderResponse
responseData={["Option 1"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={false}
/>
);
const component = screen.getByTestId("ResponseBadges");
expect(component).toHaveAttribute("data-show-id", "false");
expect(component).toHaveTextContent("showId: false");
// Should still extract IDs but showId=false
expect(component).toHaveTextContent("Option 1:choice1");
});
test("handles questions without choices property", () => {
const question = { ...defaultQuestion, type: "multipleChoiceSingle" }; // No choices property
render(
<RenderResponse
responseData={"Option 1"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
showId={true}
/>
);
const component = screen.getByTestId("ResponseBadges");
expect(component).toHaveTextContent("Option 1:choice1");
});
});

View File

@@ -1,6 +1,5 @@
import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
@@ -28,7 +27,6 @@ interface RenderResponseProps {
survey: TSurvey;
language: string | null;
isExpanded?: boolean;
showId: boolean;
}
export const RenderResponse: React.FC<RenderResponseProps> = ({
@@ -37,7 +35,6 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
survey,
language,
isExpanded = true,
showId,
}) => {
if (
(typeof responseData === "string" && responseData === "") ||
@@ -84,7 +81,6 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
choices={(question as TSurveyPictureSelectionQuestion).choices}
selected={responseData}
isExpanded={isExpanded}
showId={showId}
/>
);
}
@@ -125,10 +121,9 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
items={[capitalizeFirstLetter(responseData.toString())]}
isExpanded={isExpanded}
icon={<PhoneIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);
}
@@ -137,10 +132,9 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
items={[capitalizeFirstLetter(responseData.toString())]}
isExpanded={isExpanded}
icon={<CheckCheckIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);
}
@@ -149,43 +143,26 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
items={[capitalizeFirstLetter(responseData.toString())]}
isExpanded={isExpanded}
icon={<MousePointerClickIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);
}
break;
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyQuestionTypeEnum.Ranking:
case TSurveyQuestionTypeEnum.NPS:
if (typeof responseData === "string" || typeof responseData === "number") {
const choiceId = getChoiceIdByValue(responseData.toString(), question);
return (
<ResponseBadges
items={[{ value: responseData.toString(), id: choiceId }]}
isExpanded={isExpanded}
showId={showId}
/>
);
return <ResponseBadges items={[responseData.toString()]} isExpanded={isExpanded} />;
} else if (Array.isArray(responseData)) {
const itemsArray = responseData.map((choice) => {
const choiceId = getChoiceIdByValue(choice, question);
return { value: choice, id: choiceId };
});
return (
<>
{questionType === TSurveyQuestionTypeEnum.Ranking ? (
<RankingResponse value={itemsArray} isExpanded={isExpanded} showId={showId} />
) : (
<ResponseBadges items={itemsArray} isExpanded={isExpanded} showId={showId} />
)}
</>
);
return <ResponseBadges items={responseData} isExpanded={isExpanded} />;
}
break;
case TSurveyQuestionTypeEnum.Ranking:
if (Array.isArray(responseData)) {
return <RankingResponse value={responseData} isExpanded={isExpanded} />;
}
default:
if (
typeof responseData === "string" ||

View File

@@ -76,7 +76,7 @@ export const SingleResponseCardBody = ({
<div key={`${question.id}`}>
{isValidValue(response.data[question.id]) ? (
<div>
<p className="mb-1 text-sm text-slate-500">
<p className="text-sm text-slate-500">
{formatTextWithSlashes(
parseRecallInfo(
getLocalizedValue(question.headline, "default"),
@@ -92,7 +92,6 @@ export const SingleResponseCardBody = ({
survey={survey}
responseData={response.data[question.id]}
language={response.language}
showId={true}
/>
</div>
</div>

View File

@@ -3,7 +3,6 @@
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
@@ -163,21 +162,19 @@ export const SingleResponseCardHeader = ({
{response.contact?.id ? (
user ? (
<Link
className="flex items-center space-x-2"
className="flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<PersonAvatar personId={response.contact.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
{displayIdentifier}
</h3>
{response.contact.userId && <IdBadge id={response.contact.userId} />}
</Link>
) : (
<div className="flex items-center space-x-2">
<div className="flex items-center">
<PersonAvatar personId={response.contact.id} />
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600">
{displayIdentifier}
</h3>
{response.contact.userId && <IdBadge id={response.contact.userId} />}
</div>
)
) : (

View File

@@ -319,16 +319,11 @@ export const authOptions: NextAuthOptions = {
async signIn({ user, account }: { user: TUser; account: Account }) {
const cookieStore = await cookies();
// get callback url from the cookie store,
const callbackUrl =
cookieStore.get("__Secure-next-auth.callback-url")?.value ||
cookieStore.get("next-auth.callback-url")?.value ||
"";
const callbackUrl = cookieStore.get("next-auth.callback-url")?.value || "";
if (account?.provider === "credentials" || account?.provider === "token") {
// check if user's email is verified or not
if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
await updateUserLastLoginAt(user.email);

View File

@@ -52,7 +52,7 @@ export const PasswordChecks = ({ password }: PasswordChecksProps) => {
return (
<div className="my-2 text-left text-slate-700 sm:text-sm">
<ul aria-label="Password requirements">
<ul role="list" aria-label="Password requirements">
{validations.map((validation) => (
<li key={validation.label} className="flex items-center">
<ValidationIcon state={validation.state} />

View File

@@ -2,7 +2,6 @@ import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants";
import { ActionClientCtx, AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { deepDiff, redactPII } from "@/lib/utils/logger-helpers";
import { logAuditEvent } from "@/modules/ee/audit-logs/lib/service";
import {
TActor,
@@ -14,6 +13,7 @@ import {
} from "@/modules/ee/audit-logs/types/audit-log";
import { getIsAuditLogsEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger";
import { deepDiff, redactPII } from "./utils";
/**
* Builds an audit event and logs it.

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { deepDiff, redactPII, sanitizeUrlForLogging } from "./logger-helpers";
import { deepDiff, redactPII } from "./utils";
// Patch redis multi before any imports
beforeEach(async () => {
@@ -104,7 +104,7 @@ describe("withAuditLogging", () => {
});
test("logs audit event for successful handler", async () => {
const handler = vi.fn().mockResolvedValue("ok");
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
const { withAuditLogging } = await import("./handler");
const wrapped = withAuditLogging("created", "survey", handler);
const ctx = {
user: {
@@ -143,7 +143,7 @@ describe("withAuditLogging", () => {
});
test("logs audit event for failed handler and throws", async () => {
const handler = vi.fn().mockRejectedValue(new Error("fail"));
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
const { withAuditLogging } = await import("./handler");
const wrapped = withAuditLogging("created", "survey", handler);
const ctx = {
user: {
@@ -181,37 +181,3 @@ describe("withAuditLogging", () => {
expect(handler).toHaveBeenCalled();
});
});
describe("sanitizeUrlForLogging", () => {
test("returns sanitized URL with token", () => {
expect(sanitizeUrlForLogging("https://example.com?token=1234567890")).toBe(
"https://example.com/?token=********"
);
});
test("returns sanitized URL with code", () => {
expect(sanitizeUrlForLogging("https://example.com?code=1234567890")).toBe(
"https://example.com/?code=********"
);
});
test("returns sanitized URL with state", () => {
expect(sanitizeUrlForLogging("https://example.com?state=1234567890")).toBe(
"https://example.com/?state=********"
);
});
test("returns sanitized URL with multiple keys", () => {
expect(
sanitizeUrlForLogging("https://example.com?token=1234567890&code=1234567890&state=1234567890")
).toBe("https://example.com/?token=********&code=********&state=********");
});
test("returns sanitized URL without query params", () => {
expect(sanitizeUrlForLogging("https://example.com")).toBe("https://example.com/");
});
test("returns sanitized URL with invalid URL", () => {
expect(sanitizeUrlForLogging("not-a-valid-url")).toBe("[invalid-url]");
});
});

View File

@@ -1,5 +1,3 @@
import { isStringUrl } from "@/lib/utils/url";
const SENSITIVE_KEYS = [
"email",
"name",
@@ -35,11 +33,8 @@ const SENSITIVE_KEYS = [
"image",
"stripeCustomerId",
"fileName",
"state",
];
const URL_SENSITIVE_KEYS = ["token", "code", "state"];
/**
* Redacts sensitive data from the object by replacing the sensitive keys with "********".
* @param obj - The object to redact.
@@ -50,10 +45,6 @@ export const redactPII = (obj: any, seen: WeakSet<any> = new WeakSet()): any =>
return obj.toISOString();
}
if (typeof obj === "string" && isStringUrl(obj)) {
return sanitizeUrlForLogging(obj);
}
if (obj && typeof obj === "object") {
if (seen.has(obj)) return "[Circular]";
seen.add(obj);
@@ -98,24 +89,3 @@ export const deepDiff = (oldObj: any, newObj: any): any => {
}
return Object.keys(diff).length > 0 ? diff : undefined;
};
/**
* Sanitizes a URL for logging by redacting sensitive parameters.
* @param url - The URL to sanitize.
* @returns The sanitized URL.
*/
export const sanitizeUrlForLogging = (url: string): string => {
try {
const urlObj = new URL(url);
URL_SENSITIVE_KEYS.forEach((key) => {
if (urlObj.searchParams.has(key)) {
urlObj.searchParams.set(key, "********");
}
});
return urlObj.origin + urlObj.pathname + (urlObj.search ? `${urlObj.search}` : "");
} catch {
return "[invalid-url]";
}
};

View File

@@ -2,7 +2,6 @@ import { getResponsesByContactId } from "@/lib/response/service";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { getTranslate } from "@/tolgee/server";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
@@ -43,7 +42,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dt className="text-sm font-medium text-slate-500">{t("common.user_id")}</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? (
<IdBadge id={attributes.userId} />
<span>{attributes.userId}</span>
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}

View File

@@ -2,7 +2,6 @@
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ColumnDef } from "@tanstack/react-table";
import { TContactTableData } from "../types/contact";
@@ -27,7 +26,7 @@ export const generateContactTableColumns = (
header: "User ID",
cell: ({ row }) => {
const userId = row.original.userId;
return <IdBadge id={userId} showCopyIconOnHover={true} />;
return <HighlightedText value={userId} searchValue={searchValue} />;
},
};

View File

@@ -1,7 +1,6 @@
"use client";
import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
import { useTranslate } from "@tolgee/react";
import { TSegment } from "@formbricks/types/segment";
@@ -53,7 +52,10 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
</p>
</div>
<div>
<IdBadge id={currentSegment.id} label={t("environments.segments.segment_id")} variant="column" />
<Label className="text-xs font-normal text-slate-500">
{t("environments.segments.segment_id")}
</Label>
<p className="text-xs text-slate-700">{currentSegment.id.toString()}</p>
</div>
</div>
</div>

View File

@@ -102,8 +102,6 @@ describe("License Core Logic", () => {
spamProtection: true,
ai: false,
auditLogs: true,
multiLanguageSurveys: true,
accessControl: true,
};
const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = {
status: "active",
@@ -159,6 +157,7 @@ describe("License Core Logic", () => {
active: true,
features: mockFetchedLicenseDetails.features,
lastChecked: expect.any(Date),
version: 1,
},
expect.any(Number)
);
@@ -232,10 +231,9 @@ describe("License Core Logic", () => {
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
},
lastChecked: expect.any(Date),
version: 1,
},
expect.any(Number)
);
@@ -253,8 +251,6 @@ describe("License Core Logic", () => {
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
},
lastChecked: expect.any(Date),
isPendingDowngrade: false,
@@ -282,8 +278,6 @@ describe("License Core Logic", () => {
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
};
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining("fb:license:"),
@@ -291,6 +285,7 @@ describe("License Core Logic", () => {
active: false,
features: expectedFeatures,
lastChecked: expect.any(Date),
version: 1,
},
expect.any(Number)
);

View File

@@ -36,6 +36,7 @@ type TPreviousResult = {
active: boolean;
lastChecked: Date;
features: TEnterpriseLicenseFeatures | null;
version: number; // For cache versioning
};
// Validation schemas
@@ -51,8 +52,6 @@ const LicenseFeaturesSchema = z.object({
saml: z.boolean(),
spamProtection: z.boolean(),
auditLogs: z.boolean(),
multiLanguageSurveys: z.boolean(),
accessControl: z.boolean(),
});
const LicenseDetailsSchema = z.object({
@@ -113,8 +112,6 @@ const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = {
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
};
// Helper functions
@@ -140,6 +137,7 @@ const getPreviousResult = async (): Promise<TPreviousResult> => {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
version: 1,
};
}
@@ -160,6 +158,7 @@ const getPreviousResult = async (): Promise<TPreviousResult> => {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
version: 1,
};
};
@@ -198,6 +197,7 @@ const trackApiError = (error: LicenseApiError) => {
const validateFallback = (previousResult: TPreviousResult): boolean => {
if (!previousResult.features) return false;
if (previousResult.lastChecked.getTime() === new Date(0).getTime()) return false;
if (previousResult.version !== 1) return false; // Add version check
return true;
};
@@ -224,6 +224,7 @@ const handleInitialFailure = async (currentTime: Date) => {
active: false,
features: DEFAULT_FEATURES,
lastChecked: currentTime,
version: 1,
};
await setPreviousResult(initialFailResult);
return {
@@ -369,6 +370,7 @@ export const getEnterpriseLicense = reactCache(
active: liveLicenseDetails.status === "active",
features: liveLicenseDetails.features,
lastChecked: currentTime,
version: 1,
};
await setPreviousResult(currentLicenseState);
return {

View File

@@ -4,7 +4,6 @@ import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import * as licenseModule from "./license";
import {
getAccessControlPermission,
getBiggerUploadFileSizePermission,
getIsContactsEnabled,
getIsMultiOrgEnabled,
@@ -15,6 +14,7 @@ import {
getMultiLanguagePermission,
getOrganizationProjectsLimit,
getRemoveBrandingPermission,
getRoleManagementPermission,
getWhiteLabelPermission,
} from "./utils";
@@ -46,8 +46,6 @@ const defaultFeatures: TEnterpriseLicenseFeatures = {
spamProtection: false,
ai: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
};
const defaultLicense = {
@@ -143,59 +141,41 @@ describe("License Utils", () => {
});
});
describe("getAccessControlPermission", () => {
test("should return true if license active and accessControl feature enabled (self-hosted)", async () => {
describe("getRoleManagementPermission", () => {
test("should return true if license active (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return true if license active, accessControl enabled and plan is SCALE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("should return true if license active, accessControl enabled and plan is ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
expect(result).toBe(true);
});
test("should return false if license active, accessControl enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, accessControl: true },
});
const result = await getAccessControlPermission(constants.PROJECT_FEATURE_KEYS.STARTUP);
expect(result).toBe(false);
});
test("should return true if license active but accessControl feature disabled because of fallback", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getAccessControlPermission(mockOrganization.billing.plan);
const result = await getRoleManagementPermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return true if license active and plan is SCALE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("should return true if license active and plan is ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
expect(result).toBe(true);
});
test("should return false if license active and plan is not SCALE or ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getRoleManagementPermission(constants.PROJECT_FEATURE_KEYS.STARTUP);
expect(result).toBe(false);
});
test("should return false if license is inactive", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
active: false,
});
const result = await getAccessControlPermission(mockOrganization.billing.plan);
const result = await getRoleManagementPermission(mockOrganization.billing.plan);
expect(result).toBe(false);
});
});
@@ -233,52 +213,20 @@ describe("License Utils", () => {
});
describe("getMultiLanguagePermission", () => {
test("should return true if license active and multiLanguageSurveys feature enabled (self-hosted)", async () => {
test("should return true if license active (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return true if license active, multiLanguageSurveys enabled and plan is SCALE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("should return true if license active, multiLanguageSurveys enabled and plan is ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.ENTERPRISE);
expect(result).toBe(true);
});
test("should return false if license active, multiLanguageSurveys enabled but plan is not SCALE or ENTERPRISE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, multiLanguageSurveys: true },
});
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.STARTUP);
expect(result).toBe(false);
});
test("should return true if license active but multiLanguageSurveys feature disabled because of fallback", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return true if license active and plan is SCALE (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getMultiLanguagePermission(constants.PROJECT_FEATURE_KEYS.SCALE);
expect(result).toBe(true);
});
test("should return false if license is inactive", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,

View File

@@ -35,6 +35,20 @@ export const getWhiteLabelPermission = async (
return getFeaturePermission(billingPlan, "whitelabel");
};
export const getRoleManagementPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
export const getBiggerUploadFileSizePermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
@@ -45,16 +59,25 @@ export const getBiggerUploadFileSizePermission = async (
return false;
};
export const getMultiLanguagePermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
// Helper function for simple boolean feature flags
const getSpecificFeatureFlag = async (
featureKey: keyof Pick<
TEnterpriseLicenseFeatures,
| "isMultiOrgEnabled"
| "contacts"
| "twoFactorAuth"
| "sso"
| "auditLogs"
| "multiLanguageSurveys"
| "accessControl"
"isMultiOrgEnabled" | "contacts" | "twoFactorAuth" | "sso" | "auditLogs"
>
): Promise<boolean> => {
const licenseFeatures = await getLicenseFeatures();
@@ -110,39 +133,6 @@ export const getIsSpamProtectionEnabled = async (
return license.active && !!license.features?.spamProtection;
};
const featureFlagFallback = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
export const getMultiLanguagePermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const isEnabled = await getSpecificFeatureFlag("multiLanguageSurveys");
// If the feature is enabled in the license, return true
if (isEnabled) return true;
// If the feature is not enabled in the license, check the fallback(Backwards compatibility)
return featureFlagFallback(billingPlan);
};
export const getAccessControlPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const isEnabled = await getSpecificFeatureFlag("accessControl");
// If the feature is enabled in the license, return true
if (isEnabled) return true;
// If the feature is not enabled in the license, check the fallback(Backwards compatibility)
return featureFlagFallback(billingPlan);
};
export const getOrganizationProjectsLimit = async (
limits: Organization["billing"]["limits"]
): Promise<number> => {

View File

@@ -16,8 +16,6 @@ const ZEnterpriseLicenseFeatures = z.object({
spamProtection: z.boolean(),
ai: z.boolean(),
auditLogs: z.boolean(),
multiLanguageSurveys: z.boolean(),
accessControl: z.boolean(),
});
export type TEnterpriseLicenseFeatures = z.infer<typeof ZEnterpriseLicenseFeatures>;

View File

@@ -1,7 +1,6 @@
"use client";
import { DefaultTag } from "@/modules/ui/components/default-tag";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
@@ -31,9 +30,11 @@ export function DefaultLanguageSelect({
}: DefaultLanguageSelectProps) {
const { t } = useTranslate();
return (
<div className="space-y-2">
<Label>{t("environments.surveys.edit.1_choose_the_default_language_for_this_survey")}</Label>
<div className="flex items-center space-x-2">
<div className="space-y-4">
<p className="text-sm">
{t("environments.surveys.edit.1_choose_the_default_language_for_this_survey")}:
</p>
<div className="flex items-center space-x-4">
<div className="w-48">
<Select
defaultValue={`${defaultLanguage?.code}`}
@@ -56,7 +57,7 @@ export function DefaultLanguageSelect({
});
}}
value={`${defaultLanguage?.code}`}>
<SelectTrigger className="xs:w-[180px] xs:text-sm w-full px-4 text-xs text-slate-800 dark:border-slate-400 dark:bg-slate-700 dark:text-slate-300">
<SelectTrigger className="xs:w-[180px] xs:text-base w-full px-4 text-xs text-slate-800 dark:border-slate-400 dark:bg-slate-700 dark:text-slate-300">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -223,7 +223,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
<div className="space-y-6 pt-3">
<div className="space-y-4">
{!isMultiLanguageAllowed && !isMultiLanguageActivated ? (
<UpgradePrompt
title={t("environments.surveys.edit.upgrade_notice_title")}
@@ -257,15 +257,17 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
</div>
)}
{projectLanguages.length > 1 && (
<div className="space-y-6">
{isMultiLanguageAllowed && !isMultiLanguageActivated ? (
<div className="text-sm italic text-slate-500">
{t("environments.surveys.edit.switch_multi_lanugage_on_to_get_started")}
</div>
) : null}
<div className="my-4 space-y-4">
<div>
{isMultiLanguageAllowed && !isMultiLanguageActivated ? (
<div className="text-sm italic text-slate-500">
{t("environments.surveys.edit.switch_multi_lanugage_on_to_get_started")}
</div>
) : null}
</div>
{isMultiLanguageActivated ? (
<div className="space-y-6">
<div className="space-y-4">
<DefaultLanguageSelect
defaultLanguage={defaultLanguage}
handleDefaultLanguageChange={handleDefaultLanguageChange}
@@ -289,15 +291,15 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
</div>
)}
<Button asChild size="sm" variant="secondary">
<Link href={`/environments/${environmentId}/project/languages`} target="_blank">
{t("environments.surveys.edit.manage_languages")}
<ArrowUpRight />
</Link>
</Button>
<Link href={`/environments/${environmentId}/project/languages`} target="_blank">
<Button className="mt-2" size="sm" variant="secondary">
{t("environments.surveys.edit.manage_languages")}{" "}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
{isMultiLanguageActivated && (
<AdvancedOptionToggle
customContainerClass="px-0 pt-0"
customContainerClass="px-0 pt-2"
htmlId="languageSwitch"
isChecked={!!localSurvey.showLanguageSwitch}
onToggle={handleLanguageSwitchToggle}

View File

@@ -33,10 +33,10 @@ export function SecondaryLanguageSelect({
};
return (
<div className="space-y-2">
<p className="text-sm font-medium text-slate-800">
{t("environments.surveys.edit.2_activate_translation_for_specific_languages")}
</p>{" "}
<div className="space-y-4">
<p className="text-sm">
{t("environments.surveys.edit.2_activate_translation_for_specific_languages")}:
</p>
{projectLanguages
.filter((lang) => lang.id !== defaultLanguage.id)
.map((language) => (

View File

@@ -8,7 +8,7 @@ import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
import { updateMembership } from "@/modules/ee/role-management/lib/membership";
import { ZInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
@@ -24,8 +24,8 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
throw new Error("Organization not found");
}
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed) {
const isRoleManagementAllowed = await getRoleManagementPermission(organization.billing.plan);
if (!isRoleManagementAllowed) {
throw new OperationNotAllowedError("Role management is not allowed for this organization");
}
};

View File

@@ -18,14 +18,14 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
interface AddMemberRoleProps {
control: Control<{ name: string; email: string; role: TOrganizationRole; teamIds: string[] }>;
isAccessControlAllowed: boolean;
canDoRoleManagement: boolean;
isFormbricksCloud: boolean;
membershipRole?: TOrganizationRole;
}
export function AddMemberRole({
control,
isAccessControlAllowed,
canDoRoleManagement,
isFormbricksCloud,
membershipRole,
}: AddMemberRoleProps) {
@@ -62,8 +62,8 @@ export function AddMemberRole({
<div className="flex flex-col space-y-2">
<Label>{t("common.role_organization")}</Label>
<Select
defaultValue={isAccessControlAllowed ? "member" : "owner"}
disabled={!isAccessControlAllowed}
defaultValue={canDoRoleManagement ? "member" : "owner"}
disabled={!canDoRoleManagement}
onValueChange={(v) => {
onChange(v as TOrganizationRole);
}}

View File

@@ -11,20 +11,14 @@ vi.mock("@tolgee/react", () => ({
}));
// Create a wrapper component that provides the form context
const FormWrapper = ({
children,
defaultValues,
membershipRole,
isAccessControlAllowed,
isFormbricksCloud,
}) => {
const FormWrapper = ({ children, defaultValues, membershipRole, canDoRoleManagement, isFormbricksCloud }) => {
const methods = useForm({ defaultValues });
return (
<FormProvider {...methods}>
<AddMemberRole
control={methods.control}
membershipRole={membershipRole}
isAccessControlAllowed={isAccessControlAllowed}
canDoRoleManagement={canDoRoleManagement}
isFormbricksCloud={isFormbricksCloud}
/>
{children}
@@ -50,7 +44,7 @@ describe("AddMemberRole Component", () => {
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
isAccessControlAllowed={true}
canDoRoleManagement={true}
isFormbricksCloud={true}>
<div />
</FormWrapper>
@@ -65,7 +59,7 @@ describe("AddMemberRole Component", () => {
<FormWrapper
defaultValues={defaultValues}
membershipRole="member"
isAccessControlAllowed={true}
canDoRoleManagement={true}
isFormbricksCloud={true}>
<div data-testid="child" />
</FormWrapper>
@@ -75,12 +69,12 @@ describe("AddMemberRole Component", () => {
expect(screen.getByTestId("child")).toBeInTheDocument();
});
test("disables the role selector when isAccessControlAllowed is false", () => {
test("disables the role selector when canDoRoleManagement is false", () => {
render(
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
isAccessControlAllowed={false}
canDoRoleManagement={false}
isFormbricksCloud={true}>
<div />
</FormWrapper>
@@ -97,7 +91,7 @@ describe("AddMemberRole Component", () => {
<FormWrapper
defaultValues={defaultValues}
membershipRole="owner"
isAccessControlAllowed={true}
canDoRoleManagement={true}
isFormbricksCloud={true}>
<div />
</FormWrapper>

View File

@@ -4,16 +4,15 @@ import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { redactPII } from "@/lib/utils/logger-helpers";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth";
import {
getAccessControlPermission,
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getIsSsoEnabled,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
@@ -32,36 +31,12 @@ export const handleSsoCallback = async ({
account: Account;
callbackUrl: string;
}) => {
const contextLogger = logger.withContext({
correlationId: crypto.randomUUID(),
name: "formbricks",
});
contextLogger.debug(
{
...redactPII({ user, account, callbackUrl }),
hasEmail: !!user.email,
hasName: !!user.name,
},
"SSO callback initiated"
);
const isSsoEnabled = await getIsSsoEnabled();
if (!isSsoEnabled) {
contextLogger.debug({ isSsoEnabled }, "SSO not enabled");
return false;
}
if (!user.email || account.type !== "oauth") {
contextLogger.debug(
{
hasEmail: !!user.email,
accountType: account.type,
reason: !user.email ? "missing_email" : "invalid_account_type",
},
"SSO callback rejected: missing email or invalid account type"
);
return false;
}
@@ -70,18 +45,12 @@ export const handleSsoCallback = async ({
if (provider === "saml") {
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
if (!isSamlSsoEnabled) {
contextLogger.debug({ provider: "saml" }, "SSO callback rejected: SAML not enabled in license");
return false;
}
}
if (account.provider) {
// check if accounts for this provider / account Id already exists
contextLogger.debug(
{ lookupType: "sso_provider_account" },
"Checking for existing user with SSO provider"
);
const existingUserWithAccount = await prisma.user.findFirst({
include: {
accounts: {
@@ -97,29 +66,12 @@ export const handleSsoCallback = async ({
});
if (existingUserWithAccount) {
contextLogger.debug(
{
existingUserId: existingUserWithAccount.id,
emailMatches: existingUserWithAccount.email === user.email,
},
"Found existing user with SSO provider"
);
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"SSO callback successful: existing user, email matches"
);
return true;
}
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"Email changed in SSO provider, checking for conflicts"
);
// user seemed to change his email within the provider
// check if user with this email already exist
// if not found just update user with new email address
@@ -127,20 +79,9 @@ export const handleSsoCallback = async ({
const otherUserWithEmail = await getUserByEmail(user.email);
if (!otherUserWithEmail) {
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id, action: "email_update" },
"No other user with this email found, updating user email after SSO provider change"
);
await updateUser(existingUserWithAccount.id, { email: user.email });
return true;
}
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id, conflictingUserId: otherUserWithEmail.id },
"SSO callback failed: email conflict after provider change"
);
throw new Error(
"Looks like you updated your email somewhere else. A user with this new email exists already."
);
@@ -149,24 +90,13 @@ export const handleSsoCallback = async ({
// There is no existing account for this identity provider / account id
// check if user account with this email already exists
// if user already exists throw error and request password login
contextLogger.debug({ lookupType: "email" }, "No existing SSO account found, checking for user by email");
const existingUserWithEmail = await getUserByEmail(user.email);
if (existingUserWithEmail) {
contextLogger.debug(
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
"SSO callback successful: existing user found by email"
);
// Sign in the user with the existing account
return true;
}
contextLogger.debug(
{ action: "new_user_creation" },
"No existing user found, proceeding with new user creation"
);
let userName = user.name;
if (provider === "openid") {
@@ -178,16 +108,6 @@ export const handleSsoCallback = async ({
} else if (oidcUser.preferred_username) {
userName = oidcUser.preferred_username;
}
contextLogger.debug(
{
hasName: !!oidcUser.name,
hasGivenName: !!oidcUser.given_name,
hasFamilyName: !!oidcUser.family_name,
hasPreferredUsername: !!oidcUser.preferred_username,
},
"Extracted OIDC user name"
);
}
if (provider === "saml") {
@@ -197,14 +117,6 @@ export const handleSsoCallback = async ({
} else if (samlUser.firstName || samlUser.lastName) {
userName = `${samlUser.firstName} ${samlUser.lastName}`;
}
contextLogger.debug(
{
hasName: !!samlUser.name,
hasFirstName: !!samlUser.firstName,
hasLastName: !!samlUser.lastName,
},
"Extracted SAML user name"
);
}
// Get multi-org license status
@@ -212,23 +124,9 @@ export const handleSsoCallback = async ({
const isFirstUser = await getIsFreshInstance();
contextLogger.debug(
{
isMultiOrgEnabled,
isFirstUser,
skipInviteForSso: SKIP_INVITE_FOR_SSO,
hasDefaultTeamId: !!DEFAULT_TEAM_ID,
},
"License and instance configuration checked"
);
// Additional security checks for self-hosted instances without auto-provisioning and no multi-org enabled
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
if (!callbackUrl) {
contextLogger.debug(
{ reason: "missing_callback_url" },
"SSO callback rejected: missing callback URL for invite validation"
);
return false;
}
@@ -241,10 +139,6 @@ export const handleSsoCallback = async ({
// Allow sign-in if multi-org is enabled, otherwise check for invite token
if (source === "signin" && !inviteToken) {
contextLogger.debug(
{ reason: "signin_without_invite_token" },
"SSO callback rejected: signin without invite token"
);
return false;
}
@@ -252,32 +146,16 @@ export const handleSsoCallback = async ({
// Verify invite token and check email match
const { email, inviteId } = verifyInviteToken(inviteToken);
if (email !== user.email) {
contextLogger.debug(
{ reason: "invite_email_mismatch", inviteId },
"SSO callback rejected: invite token email mismatch"
);
return false;
}
// Check if invite token is still valid
const isValidInviteToken = await getIsValidInviteToken(inviteId);
if (!isValidInviteToken) {
contextLogger.debug(
{ reason: "invalid_invite_token", inviteId },
"SSO callback rejected: invalid or expired invite token"
);
return false;
}
contextLogger.debug({ inviteId }, "Invite token validation successful");
} catch (err) {
contextLogger.debug(
{
reason: "invite_token_validation_error",
error: err instanceof Error ? err.message : "unknown_error",
},
"SSO callback rejected: invite token validation failed"
);
// Log and reject on any validation errors
contextLogger.error(err, "Invalid callbackUrl");
logger.error(err, "Invalid callbackUrl");
return false;
}
}
@@ -285,12 +163,6 @@ export const handleSsoCallback = async ({
let organization: Organization | null = null;
if (!isFirstUser && !isMultiOrgEnabled) {
contextLogger.debug(
{
assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization",
},
"Determining organization assignment"
);
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID);
} else {
@@ -298,29 +170,13 @@ export const handleSsoCallback = async ({
}
if (!organization) {
contextLogger.debug(
{ reason: "no_organization_found" },
"SSO callback rejected: no organization found for assignment"
);
return false;
}
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed && !callbackUrl) {
contextLogger.debug(
{
reason: "insufficient_role_permissions",
organizationId: organization.id,
isAccessControlAllowed,
},
"SSO callback rejected: insufficient role management permissions"
);
return false;
}
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement && !callbackUrl) return false;
}
contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user");
const userProfile = await createUser({
name:
userName ||
@@ -335,28 +191,13 @@ export const handleSsoCallback = async ({
locale: await findMatchingLocale(),
});
contextLogger.debug(
{ newUserId: userProfile.id, identityProvider: provider },
"New SSO user created successfully"
);
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
if (isMultiOrgEnabled) {
contextLogger.debug(
{ isMultiOrgEnabled, newUserId: userProfile.id },
"Multi-org enabled, skipping organization assignment"
);
return true;
}
if (isMultiOrgEnabled) return true;
// Default organization assignment if env variable is set
if (organization) {
contextLogger.debug(
{ newUserId: userProfile.id, organizationId: organization.id, role: "member" },
"Assigning user to organization"
);
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
@@ -364,10 +205,6 @@ export const handleSsoCallback = async ({
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
contextLogger.debug(
{ newUserId: userProfile.id, defaultTeamId: DEFAULT_TEAM_ID },
"Creating default team membership"
);
await createDefaultTeamMembership(userProfile.id);
}
@@ -389,7 +226,6 @@ export const handleSsoCallback = async ({
// Without default organization assignment
return true;
}
contextLogger.debug("SSO callback successful: default return");
return true;
};

View File

@@ -5,10 +5,10 @@ import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import type { TSamlNameFields } from "@/modules/auth/types/auth";
import {
getAccessControlPermission,
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getIsSsoEnabled,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
import { beforeEach, describe, expect, test, vi } from "vitest";
@@ -43,7 +43,7 @@ vi.mock("@/modules/auth/signup/lib/invite", () => ({
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSamlSsoEnabled: vi.fn(),
getIsSsoEnabled: vi.fn(),
getAccessControlPermission: vi.fn(),
getRoleManagementPermission: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
}));
@@ -85,13 +85,6 @@ vi.mock("@formbricks/lib/jwt", () => ({
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
debug: vi.fn(),
withContext: (context: Record<string, any>) => {
return {
...context,
debug: vi.fn(),
};
},
},
}));
@@ -310,7 +303,7 @@ describe("handleSsoCallback", () => {
});
expect(result).toBe(true);
expect(getAccessControlPermission).not.toHaveBeenCalled();
expect(getRoleManagementPermission).not.toHaveBeenCalled();
});
test("should return true when organization exists but role management is not enabled", async () => {
@@ -318,7 +311,7 @@ describe("handleSsoCallback", () => {
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(getOrganizationByTeamId).mockResolvedValue(mockOrganization);
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
const result = await handleSsoCallback({
user: mockUser,

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