mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-27 00:40:29 -06:00
Compare commits
1 Commits
fix/cross-
...
feautre/ai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a141171c5 |
@@ -1,232 +0,0 @@
|
||||
---
|
||||
description: Security best practices and guidelines for writing GitHub Actions and workflows
|
||||
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
|
||||
---
|
||||
|
||||
# GitHub Actions Security Best Practices
|
||||
|
||||
## Required Security Measures
|
||||
|
||||
### 1. Set Minimum GITHUB_TOKEN Permissions
|
||||
|
||||
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
contents: read
|
||||
# Only add additional permissions if absolutely necessary:
|
||||
# pull-requests: write # for commenting on PRs
|
||||
# issues: write # for creating/updating issues
|
||||
# checks: write # for publishing check results
|
||||
```
|
||||
|
||||
### 2. Add Harden-Runner as First Step
|
||||
|
||||
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
|
||||
|
||||
```yaml
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit # or 'block' for stricter security
|
||||
```
|
||||
|
||||
### 3. Pin Actions to Full Commit SHA
|
||||
|
||||
**Always** pin third-party actions to their full commit SHA, not tags:
|
||||
|
||||
```yaml
|
||||
# ❌ BAD - uses mutable tag
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ✅ GOOD - pinned to immutable commit SHA
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
```
|
||||
|
||||
### 4. Secure Variable Handling
|
||||
|
||||
Prevent command injection by properly quoting variables:
|
||||
|
||||
```yaml
|
||||
# ❌ BAD - potential command injection
|
||||
run: echo "Processing ${{ inputs.user_input }}"
|
||||
|
||||
# ✅ GOOD - properly quoted
|
||||
env:
|
||||
USER_INPUT: ${{ inputs.user_input }}
|
||||
run: echo "Processing ${USER_INPUT}"
|
||||
```
|
||||
|
||||
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
|
||||
|
||||
### 5. Environment Variables for Secrets
|
||||
|
||||
Store sensitive data in environment variables, not inline:
|
||||
|
||||
```yaml
|
||||
# ❌ BAD
|
||||
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
|
||||
|
||||
# ✅ GOOD
|
||||
env:
|
||||
API_TOKEN: ${{ secrets.TOKEN }}
|
||||
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
|
||||
```
|
||||
|
||||
## Workflow Structure Best Practices
|
||||
|
||||
### Required Workflow Elements
|
||||
|
||||
```yaml
|
||||
name: "Descriptive Workflow Name"
|
||||
|
||||
on:
|
||||
# Define specific triggers
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Always set explicit permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
job-name:
|
||||
name: "Descriptive Job Name"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30 # tune per job; standardize repo-wide
|
||||
|
||||
# Set job-level permissions if different from workflow level
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
# Always start with Harden-Runner on ubuntu-latest
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
# Pin all actions to commit SHA
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
```
|
||||
|
||||
### Input Validation for Actions
|
||||
|
||||
For composite actions, always validate inputs:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
user_input:
|
||||
description: "User provided input"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Validate input
|
||||
shell: bash
|
||||
run: |
|
||||
# Harden shell and validate input format/content before use
|
||||
set -euo pipefail
|
||||
|
||||
USER_INPUT="${{ inputs.user_input }}"
|
||||
|
||||
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "❌ Invalid input format"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Docker Security in Actions
|
||||
|
||||
### Pin Docker Images to Digests
|
||||
|
||||
```yaml
|
||||
# ❌ BAD - mutable tag
|
||||
container: node:18
|
||||
|
||||
# ✅ GOOD - pinned to digest
|
||||
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Secure File Operations
|
||||
|
||||
```yaml
|
||||
- name: Process files securely
|
||||
shell: bash
|
||||
env:
|
||||
FILE_PATH: ${{ inputs.file_path }}
|
||||
run: |
|
||||
set -euo pipefail # Fail on errors, undefined vars, pipe failures
|
||||
|
||||
# Use absolute paths and validate
|
||||
SAFE_PATH=$(realpath "${FILE_PATH}")
|
||||
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
|
||||
echo "❌ Path outside workspace"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Artifact Handling
|
||||
|
||||
```yaml
|
||||
- name: Upload artifacts securely
|
||||
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: |
|
||||
dist/
|
||||
!dist/**/*.log # Exclude sensitive files
|
||||
retention-days: 30
|
||||
```
|
||||
|
||||
### GHCR authentication for pulls/scans
|
||||
|
||||
```yaml
|
||||
# Minimal permissions required for GHCR pulls/scans
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Minimum GITHUB_TOKEN permissions set
|
||||
- [ ] Harden-Runner added to all ubuntu-latest jobs
|
||||
- [ ] All third-party actions pinned to commit SHA
|
||||
- [ ] Input validation implemented for custom actions
|
||||
- [ ] Variables properly quoted in shell scripts
|
||||
- [ ] Secrets stored in environment variables
|
||||
- [ ] Docker images pinned to digests (if used)
|
||||
- [ ] Error handling with `set -euo pipefail`
|
||||
- [ ] File paths validated and sanitized
|
||||
- [ ] No sensitive data in logs or outputs
|
||||
- [ ] GHCR login performed before pulls/scans (packages: read)
|
||||
- [ ] Job timeouts configured (`timeout-minutes`)
|
||||
|
||||
## Recommended Additional Workflows
|
||||
|
||||
Consider adding these security-focused workflows to your repository:
|
||||
|
||||
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
|
||||
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
|
||||
3. **Dependabot Configuration** - Automated dependency updates
|
||||
|
||||
## Resources
|
||||
|
||||
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
|
||||
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
|
||||
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)
|
||||
@@ -24,6 +24,36 @@ runs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate Sentry auth token
|
||||
shell: bash
|
||||
env:
|
||||
SENTRY_TOKEN: ${{ inputs.sentry_auth_token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "🔐 Validating Sentry authentication 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" \
|
||||
"https://sentry.io/api/0/organizations/formbricks/")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
|
||||
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
|
||||
if [ -f /tmp/sentry_response.json ]; then
|
||||
echo "Response body:"
|
||||
cat /tmp/sentry_response.json
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Sentry auth token validated successfully"
|
||||
|
||||
# Clean up temp file
|
||||
rm -f /tmp/sentry_response.json
|
||||
|
||||
- name: Extract sourcemaps from Docker image
|
||||
shell: bash
|
||||
env:
|
||||
@@ -34,16 +64,16 @@ runs:
|
||||
# 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}"
|
||||
echo "Provided: $DOCKER_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📦 Extracting sourcemaps from Docker image: ${DOCKER_IMAGE}"
|
||||
echo "📦 Extracting sourcemaps from Docker image: $DOCKER_IMAGE"
|
||||
|
||||
# Create temporary container from the image and capture its ID
|
||||
echo "Creating temporary container..."
|
||||
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
|
||||
echo "Container created with ID: ${CONTAINER_ID}"
|
||||
echo "Container created with ID: $CONTAINER_ID"
|
||||
|
||||
# Set up cleanup function to ensure container is removed on script exit
|
||||
cleanup_container() {
|
||||
@@ -55,7 +85,7 @@ runs:
|
||||
# Remove the container if it exists (ignore errors if already removed)
|
||||
if [ -n "$CONTAINER_ID" ]; then
|
||||
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
|
||||
echo "Container ${CONTAINER_ID} removed"
|
||||
echo "Container $CONTAINER_ID removed"
|
||||
fi
|
||||
|
||||
# Exit with the original exit code to preserve script success/failure status
|
||||
@@ -76,7 +106,7 @@ runs:
|
||||
fi
|
||||
|
||||
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
|
||||
echo "✅ Found ${sourcemap_count} sourcemap files"
|
||||
echo "✅ Found $sourcemap_count sourcemap files"
|
||||
|
||||
if [ "$sourcemap_count" -eq 0 ]; then
|
||||
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
|
||||
|
||||
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
|
||||
15
.github/workflows/deploy-formbricks-cloud.yml
vendored
15
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -37,22 +37,17 @@ on:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
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:
|
||||
|
||||
13
.github/workflows/docker-build-validation.yml
vendored
13
.github/workflows/docker-build-validation.yml
vendored
@@ -39,21 +39,14 @@ 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
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
|
||||
40
.github/workflows/docker-security-scan.yml
vendored
40
.github/workflows/docker-security-scan.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: Docker Security Scan
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *" # Daily at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["Docker Release to Github"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Vulnerability Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
|
||||
with:
|
||||
image-ref: "ghcr.io/${{ github.repository }}:latest"
|
||||
format: "sarif"
|
||||
output: "trivy-results.sarif"
|
||||
severity: "CRITICAL,HIGH,MEDIUM,LOW"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
|
||||
if: ${{ always() && hashFiles('trivy-results.sarif') != '' }}
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
24
.github/workflows/formbricks-release.yml
vendored
24
.github/workflows/formbricks-release.yml
vendored
@@ -7,13 +7,12 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
name: Build & release docker image
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/release-docker-github.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
@@ -21,9 +20,6 @@ jobs:
|
||||
|
||||
helm-chart-release:
|
||||
name: Release Helm Chart
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/release-helm-chart.yml
|
||||
secrets: inherit
|
||||
needs:
|
||||
@@ -33,9 +29,6 @@ jobs:
|
||||
|
||||
deploy-formbricks-cloud:
|
||||
name: Deploy Helm Chart to Formbricks Cloud
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
secrets: inherit
|
||||
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
||||
needs:
|
||||
@@ -43,7 +36,7 @@ jobs:
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
ENVIRONMENT: ${{ env.ENVIRONMENT }}
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
@@ -54,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
|
||||
|
||||
@@ -71,4 +59,4 @@ jobs:
|
||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
environment: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
environment: ${{ env.ENVIRONMENT }}
|
||||
|
||||
@@ -41,8 +41,6 @@ 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
|
||||
@@ -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
|
||||
|
||||
|
||||
3
.github/workflows/release-docker-github.yml
vendored
3
.github/workflows/release-docker-github.yml
vendored
@@ -26,9 +26,6 @@ env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
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
|
||||
8
.github/workflows/semantic-pull-requests.yml
vendored
8
.github/workflows/semantic-pull-requests.yml
vendored
@@ -56,3 +56,11 @@ jobs:
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
||||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Thank you for following the naming conventions for pull request titles! 🙏
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -25,13 +25,8 @@ jobs:
|
||||
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
|
||||
|
||||
|
||||
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. 😊
|
||||
@@ -31,10 +31,6 @@
|
||||
{
|
||||
"language": "pt-PT",
|
||||
"path": "./apps/web/locales/pt-PT.json"
|
||||
},
|
||||
{
|
||||
"language": "ro-RO",
|
||||
"path": "./apps/web/locales/ro-RO.json"
|
||||
}
|
||||
],
|
||||
"forceMode": "OVERRIDE"
|
||||
|
||||
@@ -45,7 +45,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("LandingSidebar component", () => {
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
|
||||
const organization = { id: "o1", name: "orgOne" } as any;
|
||||
const organizations = [
|
||||
{ id: "o2", name: "betaOrg" },
|
||||
|
||||
@@ -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")}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
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>
|
||||
),
|
||||
}));
|
||||
@@ -113,6 +113,7 @@ const mockUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
@@ -201,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;
|
||||
});
|
||||
@@ -314,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({
|
||||
@@ -336,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 () => {
|
||||
@@ -392,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({
|
||||
@@ -415,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>
|
||||
),
|
||||
}));
|
||||
@@ -111,6 +111,7 @@ const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
imageUrl: "http://example.com/avatar.png",
|
||||
emailVerified: new Date(),
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
@@ -156,7 +157,7 @@ const defaultProps = {
|
||||
membershipRole: "owner" as const,
|
||||
organizationProjectsLimit: 5,
|
||||
isLicenseActive: true,
|
||||
isAccessControlAllowed: true,
|
||||
canDoRoleManagement: true,
|
||||
};
|
||||
|
||||
describe("MainNavigation", () => {
|
||||
@@ -346,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", () => {
|
||||
@@ -359,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} />
|
||||
<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
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("EnvironmentPage", () => {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { deleteFile } from "@/lib/storage/service";
|
||||
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
@@ -13,6 +15,8 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TUserPersonalInfoUpdateInput,
|
||||
@@ -93,6 +97,58 @@ export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalIn
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
avatarUrl: z.string(),
|
||||
});
|
||||
|
||||
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZRemoveAvatarAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
const imageUrl = ctx.user.imageUrl;
|
||||
if (!imageUrl) {
|
||||
throw new Error("Image not found");
|
||||
}
|
||||
|
||||
const fileName = getFileNameWithIdFromUrl(imageUrl);
|
||||
if (!fileName) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
|
||||
if (!deletionResult.success) {
|
||||
throw new Error("Deletion failed");
|
||||
}
|
||||
const result = await updateUser(ctx.user.id, { imageUrl: null });
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const resetPasswordAction = authenticatedActionClient.action(
|
||||
withAuditLogging(
|
||||
"passwordReset",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import * as profileActions from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
|
||||
import * as fileUploadHooks from "@/app/lib/fileUpload";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Session } from "next-auth";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { EditProfileAvatarForm } from "./EditProfileAvatarForm";
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
ProfileAvatar: ({ imageUrl }) => <div data-testid="profile-avatar">{imageUrl || "No Avatar"}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
|
||||
updateAvatarAction: vi.fn(),
|
||||
removeAvatarAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/fileUpload", () => ({
|
||||
handleFileUpload: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSession: Session = {
|
||||
user: { id: "user-id" },
|
||||
expires: "session-expires-at",
|
||||
};
|
||||
const environmentId = "test-env-id";
|
||||
|
||||
describe("EditProfileAvatarForm", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(profileActions.updateAvatarAction).mockResolvedValue({});
|
||||
vi.mocked(profileActions.removeAvatarAction).mockResolvedValue({});
|
||||
vi.mocked(fileUploadHooks.handleFileUpload).mockResolvedValue({
|
||||
url: "new-avatar.jpg",
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("renders correctly without an existing image", () => {
|
||||
render(<EditProfileAvatarForm session={mockSession} environmentId={environmentId} imageUrl={null} />);
|
||||
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("No Avatar");
|
||||
expect(screen.getByText("environments.settings.profile.upload_image")).toBeInTheDocument();
|
||||
expect(screen.queryByText("environments.settings.profile.remove_image")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly with an existing image", () => {
|
||||
render(
|
||||
<EditProfileAvatarForm
|
||||
session={mockSession}
|
||||
environmentId={environmentId}
|
||||
imageUrl="existing-avatar.jpg"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("existing-avatar.jpg");
|
||||
expect(screen.getByText("environments.settings.profile.change_image")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.profile.remove_image")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles image removal successfully", async () => {
|
||||
render(
|
||||
<EditProfileAvatarForm
|
||||
session={mockSession}
|
||||
environmentId={environmentId}
|
||||
imageUrl="existing-avatar.jpg"
|
||||
/>
|
||||
);
|
||||
const removeButton = screen.getByText("environments.settings.profile.remove_image");
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(profileActions.removeAvatarAction).toHaveBeenCalledWith({ environmentId });
|
||||
});
|
||||
});
|
||||
|
||||
test("shows error if removeAvatarAction fails", async () => {
|
||||
vi.mocked(profileActions.removeAvatarAction).mockRejectedValue(new Error("API error"));
|
||||
render(
|
||||
<EditProfileAvatarForm
|
||||
session={mockSession}
|
||||
environmentId={environmentId}
|
||||
imageUrl="existing-avatar.jpg"
|
||||
/>
|
||||
);
|
||||
const removeButton = screen.getByText("environments.settings.profile.remove_image");
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
|
||||
"environments.settings.profile.avatar_update_failed"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
removeAvatarAction,
|
||||
updateAvatarAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
|
||||
import { handleFileUpload } from "@/app/lib/fileUpload";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Session } from "next-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
interface EditProfileAvatarFormProps {
|
||||
session: Session;
|
||||
environmentId: string;
|
||||
imageUrl: string | null;
|
||||
}
|
||||
|
||||
export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: EditProfileAvatarFormProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
const fileSchema =
|
||||
typeof window !== "undefined"
|
||||
? z
|
||||
.instanceof(FileList)
|
||||
.refine((files) => files.length === 1, t("environments.settings.profile.you_must_select_a_file"))
|
||||
.refine((files) => {
|
||||
const file = files[0];
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
return allowedTypes.includes(file.type);
|
||||
}, t("environments.settings.profile.invalid_file_type"))
|
||||
.refine((files) => {
|
||||
const file = files[0];
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
return file.size <= maxSize;
|
||||
}, t("environments.settings.profile.file_size_must_be_less_than_10mb"))
|
||||
: z.any();
|
||||
|
||||
const formSchema = z.object({
|
||||
file: fileSchema,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const handleUpload = async (file: File, environmentId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (imageUrl) {
|
||||
// If avatar image already exists, then remove it before update action
|
||||
await removeAvatarAction({ environmentId });
|
||||
}
|
||||
const { url, error } = await handleFileUpload(file, environmentId);
|
||||
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateAvatarAction({ avatarUrl: url });
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast.error(t("environments.settings.profile.avatar_update_failed"));
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await removeAvatarAction({ environmentId });
|
||||
} catch (err) {
|
||||
toast.error(t("environments.settings.profile.avatar_update_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const file = data.file[0];
|
||||
if (file) {
|
||||
await handleUpload(file, environmentId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative h-10 w-10 overflow-hidden rounded-full">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
|
||||
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileAvatar userId={session.user.id} imageUrl={imageUrl} />
|
||||
</div>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4">
|
||||
<FormField
|
||||
name="file"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<div className="flex">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mr-2"
|
||||
variant={!!fieldState.error?.message ? "destructive" : "secondary"}
|
||||
onClick={() => {
|
||||
inputRef.current?.click();
|
||||
}}>
|
||||
{imageUrl
|
||||
? t("environments.settings.profile.change_image")
|
||||
: t("environments.settings.profile.upload_image")}
|
||||
<input
|
||||
type="file"
|
||||
id="hiddenFileInput"
|
||||
ref={(e) => {
|
||||
field.ref(e);
|
||||
inputRef.current = e;
|
||||
}}
|
||||
className="hidden"
|
||||
accept="image/jpeg, image/png, image/webp"
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.files);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{imageUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
className="mr-2"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleRemove}>
|
||||
{t("environments.settings.profile.remove_image")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -49,12 +49,15 @@ describe("Loading", () => {
|
||||
);
|
||||
|
||||
const loadingCards = screen.getAllByTestId("loading-card");
|
||||
expect(loadingCards).toHaveLength(2);
|
||||
expect(loadingCards).toHaveLength(3);
|
||||
|
||||
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information");
|
||||
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info");
|
||||
|
||||
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.delete_account");
|
||||
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
|
||||
expect(loadingCards[1]).toHaveTextContent("common.avatar");
|
||||
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.organization_identification");
|
||||
|
||||
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.delete_account");
|
||||
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,11 @@ const Loading = () => {
|
||||
{ classes: "h-6 w-64" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("common.avatar"),
|
||||
description: t("environments.settings.profile.organization_identification"),
|
||||
skeletonLines: [{ classes: "h-10 w-10" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
{
|
||||
title: t("environments.settings.profile.delete_account"),
|
||||
description: t("environments.settings.profile.confirm_delete_account"),
|
||||
|
||||
@@ -55,6 +55,11 @@ vi.mock(
|
||||
vi.mock("./components/DeleteAccount", () => ({
|
||||
DeleteAccount: ({ user }) => <div data-testid="delete-account">DeleteAccount: {user.id}</div>,
|
||||
}));
|
||||
vi.mock("./components/EditProfileAvatarForm", () => ({
|
||||
EditProfileAvatarForm: ({ _, environmentId }) => (
|
||||
<div data-testid="edit-profile-avatar-form">EditProfileAvatarForm: {environmentId}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./components/EditProfileDetailsForm", () => ({
|
||||
EditProfileDetailsForm: ({ user }) => (
|
||||
<div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div>
|
||||
@@ -68,6 +73,7 @@ const mockUser = {
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
imageUrl: "http://example.com/avatar.png",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||
@@ -111,6 +117,7 @@ describe("ProfilePage", () => {
|
||||
"AccountSettingsNavbar: env-123 profile"
|
||||
);
|
||||
expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("edit-profile-avatar-form")).toBeInTheDocument();
|
||||
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();
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteAccount } from "./components/DeleteAccount";
|
||||
import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm";
|
||||
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
@@ -49,6 +50,17 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
isPasswordResetEnabled={isPasswordResetEnabled}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("common.avatar")}
|
||||
description={t("environments.settings.profile.organization_identification")}>
|
||||
{user && (
|
||||
<EditProfileAvatarForm
|
||||
session={session}
|
||||
environmentId={environmentId}
|
||||
imageUrl={user.imageUrl}
|
||||
/>
|
||||
)}
|
||||
</SettingsCard>
|
||||
{user.identityProvider === "email" && (
|
||||
<SettingsCard
|
||||
title={t("common.security")}
|
||||
|
||||
@@ -126,6 +126,7 @@ const mockUser = {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
notificationSettings: { alert: {} },
|
||||
|
||||
@@ -60,6 +60,7 @@ const mockResponses = [
|
||||
userAgent: { browser: "Chrome", os: "Mac OS", device: "Desktop" },
|
||||
url: "http://localhost:3000",
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
} as unknown as TResponse,
|
||||
{
|
||||
@@ -73,6 +74,7 @@ const mockResponses = [
|
||||
userAgent: { browser: "Firefox", os: "Windows", device: "Desktop" },
|
||||
url: "http://localhost:3000/page2",
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
} as unknown as TResponse,
|
||||
{
|
||||
@@ -86,6 +88,7 @@ const mockResponses = [
|
||||
userAgent: { browser: "Safari", os: "iOS", device: "Mobile" },
|
||||
url: "http://localhost:3000/page3",
|
||||
},
|
||||
notes: [],
|
||||
tags: [],
|
||||
} as unknown as TResponse,
|
||||
] as unknown as TResponse[];
|
||||
@@ -128,6 +131,7 @@ const mockUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -82,6 +82,7 @@ export const ResponseCardModal = ({
|
||||
survey={survey}
|
||||
response={responses[currentIndex]}
|
||||
user={user}
|
||||
pageType="response"
|
||||
environment={environment}
|
||||
environmentTags={environmentTags}
|
||||
isReadOnly={isReadOnly}
|
||||
|
||||
@@ -117,6 +117,17 @@ const mockResponses: TResponse[] = [
|
||||
singleUseId: null,
|
||||
ttc: {},
|
||||
tags: [{ id: "tag1", name: "Tag1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }],
|
||||
notes: [
|
||||
{
|
||||
id: "note1",
|
||||
text: "Note 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isResolved: false,
|
||||
isEdited: false,
|
||||
user: { id: "user1", name: "User 1" },
|
||||
},
|
||||
],
|
||||
variables: { var1: "Response Var Value" },
|
||||
language: "en",
|
||||
contact: null,
|
||||
@@ -133,6 +144,7 @@ const mockResponses: TResponse[] = [
|
||||
singleUseId: null,
|
||||
ttc: {},
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
language: "de",
|
||||
contact: null,
|
||||
@@ -145,6 +157,7 @@ const mockUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
@@ -221,17 +234,12 @@ describe("ResponseDataView", () => {
|
||||
status: "Completed",
|
||||
responseId: "response1",
|
||||
tags: mockResponses[0].tags,
|
||||
notes: mockResponses[0].notes,
|
||||
variables: { var1: "Response Var Value" },
|
||||
verifiedEmail: "test@example.com",
|
||||
language: "en",
|
||||
person: null,
|
||||
contactAttributes: null,
|
||||
meta: {
|
||||
url: "http://localhost",
|
||||
userAgent: {
|
||||
browser: "test-agent",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
responseData: {
|
||||
@@ -241,17 +249,12 @@ describe("ResponseDataView", () => {
|
||||
status: "Not Completed",
|
||||
responseId: "response2",
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
verifiedEmail: "",
|
||||
language: "de",
|
||||
person: null,
|
||||
contactAttributes: null,
|
||||
meta: {
|
||||
url: "http://localhost",
|
||||
userAgent: {
|
||||
browser: "test-agent-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export const mapResponsesToTableData = (
|
||||
: t("environments.surveys.responses.not_completed"),
|
||||
responseId: response.id,
|
||||
tags: response.tags,
|
||||
notes: response.notes,
|
||||
variables: survey.variables.reduce(
|
||||
(acc, curr) => {
|
||||
return Object.assign(acc, { [curr.id]: response.variables[curr.id] });
|
||||
@@ -100,7 +101,6 @@ export const mapResponsesToTableData = (
|
||||
language: response.language,
|
||||
person: response.contact,
|
||||
contactAttributes: response.contactAttributes,
|
||||
meta: response.meta,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ const mockResponses: TResponse[] = [
|
||||
finished: true,
|
||||
data: {},
|
||||
meta: { userAgent: {} },
|
||||
notes: [],
|
||||
tags: [],
|
||||
} as unknown as TResponse,
|
||||
{
|
||||
@@ -117,6 +118,7 @@ const mockResponses: TResponse[] = [
|
||||
finished: true,
|
||||
data: {},
|
||||
meta: { userAgent: {} },
|
||||
notes: [],
|
||||
tags: [],
|
||||
} as unknown as TResponse,
|
||||
];
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyQuestion,
|
||||
@@ -102,12 +102,6 @@ vi.mock("lucide-react", () => ({
|
||||
EyeOffIcon: () => <span>EyeOff</span>,
|
||||
MailIcon: () => <span>Mail</span>,
|
||||
TagIcon: () => <span>Tag</span>,
|
||||
MousePointerClickIcon: () => <span>MousePointerClick</span>,
|
||||
AirplayIcon: () => <span>Airplay</span>,
|
||||
ArrowUpFromDotIcon: () => <span>ArrowUpFromDot</span>,
|
||||
FlagIcon: () => <span>Flag</span>,
|
||||
GlobeIcon: () => <span>Globe</span>,
|
||||
SmartphoneIcon: () => <span>Smartphone</span>,
|
||||
}));
|
||||
|
||||
// Mock new dependencies
|
||||
@@ -229,6 +223,14 @@ const mockResponseData = {
|
||||
var1: "Segment A",
|
||||
var2: 100,
|
||||
},
|
||||
notes: [
|
||||
{
|
||||
id: "note1",
|
||||
text: "This is a note",
|
||||
updatedAt: new Date(),
|
||||
user: { name: "User" } as unknown as TResponseNoteUser,
|
||||
} as TResponseNote,
|
||||
],
|
||||
status: "completed",
|
||||
tags: [{ id: "tag1", name: "Important" } as unknown as TTag],
|
||||
language: "default",
|
||||
@@ -294,6 +296,14 @@ describe("generateResponseTableColumns", () => {
|
||||
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||
expect(hf1Col).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should generate Notes column", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const notesCol = columns.find((col) => (col as any).accessorKey === "notes");
|
||||
expect(notesCol).toBeDefined();
|
||||
(notesCol?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
||||
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseTableColumns", () => {
|
||||
@@ -433,6 +443,36 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
||||
expect(cellResult).toBeUndefined();
|
||||
});
|
||||
|
||||
test("notesColumn renders when notes is an array", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
|
||||
expect(notesColumn).toBeDefined();
|
||||
|
||||
// Mock a response with notes
|
||||
const mockRow = {
|
||||
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
notesColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
|
||||
});
|
||||
|
||||
test("notesColumn returns undefined when notes is not an array", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
|
||||
expect(notesColumn).toBeDefined();
|
||||
|
||||
// Mock a response with no notes
|
||||
const mockRow = {
|
||||
original: { notes: null },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(cellResult).toBeUndefined();
|
||||
});
|
||||
|
||||
test("variableColumns render variable values correctly", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -18,14 +19,43 @@ import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
COLUMNS_ICON_MAP,
|
||||
METADATA_FIELDS,
|
||||
getAddressFieldLabel,
|
||||
getContactInfoFieldLabel,
|
||||
getMetadataFieldLabel,
|
||||
getMetadataValue,
|
||||
} from "../lib/utils";
|
||||
|
||||
const getAddressFieldLabel = (field: string, t: TFnType) => {
|
||||
switch (field) {
|
||||
case "addressLine1":
|
||||
return t("environments.surveys.responses.address_line_1");
|
||||
case "addressLine2":
|
||||
return t("environments.surveys.responses.address_line_2");
|
||||
case "city":
|
||||
return t("environments.surveys.responses.city");
|
||||
case "state":
|
||||
return t("environments.surveys.responses.state_region");
|
||||
case "zip":
|
||||
return t("environments.surveys.responses.zip_post_code");
|
||||
case "country":
|
||||
return t("environments.surveys.responses.country");
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getContactInfoFieldLabel = (field: string, t: TFnType) => {
|
||||
switch (field) {
|
||||
case "firstName":
|
||||
return t("environments.surveys.responses.first_name");
|
||||
case "lastName":
|
||||
return t("environments.surveys.responses.last_name");
|
||||
case "email":
|
||||
return t("environments.surveys.responses.email");
|
||||
case "phone":
|
||||
return t("environments.surveys.responses.phone");
|
||||
case "company":
|
||||
return t("environments.surveys.responses.company");
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getQuestionColumnsData = (
|
||||
question: TSurveyQuestion,
|
||||
@@ -64,7 +94,7 @@ const getQuestionColumnsData = (
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{choiceIds.map((choiceId, index) => (
|
||||
<IdBadge key={`${choiceId}-${index}`} id={choiceId} />
|
||||
<IdBadge key={choiceId || index} id={choiceId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -165,7 +195,6 @@ const getQuestionColumnsData = (
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -217,7 +246,6 @@ const getQuestionColumnsData = (
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -226,33 +254,6 @@ const getQuestionColumnsData = (
|
||||
}
|
||||
};
|
||||
|
||||
const getMetadataColumnsData = (t: TFnType): ColumnDef<TResponseTableData>[] => {
|
||||
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
|
||||
|
||||
METADATA_FIELDS.forEach((label) => {
|
||||
const IconComponent = COLUMNS_ICON_MAP[label];
|
||||
|
||||
metadataColumns.push({
|
||||
accessorKey: label,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
|
||||
<span className="truncate">{getMetadataFieldLabel(label, t)}</span>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const value = getMetadataValue(row.original.meta, label);
|
||||
if (value) {
|
||||
return <div className="truncate text-slate-900">{value}</div>;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return metadataColumns;
|
||||
};
|
||||
|
||||
export const generateResponseTableColumns = (
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
@@ -311,7 +312,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]} />;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -324,16 +325,27 @@ 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const notesColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "notes",
|
||||
header: t("common.notes"),
|
||||
cell: ({ row }) => {
|
||||
const notes = row.original.notes;
|
||||
if (Array.isArray(notes)) {
|
||||
const notesArray = notes.map((note) => note.text);
|
||||
return processResponseData(notesArray);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const variableColumns: ColumnDef<TResponseTableData>[] = survey.variables.map((variable) => {
|
||||
return {
|
||||
accessorKey: variable.id,
|
||||
@@ -374,8 +386,6 @@ export const generateResponseTableColumns = (
|
||||
})
|
||||
: [];
|
||||
|
||||
const metadataColumns = getMetadataColumnsData(t);
|
||||
|
||||
const verifiedEmailColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "verifiedEmail",
|
||||
header: () => (
|
||||
@@ -397,8 +407,8 @@ export const generateResponseTableColumns = (
|
||||
...questionColumns,
|
||||
...variableColumns,
|
||||
...hiddenFieldColumns,
|
||||
...metadataColumns,
|
||||
tagsColumn,
|
||||
notesColumn,
|
||||
];
|
||||
|
||||
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
MousePointerClickIcon,
|
||||
SmartphoneIcon,
|
||||
} from "lucide-react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
COLUMNS_ICON_MAP,
|
||||
getAddressFieldLabel,
|
||||
getContactInfoFieldLabel,
|
||||
getMetadataFieldLabel,
|
||||
getMetadataValue,
|
||||
} from "./utils";
|
||||
|
||||
describe("utils", () => {
|
||||
const mockT = vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"environments.surveys.responses.address_line_1": "Address Line 1",
|
||||
"environments.surveys.responses.address_line_2": "Address Line 2",
|
||||
"environments.surveys.responses.city": "City",
|
||||
"environments.surveys.responses.state_region": "State/Region",
|
||||
"environments.surveys.responses.zip_post_code": "ZIP/Post Code",
|
||||
"environments.surveys.responses.country": "Country",
|
||||
"environments.surveys.responses.first_name": "First Name",
|
||||
"environments.surveys.responses.last_name": "Last Name",
|
||||
"environments.surveys.responses.email": "Email",
|
||||
"environments.surveys.responses.phone": "Phone",
|
||||
"environments.surveys.responses.company": "Company",
|
||||
"common.action": "Action",
|
||||
"environments.surveys.responses.os": "OS",
|
||||
"environments.surveys.responses.device": "Device",
|
||||
"environments.surveys.responses.browser": "Browser",
|
||||
"common.url": "URL",
|
||||
"environments.surveys.responses.source": "Source",
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
|
||||
describe("getAddressFieldLabel", () => {
|
||||
test("returns correct label for addressLine1", () => {
|
||||
const result = getAddressFieldLabel("addressLine1", mockT);
|
||||
expect(result).toBe("Address Line 1");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_1");
|
||||
});
|
||||
|
||||
test("returns correct label for addressLine2", () => {
|
||||
const result = getAddressFieldLabel("addressLine2", mockT);
|
||||
expect(result).toBe("Address Line 2");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_2");
|
||||
});
|
||||
|
||||
test("returns correct label for city", () => {
|
||||
const result = getAddressFieldLabel("city", mockT);
|
||||
expect(result).toBe("City");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.city");
|
||||
});
|
||||
|
||||
test("returns correct label for state", () => {
|
||||
const result = getAddressFieldLabel("state", mockT);
|
||||
expect(result).toBe("State/Region");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.state_region");
|
||||
});
|
||||
|
||||
test("returns correct label for zip", () => {
|
||||
const result = getAddressFieldLabel("zip", mockT);
|
||||
expect(result).toBe("ZIP/Post Code");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.zip_post_code");
|
||||
});
|
||||
|
||||
test("returns correct label for country", () => {
|
||||
const result = getAddressFieldLabel("country", mockT);
|
||||
expect(result).toBe("Country");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
|
||||
});
|
||||
|
||||
test("returns undefined for unknown field", () => {
|
||||
const result = getAddressFieldLabel("unknown", mockT);
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockT).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContactInfoFieldLabel", () => {
|
||||
test("returns correct label for firstName", () => {
|
||||
const result = getContactInfoFieldLabel("firstName", mockT);
|
||||
expect(result).toBe("First Name");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.first_name");
|
||||
});
|
||||
|
||||
test("returns correct label for lastName", () => {
|
||||
const result = getContactInfoFieldLabel("lastName", mockT);
|
||||
expect(result).toBe("Last Name");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.last_name");
|
||||
});
|
||||
|
||||
test("returns correct label for email", () => {
|
||||
const result = getContactInfoFieldLabel("email", mockT);
|
||||
expect(result).toBe("Email");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.email");
|
||||
});
|
||||
|
||||
test("returns correct label for phone", () => {
|
||||
const result = getContactInfoFieldLabel("phone", mockT);
|
||||
expect(result).toBe("Phone");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.phone");
|
||||
});
|
||||
|
||||
test("returns correct label for company", () => {
|
||||
const result = getContactInfoFieldLabel("company", mockT);
|
||||
expect(result).toBe("Company");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.company");
|
||||
});
|
||||
|
||||
test("returns undefined for unknown field", () => {
|
||||
const result = getContactInfoFieldLabel("unknown", mockT);
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockT).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMetadataFieldLabel", () => {
|
||||
test("returns correct label for action", () => {
|
||||
const result = getMetadataFieldLabel("action", mockT);
|
||||
expect(result).toBe("Action");
|
||||
expect(mockT).toHaveBeenCalledWith("common.action");
|
||||
});
|
||||
|
||||
test("returns correct label for country", () => {
|
||||
const result = getMetadataFieldLabel("country", mockT);
|
||||
expect(result).toBe("Country");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
|
||||
});
|
||||
|
||||
test("returns correct label for os", () => {
|
||||
const result = getMetadataFieldLabel("os", mockT);
|
||||
expect(result).toBe("OS");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.os");
|
||||
});
|
||||
|
||||
test("returns correct label for device", () => {
|
||||
const result = getMetadataFieldLabel("device", mockT);
|
||||
expect(result).toBe("Device");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.device");
|
||||
});
|
||||
|
||||
test("returns correct label for browser", () => {
|
||||
const result = getMetadataFieldLabel("browser", mockT);
|
||||
expect(result).toBe("Browser");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.browser");
|
||||
});
|
||||
|
||||
test("returns correct label for url", () => {
|
||||
const result = getMetadataFieldLabel("url", mockT);
|
||||
expect(result).toBe("URL");
|
||||
expect(mockT).toHaveBeenCalledWith("common.url");
|
||||
});
|
||||
|
||||
test("returns correct label for source", () => {
|
||||
const result = getMetadataFieldLabel("source", mockT);
|
||||
expect(result).toBe("Source");
|
||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.source");
|
||||
});
|
||||
|
||||
test("returns capitalized label for unknown field", () => {
|
||||
const result = getMetadataFieldLabel("customField", mockT);
|
||||
expect(result).toBe("Customfield");
|
||||
expect(mockT).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns capitalized label for field with underscores", () => {
|
||||
const result = getMetadataFieldLabel("custom_field", mockT);
|
||||
expect(result).toBe("Custom_field");
|
||||
expect(mockT).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("COLUMNS_ICON_MAP", () => {
|
||||
test("contains correct icon mappings", () => {
|
||||
expect(COLUMNS_ICON_MAP.action).toBe(MousePointerClickIcon);
|
||||
expect(COLUMNS_ICON_MAP.country).toBe(FlagIcon);
|
||||
expect(COLUMNS_ICON_MAP.browser).toBe(GlobeIcon);
|
||||
expect(COLUMNS_ICON_MAP.os).toBe(AirplayIcon);
|
||||
expect(COLUMNS_ICON_MAP.device).toBe(SmartphoneIcon);
|
||||
expect(COLUMNS_ICON_MAP.source).toBe(ArrowUpFromDotIcon);
|
||||
expect(COLUMNS_ICON_MAP.url).toBe(GlobeIcon);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMetadataValue", () => {
|
||||
test("returns correct value for action", () => {
|
||||
const result = getMetadataValue({ action: "action_column" }, "action");
|
||||
expect(result).toBe("action_column");
|
||||
});
|
||||
|
||||
test("returns correct value for userAgent", () => {
|
||||
const result = getMetadataValue({ userAgent: { browser: "browser_column" } }, "browser");
|
||||
expect(result).toBe("browser_column");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { capitalize } from "lodash";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
MousePointerClickIcon,
|
||||
SmartphoneIcon,
|
||||
} from "lucide-react";
|
||||
import { TResponseMeta } from "@formbricks/types/responses";
|
||||
|
||||
export const getAddressFieldLabel = (field: string, t: TFnType) => {
|
||||
switch (field) {
|
||||
case "addressLine1":
|
||||
return t("environments.surveys.responses.address_line_1");
|
||||
case "addressLine2":
|
||||
return t("environments.surveys.responses.address_line_2");
|
||||
case "city":
|
||||
return t("environments.surveys.responses.city");
|
||||
case "state":
|
||||
return t("environments.surveys.responses.state_region");
|
||||
case "zip":
|
||||
return t("environments.surveys.responses.zip_post_code");
|
||||
case "country":
|
||||
return t("environments.surveys.responses.country");
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export const getContactInfoFieldLabel = (field: string, t: TFnType) => {
|
||||
switch (field) {
|
||||
case "firstName":
|
||||
return t("environments.surveys.responses.first_name");
|
||||
case "lastName":
|
||||
return t("environments.surveys.responses.last_name");
|
||||
case "email":
|
||||
return t("environments.surveys.responses.email");
|
||||
case "phone":
|
||||
return t("environments.surveys.responses.phone");
|
||||
case "company":
|
||||
return t("environments.surveys.responses.company");
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export const getMetadataFieldLabel = (label: string, t: TFnType) => {
|
||||
switch (label) {
|
||||
case "action":
|
||||
return t("common.action");
|
||||
case "country":
|
||||
return t("environments.surveys.responses.country");
|
||||
case "os":
|
||||
return t("environments.surveys.responses.os");
|
||||
case "device":
|
||||
return t("environments.surveys.responses.device");
|
||||
case "browser":
|
||||
return t("environments.surveys.responses.browser");
|
||||
case "url":
|
||||
return t("common.url");
|
||||
case "source":
|
||||
return t("environments.surveys.responses.source");
|
||||
default:
|
||||
return capitalize(label);
|
||||
}
|
||||
};
|
||||
|
||||
export const COLUMNS_ICON_MAP = {
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
browser: GlobeIcon,
|
||||
os: AirplayIcon,
|
||||
device: SmartphoneIcon,
|
||||
source: ArrowUpFromDotIcon,
|
||||
url: GlobeIcon,
|
||||
};
|
||||
|
||||
const userAgentFields = ["browser", "os", "device"];
|
||||
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
|
||||
|
||||
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
|
||||
if (userAgentFields.includes(label)) {
|
||||
return meta.userAgent?.[label];
|
||||
}
|
||||
return meta[label];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -291,6 +291,7 @@ const mockUser: TUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "https://example.com/avatar.jpg",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -172,15 +172,6 @@ vi.mock("./shareEmbedModal/success-view", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock LinkSettingsTab
|
||||
vi.mock("./shareEmbedModal/link-settings-tab", () => ({
|
||||
LinkSettingsTab: ({ survey, isReadOnly, locale }: any) => (
|
||||
<div data-testid="link-settings-tab">
|
||||
LinkSettings for {survey.id} - ReadOnly: {isReadOnly.toString()} - Locale: {locale}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any;
|
||||
@@ -193,7 +184,6 @@ vi.mock("lucide-react", async (importOriginal) => {
|
||||
SmartphoneIcon: () => <svg data-testid="smartphone-icon" />,
|
||||
SquareStack: () => <svg data-testid="square-stack-icon" />,
|
||||
UserIcon: () => <svg data-testid="user-icon" />,
|
||||
Settings: () => <svg data-testid="settings-icon" />,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -237,7 +227,6 @@ const mockSurvey: TSurvey = {
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const mockAppSurvey: TSurvey = {
|
||||
@@ -250,6 +239,7 @@ const mockUser: TUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "https://example.com/avatar.jpg",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
@@ -280,7 +270,6 @@ const defaultProps = {
|
||||
segments: mockSegments,
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
describe("ShareSurveyModal", () => {
|
||||
@@ -403,6 +392,7 @@ describe("ShareSurveyModal", () => {
|
||||
});
|
||||
|
||||
test("resets to start view when modal is closed and reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||
|
||||
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||
@@ -478,40 +468,4 @@ describe("ShareSurveyModal", () => {
|
||||
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("includes link settings tab in share tabs for link surveys", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||
|
||||
const shareView = screen.getByTestId("share-view");
|
||||
expect(shareView).toBeInTheDocument();
|
||||
|
||||
// Check that tabs are rendered and include link settings
|
||||
const tabsContainer = screen.getByTestId("tabs");
|
||||
expect(tabsContainer).toBeInTheDocument();
|
||||
|
||||
// Look for the link-settings tab button
|
||||
expect(screen.getByTestId("tab-link-settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not include link settings for app surveys", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
|
||||
|
||||
// App surveys should render TabContainer, not ShareView with link settings
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tab-link-settings")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles locale prop correctly for link settings", () => {
|
||||
const customProps = {
|
||||
...defaultProps,
|
||||
modalView: "share" as const,
|
||||
};
|
||||
|
||||
render(<ShareSurveyModal {...customProps} />);
|
||||
|
||||
// Verify that ShareView is rendered (which would contain LinkSettingsTab)
|
||||
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,32 +4,18 @@ import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surv
|
||||
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
|
||||
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
||||
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
||||
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
|
||||
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
|
||||
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
|
||||
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||
import { WebsiteEmbedTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab";
|
||||
import {
|
||||
LinkTabsType,
|
||||
ShareSettingsType,
|
||||
ShareViaType,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { getSurveyUrl } from "@/modules/analysis/utils";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
Code2Icon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
QrCodeIcon,
|
||||
Settings,
|
||||
Share2Icon,
|
||||
SquareStack,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
@@ -69,20 +55,17 @@ export const ShareSurveyModal = ({
|
||||
const { email } = user;
|
||||
const { t } = useTranslate();
|
||||
const linkTabs: {
|
||||
id: ShareViaType | ShareSettingsType;
|
||||
type: LinkTabsType;
|
||||
id: ShareViewType;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
componentType: React.ComponentType<unknown>;
|
||||
componentProps: unknown;
|
||||
disabled?: boolean;
|
||||
componentType: React.ComponentType<any>;
|
||||
componentProps: any;
|
||||
}[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: ShareViaType.ANON_LINKS,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.ANON_LINKS,
|
||||
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||
icon: LinkIcon,
|
||||
title: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||
@@ -98,8 +81,7 @@ export const ShareSurveyModal = ({
|
||||
},
|
||||
},
|
||||
{
|
||||
id: ShareViaType.PERSONAL_LINKS,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.PERSONAL_LINKS,
|
||||
label: t("environments.surveys.share.personal_links.nav_title"),
|
||||
icon: UserIcon,
|
||||
title: t("environments.surveys.share.personal_links.nav_title"),
|
||||
@@ -112,55 +94,45 @@ export const ShareSurveyModal = ({
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
},
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.WEBSITE_EMBED,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.WEBSITE_EMBED,
|
||||
label: t("environments.surveys.share.embed_on_website.nav_title"),
|
||||
icon: Code2Icon,
|
||||
title: t("environments.surveys.share.embed_on_website.nav_title"),
|
||||
description: t("environments.surveys.share.embed_on_website.description"),
|
||||
componentType: WebsiteEmbedTab,
|
||||
componentProps: { surveyUrl },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.EMAIL,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.EMAIL,
|
||||
label: t("environments.surveys.share.send_email.nav_title"),
|
||||
icon: MailIcon,
|
||||
title: t("environments.surveys.share.send_email.nav_title"),
|
||||
description: t("environments.surveys.share.send_email.description"),
|
||||
componentType: EmailTab,
|
||||
componentProps: { surveyId: survey.id, email },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.SOCIAL_MEDIA,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.SOCIAL_MEDIA,
|
||||
label: t("environments.surveys.share.social_media.title"),
|
||||
icon: Share2Icon,
|
||||
title: t("environments.surveys.share.social_media.title"),
|
||||
description: t("environments.surveys.share.social_media.description"),
|
||||
componentType: SocialMediaTab,
|
||||
componentProps: { surveyUrl, surveyTitle: survey.name },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.QR_CODE,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.QR_CODE,
|
||||
label: t("environments.surveys.summary.qr_code"),
|
||||
icon: QrCodeIcon,
|
||||
title: t("environments.surveys.summary.qr_code"),
|
||||
description: t("environments.surveys.summary.qr_code_description"),
|
||||
componentType: QRCodeTab,
|
||||
componentProps: { surveyUrl },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.DYNAMIC_POPUP,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.DYNAMIC_POPUP,
|
||||
label: t("environments.surveys.share.dynamic_popup.nav_title"),
|
||||
icon: SquareStack,
|
||||
title: t("environments.surveys.share.dynamic_popup.nav_title"),
|
||||
@@ -168,24 +140,14 @@ export const ShareSurveyModal = ({
|
||||
componentType: DynamicPopupTab,
|
||||
componentProps: { environmentId, surveyId: survey.id },
|
||||
},
|
||||
{
|
||||
id: ShareSettingsType.LINK_SETTINGS,
|
||||
type: LinkTabsType.SHARE_SETTING,
|
||||
label: t("environments.surveys.share.link_settings.title"),
|
||||
icon: Settings,
|
||||
title: t("environments.surveys.share.link_settings.title"),
|
||||
description: t("environments.surveys.share.link_settings.description"),
|
||||
componentType: LinkSettingsTab,
|
||||
componentProps: { isReadOnly, locale: user.locale },
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
survey,
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
user.locale,
|
||||
surveyUrl,
|
||||
isReadOnly,
|
||||
environmentId,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
@@ -194,15 +156,9 @@ export const ShareSurveyModal = ({
|
||||
]
|
||||
);
|
||||
|
||||
const getDefaultActiveId = useCallback(() => {
|
||||
if (survey.type !== "link") {
|
||||
return ShareViaType.APP;
|
||||
}
|
||||
|
||||
return ShareViaType.ANON_LINKS;
|
||||
}, [survey.type]);
|
||||
|
||||
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(getDefaultActiveId());
|
||||
const [activeId, setActiveId] = useState(
|
||||
survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -210,19 +166,11 @@ export const ShareSurveyModal = ({
|
||||
}
|
||||
}, [open, modalView]);
|
||||
|
||||
// Ensure active tab is not disabled - if it is, switch to default
|
||||
useEffect(() => {
|
||||
const activeTab = linkTabs.find((tab) => tab.id === activeId);
|
||||
if (activeTab?.disabled) {
|
||||
setActiveId(getDefaultActiveId());
|
||||
}
|
||||
}, [activeId, linkTabs, getDefaultActiveId]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setShowView("start");
|
||||
setActiveId(getDefaultActiveId());
|
||||
setActiveId(ShareViewType.ANON_LINKS);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -230,7 +178,7 @@ export const ShareSurveyModal = ({
|
||||
setShowView(view);
|
||||
};
|
||||
|
||||
const handleEmbedViewWithTab = (tabId: ShareViaType | ShareSettingsType) => {
|
||||
const handleEmbedViewWithTab = (tabId: ShareViewType) => {
|
||||
setShowView("share");
|
||||
setActiveId(tabId);
|
||||
};
|
||||
|
||||
@@ -1,428 +0,0 @@
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { LinkSettingsTab } from "./link-settings-tab";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
createI18nString: vi.fn(),
|
||||
extractLanguageCodes: vi.fn(),
|
||||
getEnabledLanguages: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/editor/actions", () => ({
|
||||
updateSurveyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/i18n-utils/src/utils", () => ({
|
||||
getLanguageLabel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context", () => ({
|
||||
useSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/file-input", () => ({
|
||||
FileInput: vi.fn(({ onFileUpload, fileUrl, disabled, id }) => (
|
||||
<div data-testid={id}>
|
||||
<input
|
||||
data-testid="file-input"
|
||||
type="text"
|
||||
value={fileUrl || ""}
|
||||
onChange={(e) => onFileUpload(e.target.value ? [e.target.value] : undefined)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: vi.fn(({ children, disabled, type, ...props }) => (
|
||||
<button data-testid="save-button" disabled={disabled} type={type} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)),
|
||||
}));
|
||||
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "test-survey-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
environmentId: "test-env-id",
|
||||
status: "inProgress",
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
|
||||
questions: [],
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false },
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
languages: [
|
||||
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
|
||||
{ language: { id: "lang2", code: "en" }, default: false, enabled: true } as TSurveyLanguage,
|
||||
],
|
||||
showLanguageSwitch: false,
|
||||
singleUse: { enabled: false, isEncrypted: false },
|
||||
projectOverwrites: null,
|
||||
surveyClosedMessage: null,
|
||||
delay: 0,
|
||||
isVerifyEmailEnabled: false,
|
||||
createdBy: null,
|
||||
variables: [],
|
||||
followUps: [],
|
||||
runOnDate: null,
|
||||
closeOnDate: null,
|
||||
styling: null,
|
||||
pin: null,
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
metadata: {
|
||||
title: { default: "Test Title", en: "Test Title EN" },
|
||||
description: { default: "Test Description", en: "Test Description EN" },
|
||||
ogImage: "https://example.com/image.png",
|
||||
},
|
||||
};
|
||||
|
||||
const mockSingleLanguageSurvey: TSurvey = {
|
||||
...mockSurvey,
|
||||
languages: [
|
||||
{ language: { id: "lang1", code: "default" }, default: true, enabled: true },
|
||||
] as TSurveyLanguage[],
|
||||
};
|
||||
|
||||
describe("LinkSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(useSurvey).mockReturnValue({
|
||||
survey: mockSurvey,
|
||||
});
|
||||
|
||||
vi.mocked(getEnabledLanguages).mockReturnValue([
|
||||
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
|
||||
{ language: { id: "lang2", code: "en" }, default: false, enabled: true } as TSurveyLanguage,
|
||||
]);
|
||||
|
||||
vi.mocked(extractLanguageCodes).mockReturnValue(["default", "en"]);
|
||||
|
||||
vi.mocked(createI18nString).mockImplementation((text, languages) => {
|
||||
const result = {};
|
||||
languages.forEach((lang) => {
|
||||
result[lang] = typeof text === "string" ? text : "";
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
vi.mocked(getLanguageLabel).mockImplementation((code) => {
|
||||
const labels = {
|
||||
default: "Default",
|
||||
en: "English",
|
||||
};
|
||||
return labels[code] || code;
|
||||
});
|
||||
|
||||
vi.mocked(updateSurveyAction).mockResolvedValue({ data: mockSurvey });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders form fields correctly", () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
expect(screen.getByText("common.language")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.share.link_settings.link_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.share.link_settings.link_description")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.share.link_settings.preview_image")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("save-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("initializes form with existing metadata", () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
const descriptionInput = screen.getByDisplayValue("Test Description");
|
||||
|
||||
expect(titleInput).toBeInTheDocument();
|
||||
expect(descriptionInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("initializes form with empty values when metadata is undefined", () => {
|
||||
const mockSurveyWithoutMetadata: TSurvey = {
|
||||
...mockSurvey,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
vi.mocked(useSurvey).mockReturnValue({
|
||||
survey: mockSurveyWithoutMetadata,
|
||||
});
|
||||
|
||||
vi.mocked(createI18nString).mockReturnValue({ default: "", en: "" });
|
||||
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
expect(vi.mocked(createI18nString)).toHaveBeenCalledWith("", ["default", "en"]);
|
||||
});
|
||||
|
||||
test("does not show language selector for single language surveys", () => {
|
||||
vi.mocked(useSurvey).mockReturnValue({
|
||||
survey: mockSingleLanguageSurvey,
|
||||
});
|
||||
|
||||
vi.mocked(getEnabledLanguages).mockReturnValue([
|
||||
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
|
||||
]);
|
||||
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
expect(screen.queryByText("common.language")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows language selector for multi-language surveys", () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
expect(screen.getByText("common.language")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles language change correctly", async () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
// Since the Select component is complex to test in JSDOM, let's test that
|
||||
// the language selector is rendered and has the expected options
|
||||
const languageSelect = screen.getByRole("combobox");
|
||||
expect(languageSelect).toBeInTheDocument();
|
||||
|
||||
// Check that the language options are available in the hidden select
|
||||
const hiddenSelect = screen.getByDisplayValue("Default");
|
||||
expect(hiddenSelect).toBeInTheDocument();
|
||||
|
||||
// Check for English option in the select
|
||||
const englishOption = screen
|
||||
.getByDisplayValue("Default")
|
||||
.closest("select")
|
||||
?.querySelector('option[value="en"]');
|
||||
expect(englishOption).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles title input change", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "New Title");
|
||||
|
||||
expect(titleInput).toHaveValue("New Title");
|
||||
});
|
||||
|
||||
test("handles description input change", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const descriptionInput = screen.getByDisplayValue("Test Description");
|
||||
await user.clear(descriptionInput);
|
||||
await user.type(descriptionInput, "New Description");
|
||||
|
||||
expect(descriptionInput).toHaveValue("New Description");
|
||||
});
|
||||
|
||||
test("handles file upload", async () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const fileInput = screen.getByTestId("file-input");
|
||||
fireEvent.change(fileInput, { target: { value: "https://example.com/new-image.png" } });
|
||||
|
||||
expect(fileInput).toHaveValue("https://example.com/new-image.png");
|
||||
});
|
||||
|
||||
test("handles file removal", async () => {
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const fileInput = screen.getByTestId("file-input");
|
||||
fireEvent.change(fileInput, { target: { value: "" } });
|
||||
|
||||
expect(fileInput).toHaveValue("");
|
||||
});
|
||||
|
||||
test("disables form when isReadOnly is true", () => {
|
||||
render(<LinkSettingsTab isReadOnly={true} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
const descriptionInput = screen.getByDisplayValue("Test Description");
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
|
||||
expect(titleInput).toBeDisabled();
|
||||
expect(descriptionInput).toBeDisabled();
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("submits form successfully", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "Updated Title");
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
|
||||
expect(callArgs.metadata.title?.default).toBe("Updated Title");
|
||||
});
|
||||
|
||||
test("handles submission error", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(updateSurveyAction).mockResolvedValue({ data: mockSurvey });
|
||||
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "Updated Title");
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not submit when form is saving", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolvePromise: (value: any) => void;
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
vi.mocked(updateSurveyAction).mockReturnValue(pendingPromise as any);
|
||||
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
// Make form dirty first
|
||||
const titleInput = screen.getByDisplayValue("Test Title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "Modified Title");
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
|
||||
// First click should trigger submission
|
||||
await user.click(saveButton);
|
||||
|
||||
// Wait a bit to ensure the first submission started
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Second click should be prevented because form is saving
|
||||
await user.click(saveButton);
|
||||
|
||||
// Should only be called once
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clean up by resolving the promise
|
||||
resolvePromise!({ data: mockSurvey });
|
||||
});
|
||||
|
||||
test("does not submit when isReadOnly is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={true} locale="en-US" />);
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(vi.mocked(updateSurveyAction)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles ogImage correctly in form submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const fileInput = screen.getByTestId("file-input");
|
||||
fireEvent.change(fileInput, { target: { value: "https://example.com/new-image.png" } });
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
|
||||
expect(callArgs.metadata.ogImage).toBe("https://example.com/new-image.png");
|
||||
});
|
||||
|
||||
test("handles empty ogImage correctly in form submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const fileInput = screen.getByTestId("file-input");
|
||||
fireEvent.change(fileInput, { target: { value: "" } });
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
|
||||
expect(callArgs.metadata.ogImage).toBeUndefined();
|
||||
});
|
||||
|
||||
test("merges form data with existing metadata correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const surveyWithPartialMetadata: TSurvey = {
|
||||
...mockSurvey,
|
||||
metadata: {
|
||||
title: { default: "Existing Title" },
|
||||
description: { default: "Existing Description" },
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(useSurvey).mockReturnValue({
|
||||
survey: surveyWithPartialMetadata,
|
||||
});
|
||||
|
||||
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
|
||||
|
||||
const titleInput = screen.getByDisplayValue("Existing Title");
|
||||
await user.clear(titleInput);
|
||||
await user.type(titleInput, "New Title");
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(updateSurveyAction)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(updateSurveyAction).mock.calls[0][0];
|
||||
expect(callArgs.metadata.title?.default).toBe("New Title");
|
||||
expect(callArgs.metadata.description?.default).toBe("Existing Description");
|
||||
});
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TI18nString, TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface LinkSettingsTabProps {
|
||||
isReadOnly: boolean;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
interface LinkSettingsFormData {
|
||||
title: TI18nString;
|
||||
description: TI18nString;
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
export const LinkSettingsTab = ({ isReadOnly, locale }: LinkSettingsTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
const { survey } = useSurvey();
|
||||
const enabledLanguages = getEnabledLanguages(survey.languages);
|
||||
const hasMultipleLanguages = enabledLanguages.length > 1;
|
||||
|
||||
// Set default language - use 'default' if no multi-language is set up
|
||||
const defaultLanguageCode = hasMultipleLanguages
|
||||
? survey.languages.find((lang) => lang.default)?.language.code || "default"
|
||||
: "default";
|
||||
|
||||
const languageCodes = useMemo(() => extractLanguageCodes(survey.languages), [survey.languages]);
|
||||
|
||||
const [selectedLanguageCode, setSelectedLanguageCode] = useState(defaultLanguageCode);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Current language code for metadata storage
|
||||
const currentLangCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
|
||||
|
||||
// Initialize form with current values - memoize to prevent re-initialization
|
||||
const initialFormData = useMemo(() => {
|
||||
const metadata = survey.metadata || {
|
||||
title: createI18nString("", languageCodes),
|
||||
description: createI18nString("", languageCodes),
|
||||
ogImage: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
title: metadata.title || createI18nString("", languageCodes),
|
||||
description: metadata.description || createI18nString("", languageCodes),
|
||||
ogImage: metadata.ogImage || "",
|
||||
};
|
||||
}, [survey.metadata, languageCodes]);
|
||||
|
||||
const form = useForm<LinkSettingsFormData>({
|
||||
defaultValues: initialFormData,
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { isDirty },
|
||||
} = form;
|
||||
|
||||
const handleFileUpload = (urls: string[] | undefined) => {
|
||||
if (urls && urls.length > 0) {
|
||||
setValue("ogImage", urls[0], { shouldDirty: true });
|
||||
} else {
|
||||
// Handle removal of the image
|
||||
setValue("ogImage", "", { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: LinkSettingsFormData) => {
|
||||
if (isSaving || isReadOnly) return;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
// Get current linkMetadata
|
||||
const currentSurveyMetadata = survey.metadata || {
|
||||
title: createI18nString("", languageCodes),
|
||||
description: createI18nString("", languageCodes),
|
||||
ogImage: undefined,
|
||||
};
|
||||
|
||||
// Merge form data with existing linkMetadata
|
||||
const updatedTitle: TI18nString = {
|
||||
...currentSurveyMetadata.title,
|
||||
...data.title,
|
||||
};
|
||||
|
||||
const updatedDescription: TI18nString = {
|
||||
...currentSurveyMetadata.description,
|
||||
...data.description,
|
||||
};
|
||||
|
||||
const updatedSurveyMetadata: TSurveyMetadata = {
|
||||
title: updatedTitle,
|
||||
description: updatedDescription,
|
||||
ogImage: data.ogImage || undefined,
|
||||
};
|
||||
|
||||
const updatedSurvey: TSurvey = {
|
||||
...survey,
|
||||
metadata: updatedSurveyMetadata,
|
||||
};
|
||||
|
||||
const result = await updateSurveyAction(updatedSurvey);
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.surveys.edit.settings_saved_successfully"));
|
||||
reset(data);
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const inputFields: {
|
||||
name: "title" | "description";
|
||||
label: string;
|
||||
description: string;
|
||||
placeholder: string;
|
||||
}[] = [
|
||||
{
|
||||
name: "title",
|
||||
label: t("environments.surveys.share.link_settings.link_title"),
|
||||
description: t("environments.surveys.share.link_settings.link_title_description"),
|
||||
placeholder: t("environments.surveys.share.link_settings.link_title_placeholder"),
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: t("environments.surveys.share.link_settings.link_description"),
|
||||
description: t("environments.surveys.share.link_settings.link_description_description"),
|
||||
placeholder: t("environments.surveys.share.link_settings.link_description_placeholder"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="px-1">
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Language Selection - only show if survey has multiple languages */}
|
||||
{hasMultipleLanguages && (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={selectedLanguageCode} onValueChange={setSelectedLanguageCode}>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledLanguages.map((lang) => (
|
||||
<SelectItem key={lang.language.code} value={lang.language.code} className="bg-white">
|
||||
{getLanguageLabel(lang.language.code, locale)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("environments.surveys.share.link_settings.language_help_text")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
{inputFields.map((inputField) => {
|
||||
return (
|
||||
<FormField
|
||||
key={inputField.name}
|
||||
control={form.control}
|
||||
name={inputField.name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{inputField.label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value[currentLangCode] || ""}
|
||||
className="bg-white"
|
||||
onChange={(e) => {
|
||||
const updatedValue = {
|
||||
...field.value,
|
||||
[currentLangCode]: e.target.value,
|
||||
};
|
||||
field.onChange(updatedValue);
|
||||
}}
|
||||
placeholder={inputField.placeholder}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{inputField.description}</FormDescription>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Preview Image */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ogImage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("environments.surveys.share.link_settings.preview_image")}</FormLabel>
|
||||
<FormControl>
|
||||
<FileInput
|
||||
id={`og-image-upload-${survey.id}`}
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
|
||||
environmentId={survey.environmentId}
|
||||
onFileUpload={handleFileUpload}
|
||||
fileUrl={field.value}
|
||||
maxSizeInMB={5}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("environments.surveys.share.link_settings.preview_image_description")}
|
||||
</FormDescription>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Save Button */}
|
||||
<Button type="submit" disabled={isSaving || isReadOnly || !isDirty}>
|
||||
{isSaving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { LinkTabsType, ShareSettingsType, ShareViaType } from "../../types/share";
|
||||
import { ShareViewType } from "../../types/share";
|
||||
import { ShareView } from "./share-view";
|
||||
|
||||
// Mock sidebar components
|
||||
@@ -161,7 +161,6 @@ vi.mock("lucide-react", () => ({
|
||||
Share2Icon: () => <div data-testid="share2-icon">Share2Icon</div>,
|
||||
SquareStack: () => <div data-testid="square-stack-icon">SquareStack</div>,
|
||||
UserIcon: () => <div data-testid="user-icon">UserIcon</div>,
|
||||
Settings: () => <div data-testid="settings-icon">Settings</div>,
|
||||
}));
|
||||
|
||||
// Mock tooltip and typography components
|
||||
@@ -265,25 +264,9 @@ const mockSurvey = {
|
||||
styling: null,
|
||||
} as any;
|
||||
|
||||
// Mock LinkSettingsTab
|
||||
const MockLinkSettingsTab = ({
|
||||
survey,
|
||||
isReadOnly,
|
||||
locale,
|
||||
}: {
|
||||
survey: any;
|
||||
isReadOnly: boolean;
|
||||
locale: string;
|
||||
}) => (
|
||||
<div data-testid="link-settings-tab">
|
||||
LinkSettingsTab Content for {survey.id} - ReadOnly: {isReadOnly.toString()} - Locale: {locale}
|
||||
</div>
|
||||
);
|
||||
|
||||
const mockTabs = [
|
||||
{
|
||||
id: ShareViaType.EMAIL,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.EMAIL,
|
||||
label: "Email",
|
||||
icon: () => <div data-testid="email-tab-icon" />,
|
||||
componentType: MockEmailTab,
|
||||
@@ -292,8 +275,7 @@ const mockTabs = [
|
||||
description: "Send survey via email",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.WEBSITE_EMBED,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.WEBSITE_EMBED,
|
||||
label: "Website Embed",
|
||||
icon: () => <div data-testid="website-embed-tab-icon" />,
|
||||
componentType: MockWebsiteEmbedTab,
|
||||
@@ -302,8 +284,7 @@ const mockTabs = [
|
||||
description: "Embed survey on your website",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.DYNAMIC_POPUP,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.DYNAMIC_POPUP,
|
||||
label: "Dynamic Popup",
|
||||
icon: () => <div data-testid="dynamic-popup-tab-icon" />,
|
||||
componentType: MockDynamicPopupTab,
|
||||
@@ -312,8 +293,7 @@ const mockTabs = [
|
||||
description: "Show survey as popup",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.ANON_LINKS,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.ANON_LINKS,
|
||||
label: "Anonymous Links",
|
||||
icon: () => <div data-testid="anonymous-links-tab-icon" />,
|
||||
componentType: MockAnonymousLinksTab,
|
||||
@@ -328,8 +308,7 @@ const mockTabs = [
|
||||
description: "Share anonymous links",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.QR_CODE,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.QR_CODE,
|
||||
label: "QR Code",
|
||||
icon: () => <div data-testid="qr-code-tab-icon" />,
|
||||
componentType: MockQRCodeTab,
|
||||
@@ -338,8 +317,7 @@ const mockTabs = [
|
||||
description: "Generate QR code",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.PERSONAL_LINKS,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.PERSONAL_LINKS,
|
||||
label: "Personal Links",
|
||||
icon: () => <div data-testid="personal-links-tab-icon" />,
|
||||
componentType: MockPersonalLinksTab,
|
||||
@@ -348,8 +326,7 @@ const mockTabs = [
|
||||
description: "Create personal links",
|
||||
},
|
||||
{
|
||||
id: ShareViaType.SOCIAL_MEDIA,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
id: ShareViewType.SOCIAL_MEDIA,
|
||||
label: "Social Media",
|
||||
icon: () => <div data-testid="social-media-tab-icon" />,
|
||||
componentType: MockSocialMediaTab,
|
||||
@@ -357,21 +334,11 @@ const mockTabs = [
|
||||
title: "Social Media",
|
||||
description: "Share on social media",
|
||||
},
|
||||
{
|
||||
id: ShareSettingsType.LINK_SETTINGS,
|
||||
type: LinkTabsType.SHARE_SETTING,
|
||||
label: "Link Settings",
|
||||
icon: () => <div data-testid="link-settings-tab-icon" />,
|
||||
componentType: MockLinkSettingsTab,
|
||||
componentProps: { survey: mockSurvey, isReadOnly: false, locale: "en-US" },
|
||||
title: "Link Settings",
|
||||
description: "Configure link settings",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
tabs: mockTabs,
|
||||
activeId: ShareViaType.EMAIL,
|
||||
activeId: ShareViewType.EMAIL,
|
||||
setActiveId: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -403,51 +370,43 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("renders desktop tabs", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
// Desktop sidebar should be rendered
|
||||
const sidebarLabel = screen.getByText("environments.surveys.share.share_view_title");
|
||||
expect(sidebarLabel).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders share settings section", () => {
|
||||
render(<ShareView {...defaultProps} />);
|
||||
|
||||
// Share settings section should be rendered
|
||||
const shareSettingsLabel = screen.getByText("environments.surveys.share.share_settings_title");
|
||||
expect(shareSettingsLabel).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setActiveId when a tab is clicked (desktop)", async () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
const websiteEmbedTabButton = screen.getByLabelText("Website Embed");
|
||||
await userEvent.click(websiteEmbedTabButton);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViaType.WEBSITE_EMBED);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
|
||||
});
|
||||
|
||||
test("renders EmailTab when activeId is EMAIL", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("EmailTab Content for survey1 with test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders WebsiteEmbedTab when activeId is WEBSITE_EMBED", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.WEBSITE_EMBED} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.WEBSITE_EMBED} />);
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("website-embed-tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("WebsiteEmbedTab Content for http://example.com/survey1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DynamicPopupTab when activeId is DYNAMIC_POPUP", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.DYNAMIC_POPUP} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.DYNAMIC_POPUP} />);
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dynamic-popup-tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("DynamicPopupTab Content for survey1 in env1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders AnonymousLinksTab when activeId is ANON_LINKS", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.ANON_LINKS} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.ANON_LINKS} />);
|
||||
expect(screen.getByTestId("anonymous-links-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("AnonymousLinksTab Content for survey1 at http://example.com/survey1")
|
||||
@@ -455,24 +414,16 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("renders QRCodeTab when activeId is QR_CODE", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.QR_CODE} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.QR_CODE} />);
|
||||
expect(screen.getByTestId("qr-code-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LinkSettingsTab when activeId is LINK_SETTINGS", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareSettingsType.LINK_SETTINGS} />);
|
||||
expect(screen.getByTestId("link-settings-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("LinkSettingsTab Content for survey1 - ReadOnly: false - Locale: en-US")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders nothing when activeId doesn't match any tab", () => {
|
||||
// Create a special case with no matching tab
|
||||
const propsWithNoMatchingTab = {
|
||||
...defaultProps,
|
||||
tabs: mockTabs.slice(0, 3), // Only include first 3 tabs
|
||||
activeId: ShareViaType.SOCIAL_MEDIA, // Use a tab not in the subset
|
||||
activeId: ShareViewType.SOCIAL_MEDIA, // Use a tab not in the subset
|
||||
};
|
||||
|
||||
render(<ShareView {...propsWithNoMatchingTab} />);
|
||||
@@ -485,17 +436,16 @@ describe("ShareView", () => {
|
||||
expect(screen.queryByTestId("qr-code-tab")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("personal-links-tab")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("social-media-tab")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("link-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders PersonalLinksTab when activeId is PERSONAL_LINKS", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.PERSONAL_LINKS} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.PERSONAL_LINKS} />);
|
||||
expect(screen.getByTestId("personal-links-tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("PersonalLinksTab Content for survey1 in env1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders SocialMediaTab when activeId is SOCIAL_MEDIA", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.SOCIAL_MEDIA} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.SOCIAL_MEDIA} />);
|
||||
expect(screen.getByTestId("social-media-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("SocialMediaTab Content for Test Survey at http://example.com/survey1")
|
||||
@@ -503,7 +453,7 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("calls setActiveId when a responsive tab is clicked", async () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
// Get responsive buttons - these are Button components containing icons
|
||||
const responsiveButtons = screen.getAllByTestId("website-embed-tab-icon");
|
||||
@@ -517,12 +467,12 @@ describe("ShareView", () => {
|
||||
|
||||
if (responsiveButton) {
|
||||
await userEvent.click(responsiveButton);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViaType.WEBSITE_EMBED);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
|
||||
}
|
||||
});
|
||||
|
||||
test("applies active styles to the active tab (desktop)", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
const emailTabButton = screen.getByLabelText("Email");
|
||||
expect(emailTabButton).toHaveClass("bg-slate-100");
|
||||
@@ -535,7 +485,7 @@ describe("ShareView", () => {
|
||||
});
|
||||
|
||||
test("applies active styles to the active tab (responsive)", () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
|
||||
|
||||
// Get responsive buttons - these are Button components with ghost variant
|
||||
const responsiveButtons = screen.getAllByTestId("email-tab-icon");
|
||||
@@ -587,7 +537,6 @@ describe("ShareView", () => {
|
||||
"qr-code-tab-icon",
|
||||
"personal-links-tab-icon",
|
||||
"social-media-tab-icon",
|
||||
"link-settings-tab-icon",
|
||||
];
|
||||
|
||||
expectedTestIds.forEach((testId) => {
|
||||
@@ -599,28 +548,4 @@ describe("ShareView", () => {
|
||||
expect(responsiveButton).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test("separates share via and share settings tabs correctly", () => {
|
||||
render(<ShareView {...defaultProps} />);
|
||||
|
||||
// Check that share via tabs are rendered
|
||||
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Website Embed")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Anonymous Links")).toBeInTheDocument();
|
||||
|
||||
// Check that share settings tabs are rendered
|
||||
expect(screen.getByLabelText("Link Settings")).toBeInTheDocument();
|
||||
|
||||
// Check that both sections have their titles
|
||||
expect(screen.getByText("environments.surveys.share.share_view_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.share.share_settings_title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setActiveId when a share settings tab is clicked", async () => {
|
||||
render(<ShareView {...defaultProps} activeId={ShareViaType.EMAIL} />);
|
||||
|
||||
const linkSettingsTabButton = screen.getByLabelText("Link Settings");
|
||||
await userEvent.click(linkSettingsTabButton);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareSettingsType.LINK_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||
import {
|
||||
LinkTabsType,
|
||||
ShareSettingsType,
|
||||
ShareViaType,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -26,18 +22,16 @@ import { useEffect, useState } from "react";
|
||||
|
||||
interface ShareViewProps {
|
||||
tabs: Array<{
|
||||
id: ShareViaType | ShareSettingsType;
|
||||
type: LinkTabsType;
|
||||
id: ShareViewType;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
componentType: React.ComponentType<any>;
|
||||
componentProps: any;
|
||||
title: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
activeId: ShareViaType | ShareSettingsType;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>;
|
||||
activeId: ShareViewType;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<ShareViewType>>;
|
||||
}
|
||||
|
||||
export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
@@ -69,21 +63,6 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const shareSettingsTabs = tabs.filter((tab) => tab.type === LinkTabsType.SHARE_SETTING);
|
||||
const shareViaTabs = tabs.filter((tab) => tab.type === LinkTabsType.SHARE_VIA);
|
||||
const sideBarGroups = [
|
||||
{
|
||||
id: LinkTabsType.SHARE_VIA,
|
||||
label: t("environments.surveys.share.share_view_title"),
|
||||
tabs: shareViaTabs,
|
||||
},
|
||||
{
|
||||
id: LinkTabsType.SHARE_SETTING,
|
||||
label: t("environments.surveys.share.share_settings_title"),
|
||||
tabs: shareSettingsTabs,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className={`flex h-full lg:grid lg:grid-cols-4`}>
|
||||
@@ -97,35 +76,32 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
}>
|
||||
<Sidebar className="relative h-full p-0" variant="inset" collapsible="icon">
|
||||
<SidebarContent className="h-full rounded-l-lg border-r border-slate-200 bg-white p-4">
|
||||
{sideBarGroups.map((group) => (
|
||||
<SidebarGroup className="p-0" key={group.id}>
|
||||
<SidebarGroupLabel>
|
||||
<Small className="text-xs text-slate-500">{group.label}</Small>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="flex flex-col gap-1">
|
||||
{group.tabs.map((tab) => (
|
||||
<SidebarMenuItem key={tab.id}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
||||
tab.id === activeId && !tab.disabled
|
||||
? "bg-slate-100 font-medium text-slate-900"
|
||||
: "text-slate-700"
|
||||
)}
|
||||
tooltip={tab.label}
|
||||
isActive={tab.id === activeId}
|
||||
disabled={tab.disabled}>
|
||||
<tab.icon className="h-4 w-4 text-slate-700" />
|
||||
<span>{tab.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupLabel>
|
||||
<Small className="text-xs text-slate-500">
|
||||
{t("environments.surveys.share.share_view_title")}
|
||||
</Small>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="flex flex-col gap-1">
|
||||
{tabs.map((tab) => (
|
||||
<SidebarMenuItem key={tab.id}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
||||
tab.id === activeId ? "bg-slate-100 font-medium text-slate-900" : "text-slate-700"
|
||||
)}
|
||||
tooltip={tab.label}
|
||||
isActive={tab.id === activeId}>
|
||||
<tab.icon className="h-4 w-4 text-slate-700" />
|
||||
<span>{tab.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
@@ -138,10 +114,9 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2",
|
||||
tab.id === activeId && !tab.disabled
|
||||
tab.id === activeId
|
||||
? "bg-white text-slate-900 shadow-sm hover:bg-white"
|
||||
: "border-transparent text-slate-700 hover:text-slate-900"
|
||||
)}>
|
||||
|
||||
@@ -158,6 +158,7 @@ const mockUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
@@ -173,6 +174,7 @@ const mockSession = {
|
||||
id: mockUserId,
|
||||
name: mockUser.name,
|
||||
email: mockUser.email,
|
||||
image: mockUser.imageUrl,
|
||||
role: mockUser.role,
|
||||
plan: "free",
|
||||
status: "active",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export enum ShareViaType {
|
||||
export enum ShareViewType {
|
||||
ANON_LINKS = "anon-links",
|
||||
PERSONAL_LINKS = "personal-links",
|
||||
EMAIL = "email",
|
||||
@@ -9,12 +9,3 @@ export enum ShareViaType {
|
||||
SOCIAL_MEDIA = "social-media",
|
||||
QR_CODE = "qr-code",
|
||||
}
|
||||
|
||||
export enum ShareSettingsType {
|
||||
LINK_SETTINGS = "link-settings",
|
||||
}
|
||||
|
||||
export enum LinkTabsType {
|
||||
SHARE_VIA = "share_via",
|
||||
SHARE_SETTING = "share_setting",
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -89,94 +89,4 @@ describe("QuestionFilterComboBox", () => {
|
||||
await userEvent.click(comboBoxOpenerButton);
|
||||
expect(screen.queryByText("X")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows text input for URL meta field", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "url",
|
||||
filterValue: "Contains",
|
||||
filterComboBoxValue: "example.com",
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const textInput = screen.getByDisplayValue("example.com");
|
||||
expect(textInput).toBeInTheDocument();
|
||||
expect(textInput).toHaveAttribute("type", "text");
|
||||
});
|
||||
|
||||
test("text input is disabled when no filter value is selected for URL field", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "url",
|
||||
filterValue: undefined,
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const textInput = screen.getByRole("textbox");
|
||||
expect(textInput).toBeDisabled();
|
||||
});
|
||||
|
||||
test("text input calls onChangeFilterComboBoxValue when typing for URL field", async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "url",
|
||||
filterValue: "Contains",
|
||||
filterComboBoxValue: "",
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const textInput = screen.getByRole("textbox");
|
||||
await userEvent.type(textInput, "t");
|
||||
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith("t");
|
||||
});
|
||||
|
||||
test("shows regular combobox for non-URL meta fields", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "source",
|
||||
filterValue: "Equals",
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("shows regular combobox for URL field with non-text operations", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Other",
|
||||
fieldId: "url",
|
||||
filterValue: "Equals",
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("text input handles string filter combo box values correctly", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "url",
|
||||
filterValue: "Contains",
|
||||
filterComboBoxValue: "test-url",
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const textInput = screen.getByDisplayValue("test-url");
|
||||
expect(textInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("text input handles non-string filter combo box values gracefully", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "Meta",
|
||||
fieldId: "url",
|
||||
filterValue: "Contains",
|
||||
filterComboBoxValue: ["array-value"],
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const textInput = screen.getByRole("textbox");
|
||||
expect(textInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,6 @@ type QuestionFilterComboBoxProps = {
|
||||
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
|
||||
handleRemoveMultiSelect: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
fieldId?: string;
|
||||
};
|
||||
|
||||
export const QuestionFilterComboBox = ({
|
||||
@@ -46,7 +45,6 @@ export const QuestionFilterComboBox = ({
|
||||
type,
|
||||
handleRemoveMultiSelect,
|
||||
disabled = false,
|
||||
fieldId,
|
||||
}: QuestionFilterComboBoxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
|
||||
@@ -77,9 +75,6 @@ export const QuestionFilterComboBox = ({
|
||||
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
||||
(filterValue === "Submitted" || filterValue === "Skipped");
|
||||
|
||||
// Check if this is a URL field with string comparison operations that require text input
|
||||
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
||||
|
||||
const filteredOptions = options?.filter((o) =>
|
||||
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
|
||||
.toLowerCase()
|
||||
@@ -166,80 +161,70 @@ export const QuestionFilterComboBox = ({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{isTextInputField ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
|
||||
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
|
||||
disabled={disabled || !filterValue}
|
||||
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
|
||||
/>
|
||||
) : (
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||
<div
|
||||
className={clsx(
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||
filterComboBoxItem
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"flex-1 text-left text-slate-400",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{t("common.select")}...
|
||||
</button>
|
||||
)}
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||
<div
|
||||
className={clsx(
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||
filterComboBoxItem
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"ml-2 flex items-center justify-center",
|
||||
"flex-1 text-left text-slate-400",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
{t("common.select")}...
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder={t("common.search") + "..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o, index) => (
|
||||
<CommandItem
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => commandItemOnSelect(o)}
|
||||
className="cursor-pointer">
|
||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"ml-2 flex items-center justify-center",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder={t("common.search") + "..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o, index) => (
|
||||
<CommandItem
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => commandItemOnSelect(o)}
|
||||
className="cursor-pointer">
|
||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
HomeIcon,
|
||||
ImageIcon,
|
||||
LanguagesIcon,
|
||||
LinkIcon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
MessageSquareTextIcon,
|
||||
@@ -95,7 +94,6 @@ const questionIcons = {
|
||||
source: ArrowUpFromDotIcon,
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
url: LinkIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -140,7 +138,7 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
|
||||
|
||||
const getLabelStyle = (): string | undefined => {
|
||||
if (type !== OptionsType.META) return undefined;
|
||||
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||
return label === "os" ? "uppercase" : "capitalize";
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -246,9 +246,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
|
||||
<div
|
||||
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
|
||||
key={`${s.questionType.id}-${i}-${s.questionType.label}`}>
|
||||
key={`${s.questionType.id}-${i}`}>
|
||||
<QuestionsComboBox
|
||||
key={`${s.questionType.label}-${i}-${s.questionType.id}`}
|
||||
key={`${s.questionType.label}-${i}`}
|
||||
options={questionComboBoxOptions}
|
||||
selected={s.questionType}
|
||||
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
|
||||
@@ -276,7 +276,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
? s?.questionType?.questionType
|
||||
: s?.questionType?.type
|
||||
}
|
||||
fieldId={s?.questionType?.id}
|
||||
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
|
||||
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
|
||||
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
|
||||
|
||||
12
apps/web/app/[shortUrlId]/loading.tsx
Normal file
12
apps/web/app/[shortUrlId]/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex h-1/2 w-1/4 flex-col">
|
||||
<div className="ph-no-capture h-16 w-1/3 animate-pulse rounded-lg bg-slate-200 font-medium text-slate-900"></div>
|
||||
<div className="ph-no-capture mt-4 h-full animate-pulse rounded-lg bg-slate-200 text-slate-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
59
apps/web/app/[shortUrlId]/page.tsx
Normal file
59
apps/web/app/[shortUrlId]/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { getShortUrl } from "@/lib/shortUrl/service";
|
||||
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TShortUrl, ZShortUrlId } from "@formbricks/types/short-url";
|
||||
|
||||
export const generateMetadata = async (props): Promise<Metadata> => {
|
||||
const params = await props.params;
|
||||
if (!params.shortUrlId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
try {
|
||||
const shortUrl = await getShortUrl(params.shortUrlId);
|
||||
|
||||
if (!shortUrl) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const surveyId = shortUrl.url.substring(shortUrl.url.lastIndexOf("/") + 1);
|
||||
return getMetadataForLinkSurvey(surveyId);
|
||||
} catch (error) {
|
||||
notFound();
|
||||
}
|
||||
};
|
||||
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
if (!params.shortUrlId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) {
|
||||
// return not found if unable to parse short url id
|
||||
notFound();
|
||||
}
|
||||
|
||||
let shortUrl: TShortUrl | null = null;
|
||||
|
||||
try {
|
||||
shortUrl = await getShortUrl(params.shortUrlId);
|
||||
} catch (error) {
|
||||
logger.error(error, "Could not fetch short url");
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (shortUrl) {
|
||||
redirect(shortUrl.url);
|
||||
}
|
||||
|
||||
notFound();
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -253,6 +253,7 @@ export const mockResponse: TResponse = {
|
||||
contactAttributes: {},
|
||||
meta: {},
|
||||
finished: true,
|
||||
notes: [],
|
||||
singleUseId: null,
|
||||
tags: [],
|
||||
displayId: null,
|
||||
|
||||
@@ -90,6 +90,7 @@ const mockPipelineInput = {
|
||||
personAttributes: {},
|
||||
singleUseId: null,
|
||||
personId: "person1",
|
||||
notes: [],
|
||||
tags: [],
|
||||
variables: {
|
||||
[variableId]: "Variable Value",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { NextRequest } from "next/server";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
|
||||
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (!apiKey) return null;
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib
|
||||
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
|
||||
@@ -22,180 +21,168 @@ import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
const validateInput = (
|
||||
environmentId: string,
|
||||
userId: string
|
||||
): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => {
|
||||
const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId });
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
return { isValid: true, data: inputValidation.data };
|
||||
};
|
||||
|
||||
const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
|
||||
if (!IS_FORMBRICKS_CLOUD) return false;
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
logger.error({ environmentId }, "Organization does not exist");
|
||||
|
||||
// fail closed if the organization does not exist
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
if (isLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: { responses: monthlyResponseLimit, miu: null },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, `Error sending plan limits reached event to Posthog`);
|
||||
}
|
||||
}
|
||||
|
||||
return isLimitReached;
|
||||
};
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ environmentId: string; userId: string }> };
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const { device } = userAgent(req);
|
||||
export const GET = async (
|
||||
request: NextRequest,
|
||||
props: {
|
||||
params: Promise<{
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
}>;
|
||||
}
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const { device } = userAgent(request);
|
||||
|
||||
// validate using zod
|
||||
const validation = validateInput(params.environmentId, params.userId);
|
||||
if (!validation.isValid) {
|
||||
return { response: validation.error };
|
||||
}
|
||||
// validate using zod
|
||||
const inputValidation = ZJsPeopleUserIdInput.safeParse({
|
||||
environmentId: params.environmentId,
|
||||
userId: params.userId,
|
||||
});
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { environmentId, userId } = validation.data;
|
||||
const { environmentId, userId } = inputValidation.data;
|
||||
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(environmentId);
|
||||
const project = await getProjectByEnvironmentId(environmentId);
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
updateEnvironment(environment.id, { appSetupCompleted: true }),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
}
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
updateEnvironment(environment.id, { appSetupCompleted: true }),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
}
|
||||
|
||||
// check organization subscriptions and response limits
|
||||
const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId);
|
||||
// check organization subscriptions
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
|
||||
let contact = await getContactByUserId(environmentId, userId);
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
attributes: {
|
||||
create: {
|
||||
attributeKey: {
|
||||
connect: {
|
||||
key_environmentId: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
value: userId,
|
||||
if (!organization) {
|
||||
throw new Error("Organization does not exist");
|
||||
}
|
||||
|
||||
// check if response limit is reached
|
||||
let isAppSurveyResponseLimitReached = false;
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
isAppSurveyResponseLimitReached =
|
||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
if (isAppSurveyResponseLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: monthlyResponseLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, url: request.url }, `Error sending plan limits reached event to Posthog`);
|
||||
}
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
|
||||
acc[attribute.attributeKey.key] = attribute.value;
|
||||
return acc;
|
||||
}, {}) as Record<string, string>;
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
getSyncSurveys(
|
||||
environmentId,
|
||||
contact.id,
|
||||
contactAttributes,
|
||||
device.type === "mobile" ? "phone" : "desktop"
|
||||
),
|
||||
getActionClasses(environmentId),
|
||||
]);
|
||||
|
||||
const updatedProject: any = {
|
||||
...project,
|
||||
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
...(project.styling.highlightBorderColor?.light && {
|
||||
highlightBorderColor: project.styling.highlightBorderColor.light,
|
||||
}),
|
||||
};
|
||||
|
||||
const language = contactAttributes["language"];
|
||||
|
||||
// Scenario 1: Multi language and updated trigger action classes supported.
|
||||
// Use the surveys as they are.
|
||||
let transformedSurveys: TSurvey[] = surveys;
|
||||
|
||||
// creating state object
|
||||
let state = {
|
||||
surveys: !isAppSurveyResponseLimitReached
|
||||
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
|
||||
: [],
|
||||
actionClasses,
|
||||
language,
|
||||
project: updatedProject,
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ ...state }, true),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(
|
||||
"Unable to handle the request: " + error.message,
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let contact = await getContactByUserId(environmentId, userId);
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
attributes: {
|
||||
create: {
|
||||
attributeKey: {
|
||||
connect: {
|
||||
key_environmentId: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
|
||||
acc[attribute.attributeKey.key] = attribute.value;
|
||||
return acc;
|
||||
}, {}) as Record<string, string>;
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
getSyncSurveys(
|
||||
environmentId,
|
||||
contact.id,
|
||||
contactAttributes,
|
||||
device.type === "mobile" ? "phone" : "desktop"
|
||||
),
|
||||
getActionClasses(environmentId),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const updatedProject: any = {
|
||||
...project,
|
||||
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
...(project.styling.highlightBorderColor?.light && {
|
||||
highlightBorderColor: project.styling.highlightBorderColor.light,
|
||||
}),
|
||||
};
|
||||
|
||||
const language = contactAttributes["language"];
|
||||
|
||||
// Scenario 1: Multi language and updated trigger action classes supported.
|
||||
// Use the surveys as they are.
|
||||
let transformedSurveys: TSurvey[] = surveys;
|
||||
|
||||
// creating state object
|
||||
let state = {
|
||||
surveys: !isAppSurveyResponseLimitReached
|
||||
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
|
||||
: [],
|
||||
actionClasses,
|
||||
language,
|
||||
project: updatedProject,
|
||||
};
|
||||
|
||||
return responses.successResponse({ ...state }, true);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, url: request.url },
|
||||
"Error in GET /api/v1/client/[environmentId]/app/sync/[userId]"
|
||||
);
|
||||
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZDisplayCreateInput } from "@formbricks/types/displays";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -25,55 +23,40 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
);
|
||||
};
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
|
||||
const params = await props.params;
|
||||
const jsonInput = await req.json();
|
||||
const inputValidation = ZDisplayCreateInput.safeParse({
|
||||
...jsonInput,
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const jsonInput = await request.json();
|
||||
const inputValidation = ZDisplayCreateInput.safeParse({
|
||||
...jsonInput,
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (inputValidation.data.userId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputValidation.data.userId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return {
|
||||
response: responses.forbiddenResponse(
|
||||
"User identification is only available for enterprise users.",
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
|
||||
} else {
|
||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/client/[environmentId]/displays");
|
||||
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return {
|
||||
response: responses.successResponse(response, true),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", inputValidation.data.surveyId),
|
||||
};
|
||||
} else {
|
||||
logger.error({ error, url: req.url }, "Error in POST /api/v1/client/[environmentId]/displays");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -17,69 +16,60 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
);
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ environmentId: string }> };
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
export const GET = async (
|
||||
request: NextRequest,
|
||||
props: {
|
||||
params: Promise<{
|
||||
environmentId: string;
|
||||
}>;
|
||||
}
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
|
||||
if (typeof params.environmentId !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("Environment ID is required", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
// Use optimized environment state fetcher with new caching approach
|
||||
const environmentState = await getEnvironmentState(params.environmentId);
|
||||
const { data } = environmentState;
|
||||
|
||||
return {
|
||||
response: responses.successResponse(
|
||||
{
|
||||
data,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
|
||||
},
|
||||
true,
|
||||
// Optimized cache headers for Cloudflare CDN and browser caching
|
||||
// max-age=3600: 1hr browser cache (per guidelines)
|
||||
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
|
||||
// stale-while-revalidate=1800: 30min stale serving during revalidation
|
||||
// stale-if-error=3600: 1hr stale serving on origin errors
|
||||
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
logger.warn(
|
||||
{
|
||||
environmentId: params.environmentId,
|
||||
resourceType: err.resourceType,
|
||||
resourceId: err.resourceId,
|
||||
},
|
||||
"Resource not found in environment endpoint"
|
||||
);
|
||||
return {
|
||||
response: responses.notFoundResponse(err.resourceType, err.resourceId),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{
|
||||
error: err,
|
||||
url: req.url,
|
||||
environmentId: params.environmentId,
|
||||
},
|
||||
"Error in GET /api/v1/client/[environmentId]/environment"
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(err.message, true),
|
||||
};
|
||||
try {
|
||||
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
|
||||
if (!params.environmentId || typeof params.environmentId !== "string") {
|
||||
return responses.badRequestResponse("Environment ID is required", undefined, true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Use optimized environment state fetcher with new caching approach
|
||||
const environmentState = await getEnvironmentState(params.environmentId);
|
||||
const { data } = environmentState;
|
||||
|
||||
return responses.successResponse(
|
||||
{
|
||||
data,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
|
||||
},
|
||||
true,
|
||||
// Optimized cache headers for Cloudflare CDN and browser caching
|
||||
// max-age=3600: 1hr browser cache (per guidelines)
|
||||
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
|
||||
// stale-while-revalidate=1800: 30min stale serving during revalidation
|
||||
// stale-if-error=3600: 1hr stale serving on origin errors
|
||||
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
logger.warn(
|
||||
{
|
||||
environmentId: params.environmentId,
|
||||
resourceType: err.resourceType,
|
||||
resourceId: err.resourceId,
|
||||
},
|
||||
"Resource not found in environment endpoint"
|
||||
);
|
||||
return responses.notFoundResponse(err.resourceType, err.resourceId);
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{
|
||||
error: err,
|
||||
url: request.url,
|
||||
environmentId: params.environmentId,
|
||||
},
|
||||
"Error in GET /api/v1/client/[environmentId]/environment"
|
||||
);
|
||||
return responses.internalServerErrorResponse(err.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { getResponse, updateResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
@@ -29,135 +27,108 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
|
||||
return responses.internalServerErrorResponse("Unknown error occurred", true);
|
||||
};
|
||||
|
||||
export const PUT = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ responseId: string }> };
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
const { responseId } = params;
|
||||
export const PUT = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ responseId: string }> }
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
const { responseId } = params;
|
||||
|
||||
if (!responseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Response ID is missing", undefined, true),
|
||||
};
|
||||
if (!responseId) {
|
||||
return responses.badRequestResponse("Response ID is missing", undefined, true);
|
||||
}
|
||||
|
||||
const responseUpdate = await request.json();
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await getResponse(responseId);
|
||||
} catch (error) {
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
if (response.finished) {
|
||||
return responses.badRequestResponse("Response is already finished", undefined, true);
|
||||
}
|
||||
|
||||
// get survey to get environmentId
|
||||
let survey;
|
||||
try {
|
||||
survey = await getSurvey(response.surveyId);
|
||||
} catch (error) {
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response", undefined, true);
|
||||
}
|
||||
|
||||
// Validate response data for "other" options exceeding character limit
|
||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||
responseData: inputValidation.data.data,
|
||||
surveyQuestions: survey.questions,
|
||||
responseLanguage: inputValidation.data.language,
|
||||
});
|
||||
|
||||
if (otherResponseInvalidQuestionId) {
|
||||
return responses.badRequestResponse(
|
||||
`Response exceeds character limit`,
|
||||
{
|
||||
questionId: otherResponseInvalidQuestionId,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// update response
|
||||
let updatedResponse;
|
||||
try {
|
||||
updatedResponse = await updateResponse(responseId, inputValidation.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse("Response", responseId, true);
|
||||
}
|
||||
|
||||
const responseUpdate = await req.json();
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await getResponse(responseId);
|
||||
} catch (error) {
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return {
|
||||
response: handleDatabaseError(error, req.url, endpoint, responseId),
|
||||
};
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error(
|
||||
{ error, url: request.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.finished) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Response is already finished", undefined, true),
|
||||
};
|
||||
}
|
||||
// send response update to pipeline
|
||||
// don't await to not block the response
|
||||
sendToPipeline({
|
||||
event: "responseUpdated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: updatedResponse,
|
||||
});
|
||||
|
||||
// get survey to get environmentId
|
||||
let survey;
|
||||
try {
|
||||
survey = await getSurvey(response.surveyId);
|
||||
} catch (error) {
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return {
|
||||
response: handleDatabaseError(error, req.url, endpoint, responseId),
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
// Validate response data for "other" options exceeding character limit
|
||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||
responseData: inputValidation.data.data,
|
||||
surveyQuestions: survey.questions,
|
||||
responseLanguage: inputValidation.data.language,
|
||||
});
|
||||
|
||||
if (otherResponseInvalidQuestionId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
`Response exceeds character limit`,
|
||||
{
|
||||
questionId: otherResponseInvalidQuestionId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// update response
|
||||
let updatedResponse;
|
||||
try {
|
||||
updatedResponse = await updateResponse(responseId, inputValidation.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Response", responseId, true),
|
||||
};
|
||||
}
|
||||
if (error instanceof InvalidInputError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error(
|
||||
{ error, url: req.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error.message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// send response update to pipeline
|
||||
if (updatedResponse.finished) {
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
sendToPipeline({
|
||||
event: "responseUpdated",
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: updatedResponse,
|
||||
});
|
||||
|
||||
if (updatedResponse.finished) {
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: updatedResponse,
|
||||
});
|
||||
}
|
||||
return {
|
||||
response: responses.successResponse({}, true),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
@@ -98,6 +98,7 @@ const mockResponsePrisma = {
|
||||
language: null,
|
||||
displayId: null,
|
||||
tags: [],
|
||||
notes: [],
|
||||
};
|
||||
|
||||
describe("createResponse", () => {
|
||||
|
||||
@@ -53,6 +53,22 @@ export const responseSelection = {
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
isResolved: true,
|
||||
isEdited: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ResponseSelect;
|
||||
|
||||
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { headers } from "next/headers";
|
||||
import { NextRequest } from "next/server";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -31,150 +29,121 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
);
|
||||
};
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
|
||||
const params = await props.params;
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
try {
|
||||
responseInput = await req.json();
|
||||
} catch (error) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid JSON in request body",
|
||||
{ error: error.message },
|
||||
true
|
||||
),
|
||||
};
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
try {
|
||||
responseInput = await request.json();
|
||||
} catch (error) {
|
||||
return responses.badRequestResponse("Invalid JSON in request body", { error: error.message }, true);
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (!responseInputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(responseInputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const agent = new UAParser(userAgent);
|
||||
|
||||
const country =
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
|
||||
const responseInputData = responseInputValidation.data;
|
||||
|
||||
if (responseInputData.userId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
}
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInputData.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
|
||||
}
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return responses.badRequestResponse(
|
||||
"Survey is part of another environment",
|
||||
{
|
||||
"survey.environmentId": survey.environmentId,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
if (!validateFileUploads(responseInputData.data, survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response");
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInput["meta"] = {
|
||||
source: responseInputData?.meta?.source,
|
||||
url: responseInputData?.meta?.url,
|
||||
userAgent: {
|
||||
browser: agent.getBrowser().name,
|
||||
device: agent.getDevice().type || "desktop",
|
||||
os: agent.getOS().name,
|
||||
},
|
||||
country: country,
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
response = await createResponse({
|
||||
...responseInputData,
|
||||
meta,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
} else {
|
||||
logger.error({ error, url: request.url }, "Error creating response");
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!responseInputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(responseInputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const userAgent = req.headers.get("user-agent") || undefined;
|
||||
const agent = new UAParser(userAgent);
|
||||
|
||||
const country =
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
|
||||
const responseInputData = responseInputValidation.data;
|
||||
|
||||
if (responseInputData.userId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return {
|
||||
response: responses.forbiddenResponse(
|
||||
"User identification is only available for enterprise users.",
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInputData.surveyId);
|
||||
if (!survey) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", responseInputData.surveyId, true),
|
||||
};
|
||||
}
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Survey is part of another environment",
|
||||
{
|
||||
"survey.environmentId": survey.environmentId,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseInputData.data, survey.questions)) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid file upload response"),
|
||||
};
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInput["meta"] = {
|
||||
source: responseInputData?.meta?.source,
|
||||
url: responseInputData?.meta?.url,
|
||||
userAgent: {
|
||||
browser: agent.getBrowser().name,
|
||||
device: agent.getDevice().type || "desktop",
|
||||
os: agent.getOS().name,
|
||||
},
|
||||
country: country,
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
response = await createResponse({
|
||||
...responseInputData,
|
||||
meta,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
} else {
|
||||
logger.error({ error, url: req.url }, "Error creating response");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error.message),
|
||||
};
|
||||
}
|
||||
}
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
|
||||
if (responseInput.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
|
||||
if (responseInput.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
||||
surveyId: response.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
||||
surveyId: response.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ id: response.id }, true),
|
||||
};
|
||||
},
|
||||
});
|
||||
return responses.successResponse({ id: response.id }, true);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// body -> should be a valid file object (buffer)
|
||||
// method -> PUT (to be the same as the signedUrl method)
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
|
||||
import { validateLocalSignedUrl } from "@/lib/crypto";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
@@ -29,150 +28,115 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
);
|
||||
};
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Encryption key is not set"),
|
||||
};
|
||||
}
|
||||
const params = await props.params;
|
||||
const environmentId = params.environmentId;
|
||||
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return responses.internalServerErrorResponse("Encryption key is not set");
|
||||
}
|
||||
const params = await context.params;
|
||||
const environmentId = params.environmentId;
|
||||
|
||||
const accessType = "private"; // private files are accessible only by authorized users
|
||||
const accessType = "private"; // private files are accessible only by authorized users
|
||||
|
||||
const jsonInput = await req.json();
|
||||
const fileType = jsonInput.fileType as string;
|
||||
const encodedFileName = jsonInput.fileName as string;
|
||||
const surveyId = jsonInput.surveyId as string;
|
||||
const signedSignature = jsonInput.signature as string;
|
||||
const signedUuid = jsonInput.uuid as string;
|
||||
const signedTimestamp = jsonInput.timestamp as string;
|
||||
const jsonInput = await req.json();
|
||||
const fileType = jsonInput.fileType as string;
|
||||
const encodedFileName = jsonInput.fileName as string;
|
||||
const surveyId = jsonInput.surveyId as string;
|
||||
const signedSignature = jsonInput.signature as string;
|
||||
const signedUuid = jsonInput.uuid as string;
|
||||
const signedTimestamp = jsonInput.timestamp as string;
|
||||
|
||||
if (!fileType) {
|
||||
return {
|
||||
response: responses.badRequestResponse("contentType is required"),
|
||||
};
|
||||
}
|
||||
if (!fileType) {
|
||||
return responses.badRequestResponse("contentType is required");
|
||||
}
|
||||
|
||||
if (!encodedFileName) {
|
||||
return {
|
||||
response: responses.badRequestResponse("fileName is required"),
|
||||
};
|
||||
}
|
||||
if (!encodedFileName) {
|
||||
return responses.badRequestResponse("fileName is required");
|
||||
}
|
||||
|
||||
if (!surveyId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("surveyId is required"),
|
||||
};
|
||||
}
|
||||
if (!surveyId) {
|
||||
return responses.badRequestResponse("surveyId is required");
|
||||
}
|
||||
|
||||
if (!signedSignature) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!signedSignature) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!signedUuid) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!signedUuid) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!signedTimestamp) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!signedTimestamp) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const [survey, organization] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
const [survey, organization] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", surveyId),
|
||||
};
|
||||
}
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", surveyId);
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return {
|
||||
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
|
||||
};
|
||||
}
|
||||
if (!organization) {
|
||||
return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId);
|
||||
}
|
||||
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
|
||||
// Perform server-side file validation again
|
||||
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return {
|
||||
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file", {
|
||||
fileName,
|
||||
fileType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// Perform server-side file validation again
|
||||
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType });
|
||||
}
|
||||
|
||||
// validate signature
|
||||
const validated = validateLocalSignedUrl(
|
||||
signedUuid,
|
||||
// validate signature
|
||||
const validated = validateLocalSignedUrl(
|
||||
signedUuid,
|
||||
fileName,
|
||||
environmentId,
|
||||
fileType,
|
||||
Number(signedTimestamp),
|
||||
signedSignature,
|
||||
ENCRYPTION_KEY
|
||||
);
|
||||
|
||||
if (!validated) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const base64String = jsonInput.fileBase64String as string;
|
||||
|
||||
const buffer = Buffer.from(base64String.split(",")[1], "base64");
|
||||
const file = new Blob([buffer], { type: fileType });
|
||||
|
||||
if (!file) {
|
||||
return responses.badRequestResponse("fileBuffer is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
|
||||
const bytes = await file.arrayBuffer();
|
||||
const fileBuffer = Buffer.from(bytes);
|
||||
|
||||
await putFileToLocalStorage(
|
||||
fileName,
|
||||
fileBuffer,
|
||||
accessType,
|
||||
environmentId,
|
||||
fileType,
|
||||
Number(signedTimestamp),
|
||||
signedSignature,
|
||||
ENCRYPTION_KEY
|
||||
UPLOADS_DIR,
|
||||
isBiggerFileUploadAllowed
|
||||
);
|
||||
|
||||
if (!validated) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
return responses.successResponse({
|
||||
message: "File uploaded successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return responses.badRequestResponse(err.message);
|
||||
}
|
||||
|
||||
const base64String = jsonInput.fileBase64String as string;
|
||||
|
||||
const buffer = Buffer.from(base64String.split(",")[1], "base64");
|
||||
const file = new Blob([buffer], { type: fileType });
|
||||
|
||||
if (!file) {
|
||||
return {
|
||||
response: responses.badRequestResponse("fileBuffer is required"),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
|
||||
const bytes = await file.arrayBuffer();
|
||||
const fileBuffer = Buffer.from(bytes);
|
||||
|
||||
await putFileToLocalStorage(
|
||||
fileName,
|
||||
fileBuffer,
|
||||
accessType,
|
||||
environmentId,
|
||||
UPLOADS_DIR,
|
||||
isBiggerFileUploadAllowed
|
||||
);
|
||||
|
||||
return {
|
||||
response: responses.successResponse({
|
||||
message: "File uploaded successfully",
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return {
|
||||
response: responses.badRequestResponse(err.message),
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("File upload failed"),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
return responses.internalServerErrorResponse("File upload failed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -31,62 +30,46 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
// use this to let users upload files to a survey for example
|
||||
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
|
||||
const params = await props.params;
|
||||
const environmentId = params.environmentId;
|
||||
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const environmentId = params.environmentId;
|
||||
|
||||
const jsonInput = await req.json();
|
||||
const inputValidation = ZUploadFileRequest.safeParse({
|
||||
...jsonInput,
|
||||
environmentId,
|
||||
});
|
||||
const jsonInput = await req.json();
|
||||
const inputValidation = ZUploadFileRequest.safeParse({
|
||||
...jsonInput,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid request",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Invalid request",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { fileName, fileType, surveyId } = inputValidation.data;
|
||||
const { fileName, fileType, surveyId } = inputValidation.data;
|
||||
|
||||
// Perform server-side file validation
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
fileValidation.error ?? "Invalid file",
|
||||
{ fileName, fileType },
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
// Perform server-side file validation
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType }, true);
|
||||
}
|
||||
|
||||
const [survey, organization] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
const [survey, organization] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", surveyId),
|
||||
};
|
||||
}
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", surveyId);
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return {
|
||||
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
|
||||
};
|
||||
}
|
||||
if (!organization) {
|
||||
return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId);
|
||||
}
|
||||
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
|
||||
|
||||
return {
|
||||
response: await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed),
|
||||
};
|
||||
},
|
||||
});
|
||||
return await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import * as z from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -20,83 +21,65 @@ const getEmail = async (token: string) => {
|
||||
return z.string().parse(res_?.email);
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environmentId"),
|
||||
};
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("Invalid environmentId");
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return responses.badRequestResponse("`code` must be a string");
|
||||
}
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const client_id = AIRTABLE_CLIENT_ID;
|
||||
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
|
||||
const code_verifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
|
||||
|
||||
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
|
||||
|
||||
const formData = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri,
|
||||
client_id,
|
||||
code_verifier,
|
||||
};
|
||||
|
||||
try {
|
||||
const key = await fetchAirtableAuthToken(formData);
|
||||
if (!key) {
|
||||
return responses.notFoundResponse("airtable auth token", key);
|
||||
}
|
||||
const email = await getEmail(key.access_token);
|
||||
|
||||
if (!code) {
|
||||
return {
|
||||
response: responses.badRequestResponse("`code` is missing"),
|
||||
};
|
||||
}
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
const client_id = AIRTABLE_CLIENT_ID;
|
||||
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
|
||||
const code_verifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString(
|
||||
"base64"
|
||||
);
|
||||
|
||||
if (!client_id)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Airtable client id is missing"),
|
||||
};
|
||||
|
||||
const formData = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri,
|
||||
client_id,
|
||||
code_verifier,
|
||||
const airtableIntegrationInput = {
|
||||
type: "airtable" as "airtable",
|
||||
environment: environmentId,
|
||||
config: {
|
||||
key,
|
||||
data: [],
|
||||
email,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const key = await fetchAirtableAuthToken(formData);
|
||||
if (!key) {
|
||||
return {
|
||||
response: responses.notFoundResponse("airtable auth token", key),
|
||||
};
|
||||
}
|
||||
const email = await getEmail(key.access_token);
|
||||
|
||||
const airtableIntegrationInput = {
|
||||
type: "airtable" as "airtable",
|
||||
environment: environmentId,
|
||||
config: {
|
||||
key,
|
||||
data: [],
|
||||
email,
|
||||
},
|
||||
};
|
||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
||||
return {
|
||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
||||
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`);
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
|
||||
responses.internalServerErrorResponse(error);
|
||||
}
|
||||
responses.badRequestResponse("unknown error occurred");
|
||||
};
|
||||
|
||||
@@ -1,66 +1,54 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import crypto from "crypto";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`;
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("environmentId is missing"),
|
||||
};
|
||||
}
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is missing");
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const client_id = AIRTABLE_CLIENT_ID;
|
||||
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
|
||||
if (!client_id)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Airtable client id is missing"),
|
||||
};
|
||||
const codeVerifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString(
|
||||
"base64"
|
||||
);
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const codeChallengeMethod = "S256";
|
||||
const codeChallenge = crypto
|
||||
.createHash("sha256")
|
||||
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
|
||||
.digest("base64") // base64 encode, needs to be transformed to base64url
|
||||
.replace(/=/g, "") // remove =
|
||||
.replace(/\+/g, "-") // replace + with -
|
||||
.replace(/\//g, "_"); // replace / with _ now base64url encoded
|
||||
const client_id = AIRTABLE_CLIENT_ID;
|
||||
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
|
||||
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
|
||||
const codeVerifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
|
||||
|
||||
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
|
||||
const codeChallengeMethod = "S256";
|
||||
const codeChallenge = crypto
|
||||
.createHash("sha256")
|
||||
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
|
||||
.digest("base64") // base64 encode, needs to be transformed to base64url
|
||||
.replace(/=/g, "") // remove =
|
||||
.replace(/\+/g, "-") // replace + with -
|
||||
.replace(/\//g, "_"); // replace / with _ now base64url encoded
|
||||
|
||||
authUrl.searchParams.append("client_id", client_id);
|
||||
authUrl.searchParams.append("redirect_uri", redirect_uri);
|
||||
authUrl.searchParams.append("state", environmentId);
|
||||
authUrl.searchParams.append("scope", scope);
|
||||
authUrl.searchParams.append("response_type", "code");
|
||||
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
|
||||
authUrl.searchParams.append("code_challenge", codeChallenge);
|
||||
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ authUrl: authUrl.toString() }),
|
||||
};
|
||||
},
|
||||
});
|
||||
authUrl.searchParams.append("client_id", client_id);
|
||||
authUrl.searchParams.append("redirect_uri", redirect_uri);
|
||||
authUrl.searchParams.append("state", environmentId);
|
||||
authUrl.searchParams.append("scope", scope);
|
||||
authUrl.searchParams.append("response_type", "code");
|
||||
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
|
||||
authUrl.searchParams.append("code_challenge", codeChallenge);
|
||||
|
||||
return responses.successResponse({ authUrl: authUrl.toString() });
|
||||
};
|
||||
|
||||
@@ -1,55 +1,43 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getTables } from "@/lib/airtable/service";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import * as z from "zod";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const url = req.url;
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]);
|
||||
const baseId = z.string().safeParse(queryParams.get("baseId"));
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]);
|
||||
const session = await getServerSession(authOptions);
|
||||
const baseId = z.string().safeParse(queryParams.get("baseId"));
|
||||
|
||||
if (!baseId.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Base Id is Required"),
|
||||
};
|
||||
}
|
||||
if (!baseId.success) {
|
||||
return responses.badRequestResponse("Base Id is Required");
|
||||
}
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("environmentId is missing"),
|
||||
};
|
||||
}
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is missing");
|
||||
}
|
||||
|
||||
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment || !environmentId) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!integration) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Integration not found", environmentId),
|
||||
};
|
||||
}
|
||||
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
|
||||
|
||||
const tables = await getTables(integration.config.key, baseId.data);
|
||||
return {
|
||||
response: responses.successResponse(tables),
|
||||
};
|
||||
},
|
||||
});
|
||||
if (!integration) {
|
||||
return responses.notFoundResponse("Integration not found", environmentId);
|
||||
}
|
||||
|
||||
const tables = await getTables(integration.config.key, baseId.data);
|
||||
return responses.successResponse(tables);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import {
|
||||
ENCRYPTION_KEY,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -12,93 +11,70 @@ import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integrati
|
||||
import { NextRequest } from "next/server";
|
||||
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req }: { req: NextRequest }) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const error = queryParams.get("error");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const error = queryParams.get("error");
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environmentId"),
|
||||
};
|
||||
}
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("Invalid environmentId");
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("`code` must be a string"),
|
||||
};
|
||||
}
|
||||
if (code && typeof code !== "string") {
|
||||
return responses.badRequestResponse("`code` must be a string");
|
||||
}
|
||||
|
||||
const client_id = NOTION_OAUTH_CLIENT_ID;
|
||||
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
|
||||
const redirect_uri = NOTION_REDIRECT_URI;
|
||||
if (!client_id)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion client id is missing"),
|
||||
};
|
||||
if (!redirect_uri)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion redirect url is missing"),
|
||||
};
|
||||
if (!client_secret)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion client secret is missing"),
|
||||
};
|
||||
if (code) {
|
||||
// encode in base 64
|
||||
const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
|
||||
const client_id = NOTION_OAUTH_CLIENT_ID;
|
||||
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
|
||||
const redirect_uri = NOTION_REDIRECT_URI;
|
||||
if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing");
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing");
|
||||
if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing");
|
||||
if (code) {
|
||||
// encode in base 64
|
||||
const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
|
||||
|
||||
const response = await fetch("https://api.notion.com/v1/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${encoded}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: redirect_uri,
|
||||
}),
|
||||
});
|
||||
const response = await fetch("https://api.notion.com/v1/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${encoded}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: redirect_uri,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = await response.json();
|
||||
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY);
|
||||
tokenData.access_token = encryptedAccessToken;
|
||||
const tokenData = await response.json();
|
||||
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY!);
|
||||
tokenData.access_token = encryptedAccessToken;
|
||||
|
||||
const notionIntegration: TIntegrationNotionInput = {
|
||||
type: "notion" as "notion",
|
||||
config: {
|
||||
key: tokenData,
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
|
||||
const existingIntegration = await getIntegrationByType(environmentId, "notion");
|
||||
if (existingIntegration) {
|
||||
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
|
||||
}
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`),
|
||||
};
|
||||
}
|
||||
} else if (error) {
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.badRequestResponse("Missing code or error parameter"),
|
||||
const notionIntegration: TIntegrationNotionInput = {
|
||||
type: "notion" as "notion",
|
||||
config: {
|
||||
key: tokenData,
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const existingIntegration = await getIntegrationByType(environmentId, "notion");
|
||||
if (existingIntegration) {
|
||||
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
|
||||
}
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
|
||||
|
||||
if (result) {
|
||||
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`);
|
||||
}
|
||||
} else if (error) {
|
||||
return Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import {
|
||||
NOTION_AUTH_URL,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -7,54 +6,35 @@ import {
|
||||
NOTION_REDIRECT_URI,
|
||||
} from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("environmentId is missing"),
|
||||
};
|
||||
}
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is missing");
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const client_id = NOTION_OAUTH_CLIENT_ID;
|
||||
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
|
||||
const auth_url = NOTION_AUTH_URL;
|
||||
const redirect_uri = NOTION_REDIRECT_URI;
|
||||
if (!client_id)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion client id is missing"),
|
||||
};
|
||||
if (!redirect_uri)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion redirect url is missing"),
|
||||
};
|
||||
if (!client_secret)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion client secret is missing"),
|
||||
};
|
||||
if (!auth_url)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Notion auth url is missing"),
|
||||
};
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` }),
|
||||
};
|
||||
},
|
||||
});
|
||||
const client_id = NOTION_OAUTH_CLIENT_ID;
|
||||
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
|
||||
const auth_url = NOTION_AUTH_URL;
|
||||
const redirect_uri = NOTION_REDIRECT_URI;
|
||||
if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing");
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing");
|
||||
if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing");
|
||||
if (!auth_url) return responses.internalServerErrorResponse("Notion auth url is missing");
|
||||
|
||||
return responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` });
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -9,103 +8,79 @@ import {
|
||||
TIntegrationSlackCredential,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req }: { req: NextRequest }) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const error = queryParams.get("error");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const error = queryParams.get("error");
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environmentId"),
|
||||
};
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("Invalid environmentId");
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return responses.badRequestResponse("`code` must be a string");
|
||||
}
|
||||
|
||||
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
|
||||
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
|
||||
|
||||
const formData = {
|
||||
code,
|
||||
client_id: SLACK_CLIENT_ID,
|
||||
client_secret: SLACK_CLIENT_SECRET,
|
||||
};
|
||||
const formBody: string[] = [];
|
||||
for (const property in formData) {
|
||||
const encodedKey = encodeURIComponent(property);
|
||||
const encodedValue = encodeURIComponent(formData[property]);
|
||||
formBody.push(encodedKey + "=" + encodedValue);
|
||||
}
|
||||
const bodyString = formBody.join("&");
|
||||
if (code) {
|
||||
const response = await fetch("https://slack.com/api/oauth.v2.access", {
|
||||
method: "POST",
|
||||
body: bodyString,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.ok) {
|
||||
return responses.badRequestResponse(data.error);
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("`code` must be a string"),
|
||||
};
|
||||
}
|
||||
|
||||
if (!SLACK_CLIENT_ID)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack client id is missing"),
|
||||
};
|
||||
if (!SLACK_CLIENT_SECRET)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack client secret is missing"),
|
||||
};
|
||||
|
||||
const formData = {
|
||||
code,
|
||||
client_id: SLACK_CLIENT_ID,
|
||||
client_secret: SLACK_CLIENT_SECRET,
|
||||
const slackCredentials: TIntegrationSlackCredential = {
|
||||
app_id: data.app_id,
|
||||
authed_user: data.authed_user,
|
||||
token_type: data.token_type,
|
||||
access_token: data.access_token,
|
||||
bot_user_id: data.bot_user_id,
|
||||
team: data.team,
|
||||
};
|
||||
const formBody: string[] = [];
|
||||
for (const property in formData) {
|
||||
const encodedKey = encodeURIComponent(property);
|
||||
const encodedValue = encodeURIComponent(formData[property]);
|
||||
formBody.push(encodedKey + "=" + encodedValue);
|
||||
}
|
||||
const bodyString = formBody.join("&");
|
||||
if (code) {
|
||||
const response = await fetch("https://slack.com/api/oauth.v2.access", {
|
||||
method: "POST",
|
||||
body: bodyString,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const slackIntegration = await getIntegrationByType(environmentId, "slack");
|
||||
|
||||
if (!data.ok) {
|
||||
return {
|
||||
response: responses.badRequestResponse(data.error),
|
||||
};
|
||||
}
|
||||
|
||||
const slackCredentials: TIntegrationSlackCredential = {
|
||||
app_id: data.app_id,
|
||||
authed_user: data.authed_user,
|
||||
token_type: data.token_type,
|
||||
access_token: data.access_token,
|
||||
bot_user_id: data.bot_user_id,
|
||||
team: data.team,
|
||||
};
|
||||
|
||||
const slackIntegration = await getIntegrationByType(environmentId, "slack");
|
||||
|
||||
const slackConfiguration: TIntegrationSlackConfig = {
|
||||
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],
|
||||
key: slackCredentials,
|
||||
};
|
||||
|
||||
const integration = {
|
||||
type: "slack" as "slack",
|
||||
environment: environmentId,
|
||||
config: slackConfiguration,
|
||||
};
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, integration);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`),
|
||||
};
|
||||
}
|
||||
} else if (error) {
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.badRequestResponse("Missing code or error parameter"),
|
||||
const slackConfiguration: TIntegrationSlackConfig = {
|
||||
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],
|
||||
key: slackCredentials,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const integration = {
|
||||
type: "slack" as "slack",
|
||||
environment: environmentId,
|
||||
config: slackConfiguration,
|
||||
};
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, integration);
|
||||
|
||||
if (result) {
|
||||
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`);
|
||||
}
|
||||
} else if (error) {
|
||||
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,47 +1,30 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const environmentId = req.headers.get("environmentId");
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!environmentId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("environmentId is missing"),
|
||||
};
|
||||
}
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is missing");
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
if (!SLACK_CLIENT_ID)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack client id is missing"),
|
||||
};
|
||||
if (!SLACK_CLIENT_SECRET)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack client secret is missing"),
|
||||
};
|
||||
if (!SLACK_AUTH_URL)
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Slack auth url is missing"),
|
||||
};
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` }),
|
||||
};
|
||||
},
|
||||
});
|
||||
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
|
||||
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
|
||||
if (!SLACK_AUTH_URL) return responses.internalServerErrorResponse("Slack auth url is missing");
|
||||
|
||||
return responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` });
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
@@ -28,49 +27,36 @@ const fetchAndAuthorizeActionClass = async (
|
||||
return actionClass;
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
props,
|
||||
authentication,
|
||||
}: {
|
||||
props: { params: Promise<{ actionClassId: string }> };
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ actionClassId: string }> }
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
|
||||
if (actionClass) {
|
||||
return responses.successResponse(actionClass);
|
||||
}
|
||||
return responses.notFoundResponse("Action Class", params.actionClassId);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
|
||||
if (actionClass) {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.successResponse(actionClass),
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: responses.notFoundResponse("Action Class", params.actionClassId),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
response: handleErrorResponse(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
|
||||
export const PUT = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ actionClassId: string }> };
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
|
||||
if (!actionClass) {
|
||||
return {
|
||||
@@ -78,12 +64,13 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
auditLog.oldObject = actionClass;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
let actionClassUpdate;
|
||||
try {
|
||||
actionClassUpdate = await req.json();
|
||||
actionClassUpdate = await request.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON");
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
@@ -118,25 +105,24 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "actionClass",
|
||||
});
|
||||
"updated",
|
||||
"actionClass"
|
||||
);
|
||||
|
||||
export const DELETE = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
props,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
props: { params: Promise<{ actionClassId: string }> };
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
export const DELETE = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
|
||||
auditLog.targetId = params.actionClassId;
|
||||
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
|
||||
if (!actionClass) {
|
||||
return {
|
||||
@@ -145,6 +131,7 @@ export const DELETE = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
auditLog.oldObject = actionClass;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
const deletedActionClass = await deleteActionClass(params.actionClassId);
|
||||
return {
|
||||
@@ -156,6 +143,6 @@ export const DELETE = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "actionClass",
|
||||
});
|
||||
"deleted",
|
||||
"actionClass"
|
||||
);
|
||||
|
||||
@@ -1,53 +1,51 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { createActionClass } from "@/lib/actionClass/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getActionClasses } from "./lib/action-classes";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
|
||||
export const GET = async (request: Request) => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const actionClasses = await getActionClasses(environmentIds);
|
||||
|
||||
return responses.successResponse(actionClasses);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = withApiLogging(
|
||||
async (request: Request, _, auditLog: ApiAuditLog) => {
|
||||
try {
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const actionClasses = await getActionClasses(environmentIds);
|
||||
|
||||
return {
|
||||
response: responses.successResponse(actionClasses),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
try {
|
||||
let actionClassInput;
|
||||
try {
|
||||
actionClassInput = await req.json();
|
||||
actionClassInput = await request.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON input");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
@@ -87,6 +85,6 @@ export const POST = withV1ApiWrapper({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
action: "created",
|
||||
targetType: "actionClass",
|
||||
});
|
||||
"created",
|
||||
"actionClass"
|
||||
);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
@@ -12,11 +9,9 @@ export const GET = async () => {
|
||||
const headersList = await headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
if (apiKey) {
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey: hashedApiKey,
|
||||
hashedKey: hashApiKey(apiKey),
|
||||
},
|
||||
select: {
|
||||
apiKeyEnvironments: {
|
||||
@@ -44,13 +39,9 @@ export const GET = async () => {
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, hashedApiKey);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
return new Response("Not authenticated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -69,18 +60,16 @@ export const GET = async () => {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return responses.badRequestResponse("You can't use this method with this API key");
|
||||
return new Response("You can't use this method with this API key", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const sessionUser = await getSessionUser();
|
||||
if (!sessionUser) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, sessionUser.id);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
return new Response("Not authenticated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
|
||||
async function fetchAndAuthorizeResponse(
|
||||
responseId: string,
|
||||
authentication: TApiKeyAuthentication,
|
||||
authentication: any,
|
||||
requiredPermission: "GET" | "PUT" | "DELETE"
|
||||
) {
|
||||
if (!authentication) {
|
||||
return { error: responses.notAuthenticatedResponse() };
|
||||
}
|
||||
|
||||
const response = await getResponse(responseId);
|
||||
if (!response) {
|
||||
return { error: responses.notFoundResponse("Response", responseId) };
|
||||
@@ -36,47 +31,38 @@ async function fetchAndAuthorizeResponse(
|
||||
return { response, survey };
|
||||
}
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
props,
|
||||
authentication,
|
||||
}: {
|
||||
props: { params: Promise<{ responseId: string }> };
|
||||
authentication: TApiKeyAuthentication;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
|
||||
if (result.error) {
|
||||
return {
|
||||
response: result.error,
|
||||
};
|
||||
}
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ responseId: string }> }
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
return {
|
||||
response: responses.successResponse(result.response),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
response: handleErrorResponse(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
|
||||
if (result.error) return result.error;
|
||||
|
||||
export const DELETE = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
props,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
props: { params: Promise<{ responseId: string }> };
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: TApiKeyAuthentication;
|
||||
}) => {
|
||||
return responses.successResponse(result.response);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
auditLog.targetId = params.responseId;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
|
||||
if (result.error) {
|
||||
return {
|
||||
@@ -95,25 +81,24 @@ export const DELETE = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "response",
|
||||
});
|
||||
"deleted",
|
||||
"response"
|
||||
);
|
||||
|
||||
export const PUT = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ responseId: string }> };
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: TApiKeyAuthentication;
|
||||
}) => {
|
||||
export const PUT = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
auditLog.targetId = params.responseId;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
|
||||
if (result.error) {
|
||||
return {
|
||||
@@ -124,9 +109,9 @@ export const PUT = withV1ApiWrapper({
|
||||
|
||||
let responseUpdate;
|
||||
try {
|
||||
responseUpdate = await req.json();
|
||||
responseUpdate = await request.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON");
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
@@ -159,6 +144,6 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "response",
|
||||
});
|
||||
"updated",
|
||||
"response"
|
||||
);
|
||||
|
||||
@@ -65,7 +65,8 @@ const mockResponsePrisma = {
|
||||
displayId,
|
||||
contact: null, // Prisma relation
|
||||
tags: [], // Prisma relation
|
||||
} as unknown as ResponsePrisma & { contact: any; tags: any[] }; // Adjust type as needed
|
||||
notes: [], // Prisma relation
|
||||
} as unknown as ResponsePrisma & { contact: any; tags: any[]; notes: any[] }; // Adjust type as needed
|
||||
|
||||
const mockResponse: TResponse = {
|
||||
id: responseId,
|
||||
@@ -84,6 +85,7 @@ const mockResponse: TResponse = {
|
||||
displayId,
|
||||
contact: null, // Transformed structure
|
||||
tags: [], // Transformed structure
|
||||
notes: [], // Transformed structure
|
||||
};
|
||||
|
||||
const mockEnvironmentIds = [environmentId, "env-2"];
|
||||
|
||||
@@ -57,6 +57,22 @@ export const responseSelection = {
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
text: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
isResolved: true,
|
||||
isEdited: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ResponseSelect;
|
||||
|
||||
export const createResponse = async (responseInput: TResponseInput): Promise<TResponse> => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -11,56 +12,42 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const surveyId = searchParams.get("surveyId");
|
||||
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined;
|
||||
export const GET = async (request: NextRequest) => {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const surveyId = searchParams.get("surveyId");
|
||||
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined;
|
||||
|
||||
try {
|
||||
let allResponses: TResponse[] = [];
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
let allResponses: TResponse[] = [];
|
||||
|
||||
if (surveyId) {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", surveyId, true),
|
||||
};
|
||||
}
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
const surveyResponses = await getResponses(surveyId, limit, offset);
|
||||
allResponses.push(...surveyResponses);
|
||||
} else {
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
|
||||
allResponses.push(...environmentResponses);
|
||||
if (surveyId) {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", surveyId, true);
|
||||
}
|
||||
return {
|
||||
response: responses.successResponse(allResponses),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
throw error;
|
||||
const surveyResponses = await getResponses(surveyId, limit, offset);
|
||||
allResponses.push(...surveyResponses);
|
||||
} else {
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
|
||||
allResponses.push(...environmentResponses);
|
||||
}
|
||||
},
|
||||
});
|
||||
return responses.successResponse(allResponses);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const validateInput = async (request: Request) => {
|
||||
let jsonInput;
|
||||
@@ -105,18 +92,19 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
|
||||
return { survey };
|
||||
};
|
||||
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
auditLog,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
auditLog: TApiAuditLog;
|
||||
authentication: NonNullable<TApiKeyAuthentication>;
|
||||
}) => {
|
||||
export const POST = withApiLogging(
|
||||
async (request: Request, _, auditLog: ApiAuditLog) => {
|
||||
try {
|
||||
const inputResult = await validateInput(req);
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
const inputResult = await validateInput(request);
|
||||
if (inputResult.error) {
|
||||
return {
|
||||
response: inputResult.error,
|
||||
@@ -157,14 +145,16 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.successResponse(response, true),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error in POST /api/v1/management/responses");
|
||||
|
||||
if (error instanceof InvalidInputError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
} else if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error.message),
|
||||
};
|
||||
@@ -178,6 +168,6 @@ export const POST = withV1ApiWrapper({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
action: "created",
|
||||
targetType: "response",
|
||||
});
|
||||
"created",
|
||||
"response"
|
||||
);
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { Session } from "next-auth";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { vi } from "vitest";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { checkAuth, checkForRequiredFields } from "./utils";
|
||||
import { checkForRequiredFields } from "./utils";
|
||||
import { checkAuth } from "./utils";
|
||||
|
||||
// Create mock response objects
|
||||
const mockBadRequestResponse = new Response("Bad Request", { status: 400 });
|
||||
const mockNotAuthenticatedResponse = new Response("Not authenticated", { status: 401 });
|
||||
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
|
||||
|
||||
vi.mock("@/app/api/v1/auth", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
}));
|
||||
@@ -72,22 +80,19 @@ describe("checkForRequiredFields", () => {
|
||||
|
||||
describe("checkAuth", () => {
|
||||
const environmentId = "env-123";
|
||||
const mockRequest = new NextRequest("http://localhost:3000/api/test");
|
||||
|
||||
test("returns notAuthenticatedResponse when authentication is null", async () => {
|
||||
const result = await checkAuth(null, environmentId);
|
||||
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockNotAuthenticatedResponse);
|
||||
});
|
||||
|
||||
test("returns notAuthenticatedResponse when authentication is undefined", async () => {
|
||||
const result = await checkAuth(undefined as any, environmentId);
|
||||
|
||||
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockNotAuthenticatedResponse);
|
||||
});
|
||||
|
||||
test("returns unauthorizedResponse when API key authentication lacks POST permission", async () => {
|
||||
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
|
||||
const mockAuthentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
@@ -107,10 +112,12 @@ describe("checkAuth", () => {
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||
vi.mocked(hasPermission).mockReturnValue(false);
|
||||
|
||||
const result = await checkAuth(mockAuthentication, environmentId);
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(hasPermission).toHaveBeenCalledWith(
|
||||
mockAuthentication.environmentPermissions,
|
||||
environmentId,
|
||||
@@ -120,7 +127,7 @@ describe("checkAuth", () => {
|
||||
expect(result).toBe(mockUnauthorizedResponse);
|
||||
});
|
||||
|
||||
test("returns undefined when API key authentication has POST permission", async () => {
|
||||
test("returns undefined when no session and authentication has POST permission", async () => {
|
||||
const mockAuthentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
@@ -140,10 +147,12 @@ describe("checkAuth", () => {
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||
vi.mocked(hasPermission).mockReturnValue(true);
|
||||
|
||||
const result = await checkAuth(mockAuthentication, environmentId);
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(hasPermission).toHaveBeenCalledWith(
|
||||
mockAuthentication.environmentPermissions,
|
||||
environmentId,
|
||||
@@ -162,7 +171,7 @@ describe("checkAuth", () => {
|
||||
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
|
||||
|
||||
const result = await checkAuth(mockSession, environmentId);
|
||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
@@ -179,18 +188,25 @@ describe("checkAuth", () => {
|
||||
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
|
||||
const result = await checkAuth(mockSession, environmentId);
|
||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns notAuthenticatedResponse when authentication object is neither session nor API key", async () => {
|
||||
const invalidAuth = { someProperty: "value" } as any;
|
||||
test("does not call authenticateRequest when session exists", async () => {
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
expires: "2024-12-31T23:59:59.999Z",
|
||||
};
|
||||
|
||||
const result = await checkAuth(invalidAuth, environmentId);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
|
||||
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockNotAuthenticatedResponse);
|
||||
await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).not.toHaveBeenCalled();
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TApiV1Authentication } from "@/app/lib/api/with-api-logging";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { Session } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const checkForRequiredFields = (
|
||||
environmentId: string,
|
||||
@@ -21,21 +23,19 @@ export const checkForRequiredFields = (
|
||||
}
|
||||
};
|
||||
|
||||
export const checkAuth = async (authentication: TApiV1Authentication, environmentId: string) => {
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => {
|
||||
if (!session) {
|
||||
//check whether its using API key
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
if ("user" in authentication) {
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
} else if ("hashedApiKey" in authentication) {
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
} else {
|
||||
return responses.notAuthenticatedResponse();
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user