mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-25 07:50:19 -06:00
Compare commits
4 Commits
chore/hard
...
fix/form-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb17c22fc2 | ||
|
|
3d52f7b63b | ||
|
|
17222a59ef | ||
|
|
1ab856d2f0 |
@@ -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: {
|
||||
|
||||
4
.github/actions/cache-build-web/action.yml
vendored
4
.github/actions/cache-build-web/action.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
82
.github/workflows/apply-issue-labels-to-pr.yml
vendored
Normal file
82
.github/workflows/apply-issue-labels-to-pr.yml
vendored
Normal 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),
|
||||
});
|
||||
}
|
||||
4
.github/workflows/chromatic.yml
vendored
4
.github/workflows/chromatic.yml
vendored
@@ -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
27
.github/workflows/dependency-review.yml
vendored
Normal 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
|
||||
18
.github/workflows/deploy-formbricks-cloud.yml
vendored
18
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -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
|
||||
|
||||
125
.github/workflows/docker-build-validation.yml
vendored
125
.github/workflows/docker-build-validation.yml
vendored
@@ -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
|
||||
|
||||
|
||||
7
.github/workflows/formbricks-release.yml
vendored
7
.github/workflows/formbricks-release.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
18
.github/workflows/release-docker-github.yml
vendored
18
.github/workflows/release-docker-github.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
30
.github/workflows/release-helm-chart.yml
vendored
30
.github/workflows/release-helm-chart.yml
vendored
@@ -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
81
.github/workflows/scorecard.yml
vendored
Normal 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
|
||||
@@ -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 }}
|
||||
|
||||
10
.github/workflows/tolgee.yml
vendored
10
.github/workflows/tolgee.yml
vendored
@@ -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
|
||||
|
||||
20
.github/workflows/upload-sentry-sourcemaps.yml
vendored
20
.github/workflows/upload-sentry-sourcemaps.yml
vendored
@@ -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 }}
|
||||
32
.github/workflows/welcome-new-contributors.yml
vendored
Normal file
32
.github/workflows/welcome-new-contributors.yml
vendored
Normal 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. 😊
|
||||
@@ -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>
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
|
||||
industry: "ind",
|
||||
defaultBrandColor: "#fff",
|
||||
organizationTeams: [],
|
||||
isAccessControlAllowed: false,
|
||||
canDoRoleManagement: false,
|
||||
userProjectsCount: 0,
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "I’d 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 I’d 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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": "在此輸入您的答案..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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]";
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user