mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
Compare commits
3 Commits
chore/hard
...
chore/dont
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3a41272e | ||
|
|
83a38c242e | ||
|
|
c4181a1c9c |
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
|
||||
|
||||
30
.github/workflows/docker-build-validation.yml
vendored
30
.github/workflows/docker-build-validation.yml
vendored
@@ -4,9 +4,15 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- helm-chart/**
|
||||
- infra/**
|
||||
merge_group:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- helm-chart/**
|
||||
- infra/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -39,29 +45,20 @@ 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: |
|
||||
@@ -98,9 +95,6 @@ jobs:
|
||||
|
||||
- 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,8 +106,8 @@ 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..."
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
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();
|
||||
@@ -91,13 +91,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,12 +49,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",
|
||||
@@ -1316,7 +1311,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",
|
||||
@@ -1644,7 +1639,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 +1813,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -1316,7 +1311,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",
|
||||
@@ -1644,7 +1639,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 +1813,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -1207,7 +1202,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.",
|
||||
@@ -1316,7 +1311,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",
|
||||
@@ -1644,7 +1639,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 +1813,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -1316,7 +1311,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",
|
||||
@@ -1644,7 +1639,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 +1813,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -1316,7 +1311,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",
|
||||
@@ -1644,7 +1639,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 +1813,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",
|
||||
|
||||
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": "找不到團隊",
|
||||
@@ -1316,7 +1311,7 @@
|
||||
"columns": "欄位",
|
||||
"company": "公司",
|
||||
"company_logo": "公司標誌",
|
||||
"completed_responses": "部分或完整答复。",
|
||||
"completed_responses": "完成的回應。",
|
||||
"concat": "串連 +",
|
||||
"conditional_logic": "條件邏輯",
|
||||
"confirm_default_language": "確認預設語言",
|
||||
@@ -1644,7 +1639,6 @@
|
||||
"company": "公司",
|
||||
"completed": "已完成 ✅",
|
||||
"country": "國家/地區",
|
||||
"delete_response_confirmation": "這將刪除調查回覆,包括所有答案、註解、標籤、附加文件和回覆元數據。",
|
||||
"device": "裝置",
|
||||
"device_info": "裝置資訊",
|
||||
"email": "電子郵件",
|
||||
@@ -1819,6 +1813,7 @@
|
||||
"last_quarter": "上一季",
|
||||
"last_year": "去年",
|
||||
"no_responses_found": "找不到回應",
|
||||
"only_completed": "僅已完成",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
"qr_code": "QR 碼",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
@@ -22,7 +21,6 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
|
||||
{t("environments.project.teams.team_name")}
|
||||
</TableHead>
|
||||
<TableHead className="font-medium text-slate-500">{t("common.size")}</TableHead>
|
||||
<TableHead className="font-medium text-slate-500">{t("common.team_id")}</TableHead>
|
||||
<TableHead className="font-medium text-slate-500">
|
||||
{t("environments.project.teams.permission")}
|
||||
</TableHead>
|
||||
@@ -42,9 +40,6 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
|
||||
<TableCell>
|
||||
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IdBadge id={team.id} showCopyIconOnHover={true} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>
|
||||
</TableCell>
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("TeamSettingsModal", () => {
|
||||
expect(screen.getByText("common.team_name")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.members")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.teams.add_members_description")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.add_member")).toBeInTheDocument();
|
||||
expect(screen.getByText("Add member")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.projects")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.add_project")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.teams.add_projects_description")).toBeInTheDocument();
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -236,8 +235,6 @@ export const TeamSettingsModal = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<IdBadge id={team.id} label={t("common.team_id")} variant="column" />
|
||||
|
||||
{/* Members Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col space-y-1">
|
||||
@@ -353,7 +350,6 @@ export const TeamSettingsModal = ({
|
||||
/>
|
||||
<TooltipRenderer
|
||||
shouldRender={selectedMemberIds.length === orgMembers.length || hasEmptyMember}
|
||||
triggerClass="inline-block"
|
||||
tooltipContent={
|
||||
hasEmptyMember
|
||||
? t("environments.settings.teams.please_fill_all_member_fields")
|
||||
@@ -370,7 +366,7 @@ export const TeamSettingsModal = ({
|
||||
hasEmptyMember
|
||||
}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<span>{t("common.add_member")}</span>
|
||||
<span>Add member</span>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
@@ -474,7 +470,6 @@ export const TeamSettingsModal = ({
|
||||
|
||||
<TooltipRenderer
|
||||
shouldRender={selectedProjectIds.length === orgProjects.length || hasEmptyProject}
|
||||
triggerClass="inline-block"
|
||||
tooltipContent={
|
||||
hasEmptyProject
|
||||
? t("environments.settings.teams.please_fill_all_project_fields")
|
||||
|
||||
@@ -12,7 +12,7 @@ interface TeamsViewProps {
|
||||
organizationId: string;
|
||||
membershipRole?: TOrganizationRole;
|
||||
currentUserId: string;
|
||||
isAccessControlAllowed: boolean;
|
||||
canDoRoleManagement: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const TeamsView = async ({
|
||||
organizationId,
|
||||
membershipRole,
|
||||
currentUserId,
|
||||
isAccessControlAllowed,
|
||||
canDoRoleManagement,
|
||||
environmentId,
|
||||
}: TeamsViewProps) => {
|
||||
const t = await getTranslate();
|
||||
@@ -52,7 +52,7 @@ export const TeamsView = async ({
|
||||
<SettingsCard
|
||||
title={t("environments.settings.teams.teams")}
|
||||
description={t("environments.settings.teams.teams_description")}>
|
||||
{isAccessControlAllowed ? (
|
||||
{canDoRoleManagement ? (
|
||||
<TeamsTable
|
||||
teams={teams}
|
||||
membershipRole={membershipRole}
|
||||
|
||||
@@ -313,32 +313,32 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
<Section className="mx-0 mt-4">
|
||||
{firstQuestion.choices.map((choice) =>
|
||||
firstQuestion.allowMulti ? (
|
||||
<Img
|
||||
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
|
||||
key={choice.id}
|
||||
src={choice.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
|
||||
key={choice.id}
|
||||
target="_blank">
|
||||
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
<Section className="mx-0 mt-4">
|
||||
{firstQuestion.choices.map((choice) =>
|
||||
firstQuestion.allowMulti ? (
|
||||
<Img
|
||||
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
|
||||
key={choice.id}
|
||||
src={choice.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
|
||||
key={choice.id}
|
||||
target="_blank">
|
||||
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Cal:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("EditMemberships", () => {
|
||||
organization: mockOrg,
|
||||
currentUserId: "user-1",
|
||||
role: "owner",
|
||||
isAccessControlAllowed: true,
|
||||
canDoRoleManagement: true,
|
||||
isUserManagementDisabledFromUi: false,
|
||||
});
|
||||
render(ui);
|
||||
@@ -81,18 +81,18 @@ describe("EditMemberships", () => {
|
||||
expect(props.organization.id).toBe("org-1");
|
||||
expect(props.currentUserId).toBe("user-1");
|
||||
expect(props.currentUserRole).toBe("owner");
|
||||
expect(props.isAccessControlAllowed).toBe(true);
|
||||
expect(props.canDoRoleManagement).toBe(true);
|
||||
expect(props.isUserManagementDisabledFromUi).toBe(false);
|
||||
expect(Array.isArray(props.invites)).toBe(true);
|
||||
expect(Array.isArray(props.members)).toBe(true);
|
||||
});
|
||||
|
||||
test("does not render role/actions columns if isAccessControlAllowed or isUserManagementDisabledFromUi is false", async () => {
|
||||
test("does not render role/actions columns if canDoRoleManagement or isUserManagementDisabledFromUi is false", async () => {
|
||||
const ui = await EditMemberships({
|
||||
organization: mockOrg,
|
||||
currentUserId: "user-1",
|
||||
role: "member",
|
||||
isAccessControlAllowed: false,
|
||||
canDoRoleManagement: false,
|
||||
isUserManagementDisabledFromUi: true,
|
||||
});
|
||||
render(ui);
|
||||
@@ -109,7 +109,7 @@ describe("EditMemberships", () => {
|
||||
organization: mockOrg,
|
||||
currentUserId: "user-1",
|
||||
role: undefined as any,
|
||||
isAccessControlAllowed: true,
|
||||
canDoRoleManagement: true,
|
||||
isUserManagementDisabledFromUi: false,
|
||||
});
|
||||
render(ui);
|
||||
|
||||
@@ -10,7 +10,7 @@ interface EditMembershipsProps {
|
||||
organization: TOrganization;
|
||||
currentUserId: string;
|
||||
role: TOrganizationRole;
|
||||
isAccessControlAllowed: boolean;
|
||||
canDoRoleManagement: boolean;
|
||||
isUserManagementDisabledFromUi: boolean;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const EditMemberships = async ({
|
||||
organization,
|
||||
currentUserId,
|
||||
role,
|
||||
isAccessControlAllowed,
|
||||
canDoRoleManagement,
|
||||
isUserManagementDisabledFromUi,
|
||||
}: EditMembershipsProps) => {
|
||||
const members = await getMembershipByOrganizationId(organization.id);
|
||||
@@ -32,9 +32,7 @@ export const EditMemberships = async ({
|
||||
<div className="w-1/2 overflow-hidden">{t("common.full_name")}</div>
|
||||
<div className="w-1/2 overflow-hidden">{t("common.email")}</div>
|
||||
|
||||
{isAccessControlAllowed && (
|
||||
<div className="min-w-[100px] whitespace-nowrap">{t("common.role")}</div>
|
||||
)}
|
||||
{canDoRoleManagement && <div className="min-w-[100px] whitespace-nowrap">{t("common.role")}</div>}
|
||||
|
||||
<div className="min-w-[80px] whitespace-nowrap">{t("common.status")}</div>
|
||||
|
||||
@@ -50,7 +48,7 @@ export const EditMemberships = async ({
|
||||
invites={invites ?? []}
|
||||
members={members ?? []}
|
||||
currentUserRole={role}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
|
||||
/>
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("MembersInfo", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders member info and EditMembershipRole when isAccessControlAllowed", () => {
|
||||
test("renders member info and EditMembershipRole when canDoRoleManagement", () => {
|
||||
render(
|
||||
<MembersInfo
|
||||
organization={org}
|
||||
@@ -85,7 +85,7 @@ describe("MembersInfo", () => {
|
||||
invites={[]}
|
||||
currentUserRole="owner"
|
||||
currentUserId="user-1"
|
||||
isAccessControlAllowed={true}
|
||||
canDoRoleManagement={true}
|
||||
isFormbricksCloud={true}
|
||||
isUserManagementDisabledFromUi={false}
|
||||
/>
|
||||
@@ -105,7 +105,7 @@ describe("MembersInfo", () => {
|
||||
invites={[]}
|
||||
currentUserRole="owner"
|
||||
currentUserId="user-1"
|
||||
isAccessControlAllowed={true}
|
||||
canDoRoleManagement={true}
|
||||
isFormbricksCloud={true}
|
||||
isUserManagementDisabledFromUi={false}
|
||||
/>
|
||||
@@ -121,7 +121,7 @@ describe("MembersInfo", () => {
|
||||
invites={[invite]}
|
||||
currentUserRole="owner"
|
||||
currentUserId="user-1"
|
||||
isAccessControlAllowed={true}
|
||||
canDoRoleManagement={true}
|
||||
isFormbricksCloud={true}
|
||||
isUserManagementDisabledFromUi={false}
|
||||
/>
|
||||
@@ -139,7 +139,7 @@ describe("MembersInfo", () => {
|
||||
invites={[invite]}
|
||||
currentUserRole="owner"
|
||||
currentUserId="user-1"
|
||||
isAccessControlAllowed={true}
|
||||
canDoRoleManagement={true}
|
||||
isFormbricksCloud={true}
|
||||
isUserManagementDisabledFromUi={false}
|
||||
/>
|
||||
@@ -147,7 +147,7 @@ describe("MembersInfo", () => {
|
||||
expect(screen.getByTestId("expired-badge")).toHaveTextContent("Expired");
|
||||
});
|
||||
|
||||
test("does not render EditMembershipRole if isAccessControlAllowed is false", () => {
|
||||
test("does not render EditMembershipRole if canDoRoleManagement is false", () => {
|
||||
render(
|
||||
<MembersInfo
|
||||
organization={org}
|
||||
@@ -155,7 +155,7 @@ describe("MembersInfo", () => {
|
||||
invites={[]}
|
||||
currentUserRole="owner"
|
||||
currentUserId="user-1"
|
||||
isAccessControlAllowed={false}
|
||||
canDoRoleManagement={false}
|
||||
isFormbricksCloud={true}
|
||||
isUserManagementDisabledFromUi={false}
|
||||
/>
|
||||
@@ -171,7 +171,7 @@ describe("MembersInfo", () => {
|
||||
invites={[]}
|
||||
currentUserRole="owner"
|
||||
currentUserId="user-1"
|
||||
isAccessControlAllowed={true}
|
||||
canDoRoleManagement={true}
|
||||
isFormbricksCloud={true}
|
||||
isUserManagementDisabledFromUi={true}
|
||||
/>
|
||||
@@ -193,7 +193,7 @@ describe("MembersInfo", () => {
|
||||
invites={[invite]}
|
||||
currentUserRole="owner"
|
||||
currentUserId="user-1"
|
||||
isAccessControlAllowed={true}
|
||||
canDoRoleManagement={true}
|
||||
isFormbricksCloud={true}
|
||||
isUserManagementDisabledFromUi={false}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@ interface MembersInfoProps {
|
||||
invites: TInvite[];
|
||||
currentUserRole: TOrganizationRole;
|
||||
currentUserId: string;
|
||||
isAccessControlAllowed: boolean;
|
||||
canDoRoleManagement: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isUserManagementDisabledFromUi: boolean;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export const MembersInfo = ({
|
||||
currentUserRole,
|
||||
members,
|
||||
currentUserId,
|
||||
isAccessControlAllowed,
|
||||
canDoRoleManagement,
|
||||
isFormbricksCloud,
|
||||
isUserManagementDisabledFromUi,
|
||||
}: MembersInfoProps) => {
|
||||
@@ -105,7 +105,7 @@ export const MembersInfo = ({
|
||||
<p className="w-full truncate"> {member.email}</p>
|
||||
</div>
|
||||
|
||||
{isAccessControlAllowed && allMembers?.length > 0 && (
|
||||
{canDoRoleManagement && allMembers?.length > 0 && (
|
||||
<div className="ph-no-capture min-w-[100px]">
|
||||
<EditMembershipRole
|
||||
currentUserRole={currentUserRole}
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("OrganizationActions Component", () => {
|
||||
organization: { id: "org-123", name: "Test Org" } as TOrganization,
|
||||
teams: [{ id: "team-1", name: "Team 1" }],
|
||||
isInviteDisabled: false,
|
||||
isAccessControlAllowed: true,
|
||||
canDoRoleManagement: true,
|
||||
isFormbricksCloud: false,
|
||||
environmentId: "env-123",
|
||||
isMultiOrgEnabled: true,
|
||||
@@ -310,7 +310,7 @@ describe("OrganizationActions Component", () => {
|
||||
expect
|
||||
.objectContaining({
|
||||
environmentId: "env-123",
|
||||
isAccessControlAllowed: true,
|
||||
canDoRoleManagement: true,
|
||||
isFormbricksCloud: false,
|
||||
teams: expect.arrayContaining(defaultProps.teams),
|
||||
membershipRole: "owner",
|
||||
|
||||
@@ -31,7 +31,7 @@ interface OrganizationActionsProps {
|
||||
organization: TOrganization;
|
||||
teams: TOrganizationTeam[];
|
||||
isInviteDisabled: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
canDoRoleManagement: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
environmentId: string;
|
||||
isMultiOrgEnabled: boolean;
|
||||
@@ -45,7 +45,7 @@ export const OrganizationActions = ({
|
||||
teams,
|
||||
isLeaveOrganizationDisabled,
|
||||
isInviteDisabled,
|
||||
isAccessControlAllowed,
|
||||
canDoRoleManagement,
|
||||
isFormbricksCloud,
|
||||
environmentId,
|
||||
isMultiOrgEnabled,
|
||||
@@ -154,7 +154,7 @@ export const OrganizationActions = ({
|
||||
setOpen={setInviteMemberModalOpen}
|
||||
onSubmit={handleAddMembers}
|
||||
membershipRole={membershipRole}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
environmentId={environmentId}
|
||||
teams={teams}
|
||||
|
||||
@@ -15,14 +15,14 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
interface BulkInviteTabProps {
|
||||
setOpen: (v: boolean) => void;
|
||||
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
|
||||
isAccessControlAllowed: boolean;
|
||||
canDoRoleManagement: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export const BulkInviteTab = ({
|
||||
setOpen,
|
||||
onSubmit,
|
||||
isAccessControlAllowed,
|
||||
canDoRoleManagement,
|
||||
isFormbricksCloud,
|
||||
}: BulkInviteTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
@@ -48,7 +48,7 @@ export const BulkInviteTab = ({
|
||||
},
|
||||
complete: (results: ParseResult<{ name: string; email: string; role: string }>) => {
|
||||
const members = results.data.map((csv) => {
|
||||
let orgRole = isAccessControlAllowed ? csv.role.trim().toLowerCase() : "owner";
|
||||
let orgRole = canDoRoleManagement ? csv.role.trim().toLowerCase() : "owner";
|
||||
if (!isFormbricksCloud) {
|
||||
orgRole = orgRole === "billing" ? "owner" : orgRole;
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export const BulkInviteTab = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAccessControlAllowed && (
|
||||
{!canDoRoleManagement && (
|
||||
<Alert variant="default" className="mt-1.5 flex items-start bg-slate-50">
|
||||
<AlertDescription className="ml-2">
|
||||
<p className="text-sm">
|
||||
|
||||
@@ -35,7 +35,7 @@ const defaultProps = {
|
||||
{ id: "team-1", name: "Team 1" },
|
||||
{ id: "team-2", name: "Team 2" },
|
||||
],
|
||||
isAccessControlAllowed: true,
|
||||
canDoRoleManagement: true,
|
||||
isFormbricksCloud: true,
|
||||
environmentId: "env-1",
|
||||
membershipRole: "owner" as TOrganizationRole,
|
||||
@@ -85,21 +85,21 @@ describe("IndividualInviteTab", () => {
|
||||
});
|
||||
|
||||
test("shows member role info alert when role is member", async () => {
|
||||
render(<IndividualInviteTab {...defaultProps} isAccessControlAllowed={true} />);
|
||||
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={true} />);
|
||||
await userEvent.type(screen.getByLabelText("common.full_name"), "Test User");
|
||||
await userEvent.type(screen.getByLabelText("common.email"), "test@example.com");
|
||||
// Simulate selecting member role
|
||||
// Not needed as default is member if isAccessControlAllowed is true
|
||||
// Not needed as default is member if canDoRoleManagement is true
|
||||
expect(screen.getByText("environments.settings.teams.member_role_info_message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows team select when isAccessControlAllowed is true", () => {
|
||||
render(<IndividualInviteTab {...defaultProps} isAccessControlAllowed={true} />);
|
||||
test("shows team select when canDoRoleManagement is true", () => {
|
||||
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={true} />);
|
||||
expect(screen.getByTestId("multi-select")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows upgrade alert when isAccessControlAllowed is false", () => {
|
||||
render(<IndividualInviteTab {...defaultProps} isAccessControlAllowed={false} />);
|
||||
test("shows upgrade alert when canDoRoleManagement is false", () => {
|
||||
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={false} />);
|
||||
expect(screen.getByText("environments.settings.teams.upgrade_plan_notice_message")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.start_free_trial")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ interface IndividualInviteTabProps {
|
||||
setOpen: (v: boolean) => void;
|
||||
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
|
||||
teams: TOrganizationTeam[];
|
||||
isAccessControlAllowed: boolean;
|
||||
canDoRoleManagement: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
environmentId: string;
|
||||
membershipRole?: TOrganizationRole;
|
||||
@@ -33,7 +33,7 @@ export const IndividualInviteTab = ({
|
||||
setOpen,
|
||||
onSubmit,
|
||||
teams,
|
||||
isAccessControlAllowed,
|
||||
canDoRoleManagement,
|
||||
isFormbricksCloud,
|
||||
environmentId,
|
||||
membershipRole,
|
||||
@@ -52,7 +52,7 @@ export const IndividualInviteTab = ({
|
||||
const form = useForm<TFormData>({
|
||||
resolver: zodResolver(ZFormSchema),
|
||||
defaultValues: {
|
||||
role: isAccessControlAllowed ? "member" : "owner",
|
||||
role: canDoRoleManagement ? "member" : "owner",
|
||||
teamIds: [],
|
||||
},
|
||||
});
|
||||
@@ -106,7 +106,7 @@ export const IndividualInviteTab = ({
|
||||
<div>
|
||||
<AddMemberRole
|
||||
control={control}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
@@ -117,7 +117,7 @@ export const IndividualInviteTab = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAccessControlAllowed && (
|
||||
{canDoRoleManagement && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="teamIds"
|
||||
@@ -143,7 +143,7 @@ export const IndividualInviteTab = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isAccessControlAllowed && (
|
||||
{!canDoRoleManagement && (
|
||||
<Alert>
|
||||
<AlertDescription className="flex">
|
||||
{t("environments.settings.teams.upgrade_plan_notice_message")}
|
||||
|
||||
@@ -55,7 +55,7 @@ const defaultProps = {
|
||||
setOpen: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
teams: [],
|
||||
isAccessControlAllowed: true,
|
||||
canDoRoleManagement: true,
|
||||
isFormbricksCloud: true,
|
||||
environmentId: "env-1",
|
||||
membershipRole: "owner" as TOrganizationRole,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user