mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 08:20:29 -06:00
Compare commits
1 Commits
add-cursor
...
fix-bring-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe07a0689c |
@@ -1,3 +0,0 @@
|
||||
Build the full app and if you run into any errors, fix them and build again until the app builds.
|
||||
|
||||
Complete when: This is complete only when the app build fully.
|
||||
@@ -1 +0,0 @@
|
||||
Open a draft PR with a concise description of what we’ve done in this PR and what remains to be done.
|
||||
@@ -1,8 +0,0 @@
|
||||
Run the unit tests of all files we’ve changed so far:
|
||||
|
||||
1. Check the diff to main
|
||||
2. Determine all files that we changed
|
||||
3. Run the corresponding unit tests
|
||||
4. If tests are failing, update the unit test following the guideline in the corresponding cursor rule.
|
||||
|
||||
Complete when: This is complete when all unit tests pass.
|
||||
@@ -1 +0,0 @@
|
||||
Run /unit-test and if complete /build and if complete /pr
|
||||
152
.cursor/rules/eks-alb-optimization.mdc
Normal file
152
.cursor/rules/eks-alb-optimization.mdc
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# EKS & ALB Optimization Guide for Error Reduction
|
||||
|
||||
## Infrastructure Overview
|
||||
|
||||
This project uses AWS EKS with Application Load Balancer (ALB) for the Formbricks application. The infrastructure has been optimized to minimize ELB 502/504 errors through careful configuration of connection handling, health checks, and pod lifecycle management.
|
||||
|
||||
## Key Infrastructure Files
|
||||
|
||||
### Terraform Configuration
|
||||
- **Main Infrastructure**: [infra/terraform/main.tf](mdc:infra/terraform/main.tf) - EKS cluster, VPC, Karpenter, and core AWS resources
|
||||
- **Monitoring**: [infra/terraform/cloudwatch.tf](mdc:infra/terraform/cloudwatch.tf) - CloudWatch alarms for 502/504 error tracking and alerting
|
||||
- **Database**: [infra/terraform/rds.tf](mdc:infra/terraform/rds.tf) - Aurora PostgreSQL configuration
|
||||
|
||||
### Helm Configuration
|
||||
- **Production**: [infra/formbricks-cloud-helm/values.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values.yaml.gotmpl) - Optimized ALB and pod configurations
|
||||
- **Staging**: [infra/formbricks-cloud-helm/values-staging.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values-staging.yaml.gotmpl) - Staging environment with spot instances
|
||||
- **Deployment**: [infra/formbricks-cloud-helm/helmfile.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/helmfile.yaml.gotmpl) - Multi-environment Helm releases
|
||||
|
||||
## ALB Optimization Patterns
|
||||
|
||||
### Connection Handling Optimizations
|
||||
```yaml
|
||||
# Key ALB annotations for reducing 502/504 errors
|
||||
alb.ingress.kubernetes.io/load-balancer-attributes: |
|
||||
idle_timeout.timeout_seconds=120,
|
||||
connection_logs.s3.enabled=false,
|
||||
access_logs.s3.enabled=false
|
||||
|
||||
alb.ingress.kubernetes.io/target-group-attributes: |
|
||||
deregistration_delay.timeout_seconds=30,
|
||||
stickiness.enabled=false,
|
||||
load_balancing.algorithm.type=least_outstanding_requests,
|
||||
target_group_health.dns_failover.minimum_healthy_targets.count=1
|
||||
```
|
||||
|
||||
### Health Check Configuration
|
||||
- **Interval**: 15 seconds for faster detection of unhealthy targets
|
||||
- **Timeout**: 5 seconds to prevent false positives
|
||||
- **Thresholds**: 2 healthy, 3 unhealthy for balanced responsiveness
|
||||
- **Path**: `/health` endpoint optimized for < 100ms response time
|
||||
|
||||
## Pod Lifecycle Management
|
||||
|
||||
### Graceful Shutdown Pattern
|
||||
```yaml
|
||||
# PreStop hook to allow connection draining
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["/bin/sh", "-c", "sleep 15"]
|
||||
|
||||
# Termination grace period for complete cleanup
|
||||
terminationGracePeriodSeconds: 45
|
||||
```
|
||||
|
||||
### Health Probe Strategy
|
||||
- **Startup Probe**: 5s initial delay, 5s interval, max 60s startup time
|
||||
- **Readiness Probe**: 10s delay, 10s interval for traffic readiness
|
||||
- **Liveness Probe**: 30s delay, 30s interval for container health
|
||||
|
||||
### Rolling Update Configuration
|
||||
```yaml
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 25% # Maintain capacity during updates
|
||||
maxSurge: 50% # Allow faster rollouts
|
||||
```
|
||||
|
||||
## Karpenter Node Management
|
||||
|
||||
### Node Lifecycle Optimization
|
||||
- **Startup Taints**: Prevent traffic during node initialization
|
||||
- **Graceful Shutdown**: 30s grace period for pod eviction
|
||||
- **Consolidation Delay**: 60s to reduce unnecessary churn
|
||||
- **Eviction Policies**: Configured for smooth pod migrations
|
||||
|
||||
### Instance Selection
|
||||
- **Families**: c8g, c7g, m8g, m7g, r8g, r7g (ARM64 Graviton)
|
||||
- **Sizes**: 2, 4, 8 vCPUs for cost optimization
|
||||
- **Bottlerocket AMI**: Enhanced security and performance
|
||||
|
||||
## Monitoring & Alerting
|
||||
|
||||
### Critical ALB Metrics
|
||||
1. **ELB 502 Errors**: Threshold 20 over 5 minutes
|
||||
2. **ELB 504 Errors**: Threshold 15 over 5 minutes
|
||||
3. **Target Connection Errors**: Threshold 50 over 5 minutes
|
||||
4. **4XX Errors**: Threshold 100 over 10 minutes (client issues)
|
||||
|
||||
### Expected Improvements
|
||||
- **60-80% reduction** in ELB 502 errors
|
||||
- **Faster recovery** during pod restarts
|
||||
- **Better connection reuse** efficiency
|
||||
- **Improved autoscaling** responsiveness
|
||||
|
||||
## Deployment Patterns
|
||||
|
||||
### Infrastructure Updates
|
||||
1. **Terraform First**: Apply infrastructure changes via [infra/deploy-improvements.sh](mdc:infra/deploy-improvements.sh)
|
||||
2. **Helm Second**: Deploy application configurations
|
||||
3. **Verification**: Check pod status, endpoints, and ALB health
|
||||
4. **Monitoring**: Watch CloudWatch metrics for 24-48 hours
|
||||
|
||||
### Environment-Specific Configurations
|
||||
- **Production**: On-demand instances, stricter resource limits
|
||||
- **Staging**: Spot instances, rate limiting disabled, relaxed resources
|
||||
|
||||
## Troubleshooting Patterns
|
||||
|
||||
### 502 Error Investigation
|
||||
1. Check pod readiness and health probe status
|
||||
2. Verify ALB target group health
|
||||
3. Review deregistration timing during deployments
|
||||
4. Monitor connection pool utilization
|
||||
|
||||
### 504 Error Analysis
|
||||
1. Check application response times
|
||||
2. Verify timeout configurations (ALB: 120s, App: aligned)
|
||||
3. Review database query performance
|
||||
4. Monitor resource utilization during traffic spikes
|
||||
|
||||
### Connection Error Patterns
|
||||
1. Verify Karpenter node lifecycle timing
|
||||
2. Check pod termination grace periods
|
||||
3. Review ALB connection draining settings
|
||||
4. Monitor cluster autoscaling events
|
||||
|
||||
## Best Practices
|
||||
|
||||
### When Making Changes
|
||||
- **Test in staging first** with same configurations
|
||||
- **Monitor metrics** for 24-48 hours after changes
|
||||
- **Use gradual rollouts** with proper health checks
|
||||
- **Maintain ALB timeout alignment** across all layers
|
||||
|
||||
### Performance Optimization
|
||||
- **Health endpoint** should respond < 100ms consistently
|
||||
- **Connection pooling** aligned with ALB idle timeouts
|
||||
- **Resource requests/limits** tuned for consistent performance
|
||||
- **Graceful shutdown** implemented in application code
|
||||
|
||||
### Monitoring Strategy
|
||||
- **Real-time alerts** for error rate spikes
|
||||
- **Trend analysis** for connection patterns
|
||||
- **Capacity planning** based on LCU usage
|
||||
- **4XX pattern analysis** for client behavior insights
|
||||
165
.github/workflows/pr-size-check.yml
vendored
165
.github/workflows/pr-size-check.yml
vendored
@@ -1,165 +0,0 @@
|
||||
name: PR Size Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-pr-size:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check PR size
|
||||
id: check-size
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Fetch the base branch
|
||||
git fetch origin "${{ github.base_ref }}"
|
||||
|
||||
# Get diff stats
|
||||
diff_output=$(git diff --numstat "origin/${{ github.base_ref }}"...HEAD)
|
||||
|
||||
# Count lines, excluding:
|
||||
# - Test files (*.test.ts, *.spec.tsx, etc.)
|
||||
# - Locale files (locales/*.json, i18n/*.json)
|
||||
# - Lock files (pnpm-lock.yaml, package-lock.json, yarn.lock)
|
||||
# - Generated files (dist/, coverage/, build/, .next/)
|
||||
# - Storybook stories (*.stories.tsx)
|
||||
|
||||
total_additions=0
|
||||
total_deletions=0
|
||||
counted_files=0
|
||||
excluded_files=0
|
||||
|
||||
while IFS=$'\t' read -r additions deletions file; do
|
||||
# Skip if additions or deletions are "-" (binary files)
|
||||
if [ "$additions" = "-" ] || [ "$deletions" = "-" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if file should be excluded
|
||||
case "$file" in
|
||||
*.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx|*.test.js|*.test.jsx|*.spec.js|*.spec.jsx)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
*/locales/*.json|*/i18n/*.json)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
pnpm-lock.yaml|package-lock.json|yarn.lock)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
dist/*|coverage/*|build/*|node_modules/*|test-results/*|playwright-report/*|.next/*|*.tsbuildinfo)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
*.stories.ts|*.stories.tsx|*.stories.js|*.stories.jsx)
|
||||
excluded_files=$((excluded_files + 1))
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
total_additions=$((total_additions + additions))
|
||||
total_deletions=$((total_deletions + deletions))
|
||||
counted_files=$((counted_files + 1))
|
||||
done <<EOF
|
||||
${diff_output}
|
||||
EOF
|
||||
|
||||
total_changes=$((total_additions + total_deletions))
|
||||
|
||||
echo "counted_files=${counted_files}" >> "${GITHUB_OUTPUT}"
|
||||
echo "excluded_files=${excluded_files}" >> "${GITHUB_OUTPUT}"
|
||||
echo "total_additions=${total_additions}" >> "${GITHUB_OUTPUT}"
|
||||
echo "total_deletions=${total_deletions}" >> "${GITHUB_OUTPUT}"
|
||||
echo "total_changes=${total_changes}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Set flag if PR is too large (> 800 lines)
|
||||
if [ ${total_changes} -gt 800 ]; then
|
||||
echo "is_too_large=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "is_too_large=false" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
- name: Comment on PR if too large
|
||||
if: steps.check-size.outputs.is_too_large == 'true'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const totalChanges = ${{ steps.check-size.outputs.total_changes }};
|
||||
const countedFiles = ${{ steps.check-size.outputs.counted_files }};
|
||||
const excludedFiles = ${{ steps.check-size.outputs.excluded_files }};
|
||||
const additions = ${{ steps.check-size.outputs.total_additions }};
|
||||
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
||||
|
||||
const body = `## 🚨 PR Size Warning
|
||||
|
||||
This PR has approximately **${totalChanges} lines** of changes (${additions} additions, ${deletions} deletions across ${countedFiles} files).
|
||||
|
||||
Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.
|
||||
|
||||
### 💡 Suggestions:
|
||||
- **Split by feature or module** - Break down into logical, independent pieces
|
||||
- **Create a sequence of PRs** - Each building on the previous one
|
||||
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
|
||||
|
||||
### 📊 What was counted:
|
||||
- ✅ Source files, stylesheets, configuration files
|
||||
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
|
||||
|
||||
### 📚 Guidelines:
|
||||
- **Ideal:** 300-500 lines per PR
|
||||
- **Warning:** 500-800 lines
|
||||
- **Critical:** 800+ lines ⚠️
|
||||
|
||||
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.`;
|
||||
|
||||
// Check if we already commented
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('🚨 PR Size Warning')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: body
|
||||
});
|
||||
} else {
|
||||
// Create new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
86
.github/workflows/terraform-plan-and-apply.yml
vendored
Normal file
86
.github/workflows/terraform-plan-and-apply.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
name: "Terraform"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# TODO: enable it back when migration is completed.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
|
||||
- name: Terraform Format
|
||||
id: fmt
|
||||
run: terraform fmt -check -recursive
|
||||
continue-on-error: true
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Init
|
||||
id: init
|
||||
run: terraform init
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Validate
|
||||
id: validate
|
||||
run: terraform validate
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Terraform Plan
|
||||
id: plan
|
||||
run: terraform plan -out .planfile
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Post PR comment
|
||||
uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1
|
||||
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
planfile: .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
- name: Terraform Apply
|
||||
id: apply
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: terraform apply .planfile
|
||||
working-directory: "infra/terraform"
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -56,6 +56,19 @@ packages/database/migrations
|
||||
branch.json
|
||||
.vercel
|
||||
|
||||
# Terraform
|
||||
infra/terraform/.terraform/
|
||||
**/.terraform.lock.hcl
|
||||
**/terraform.tfstate
|
||||
**/terraform.tfstate.*
|
||||
**/crash.log
|
||||
**/override.tf
|
||||
**/override.tf.json
|
||||
**/*.tfvars
|
||||
**/*.tfvars.json
|
||||
**/.terraformrc
|
||||
**/terraform.rc
|
||||
|
||||
# IntelliJ IDEA
|
||||
/.idea/
|
||||
/*.iml
|
||||
|
||||
@@ -105,7 +105,7 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
subheader: t("templates.star_rating_survey_question_2_html"),
|
||||
html: t("templates.star_rating_survey_question_2_html"),
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -322,7 +322,7 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
subheader: t("templates.smileys_survey_question_2_html"),
|
||||
html: t("templates.smileys_survey_question_2_html"),
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
@@ -21,21 +36,6 @@ import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
@@ -60,7 +60,7 @@ export const MainNavigation = ({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslate();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
TIntegrationAirtableTables,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
@@ -118,9 +117,7 @@ const renderQuestionSelection = ({
|
||||
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
</span>
|
||||
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
TIntegrationGoogleSheetsInput,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
|
||||
import {
|
||||
@@ -277,7 +276,7 @@ export const AddIntegrationModal = ({
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2 w-[30rem] truncate">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
{getLocalizedValue(question.headline, "default")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
TIntegrationSlackInput,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import SlackLogo from "@/images/slacklogo.png";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
@@ -282,9 +281,7 @@ export const AddChannelMappingModal = ({
|
||||
handleCheckboxChange(question.id);
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
</span>
|
||||
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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 { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
@@ -55,9 +54,7 @@ const getQuestionColumnsData = (
|
||||
|
||||
// Helper function to get localized question headline
|
||||
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
|
||||
return getTextContent(
|
||||
getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default")
|
||||
);
|
||||
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
|
||||
};
|
||||
|
||||
// Helper function to render choice ID badges
|
||||
@@ -86,7 +83,7 @@ const getQuestionColumnsData = (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
|
||||
<span className="truncate">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default")) +
|
||||
{getLocalizedValue(question.headline, "default") +
|
||||
" - " +
|
||||
getLocalizedValue(matrixRow.label, "default")}
|
||||
</span>
|
||||
@@ -202,11 +199,9 @@ const getQuestionColumnsData = (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[question.type]}</span>
|
||||
<span className="truncate">
|
||||
{getTextContent(
|
||||
getLocalizedValue(
|
||||
recallToHeadline(question.headline, survey, false, "default"),
|
||||
"default"
|
||||
)
|
||||
{getLocalizedValue(
|
||||
recallToHeadline(question.headline, survey, false, "default"),
|
||||
"default"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
@@ -31,9 +30,7 @@ export const QuestionSummaryHeader = ({
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
||||
{formatTextWithSlashes(
|
||||
getTextContent(
|
||||
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
|
||||
),
|
||||
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"],
|
||||
"@",
|
||||
["text-lg"]
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
@@ -260,7 +259,7 @@ export const getSurveySummaryDropOff = (
|
||||
return {
|
||||
questionId: question.id,
|
||||
questionType: question.type,
|
||||
headline: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
headline: getLocalizedValue(question.headline, "default"),
|
||||
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
|
||||
impressions: impressionsArr[index] || 0,
|
||||
dropOffCount: dropOffArr[index] || 0,
|
||||
|
||||
@@ -22,7 +22,7 @@ const mockSurvey: TSurvey = {
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: { default: "Welcome" },
|
||||
subheader: { default: "" },
|
||||
html: { default: "" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
buttonLabel: { default: "Start" },
|
||||
|
||||
@@ -91,7 +91,7 @@ export const mockSurvey: TSurvey = {
|
||||
createdBy: "cm98dg3xm000019hpubj39vfi",
|
||||
status: "inProgress",
|
||||
welcomeCard: {
|
||||
subheader: {
|
||||
html: {
|
||||
default: "Thanks for providing your feedback - let's go!",
|
||||
},
|
||||
enabled: false,
|
||||
@@ -168,7 +168,6 @@ export const mockSurvey: TSurvey = {
|
||||
triggers: [],
|
||||
segment: null,
|
||||
followUps: mockFollowUps,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
export const mockContactQuestion: TSurveyContactInfoQuestion = {
|
||||
|
||||
@@ -62,7 +62,6 @@ const baseSurvey: TSurvey = {
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
pin: null,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const attributes: TAttributes = {
|
||||
@@ -103,7 +102,7 @@ describe("replaceAttributeRecall", () => {
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome, recall:name!" },
|
||||
subheader: { default: "<p>Some content</p>" },
|
||||
html: { default: "<p>Some content</p>" },
|
||||
buttonLabel: { default: "Start" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
@@ -207,7 +206,7 @@ describe("replaceAttributeRecall", () => {
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome!" },
|
||||
subheader: { default: "<p>Some content</p>" },
|
||||
html: { default: "<p>Some content</p>" },
|
||||
buttonLabel: { default: "Start" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
|
||||
@@ -313,7 +313,6 @@ describe("Survey Builder", () => {
|
||||
test("creates a consent question with required fields", () => {
|
||||
const question = buildConsentQuestion({
|
||||
headline: "Consent Question",
|
||||
subheader: "",
|
||||
label: "I agree to terms",
|
||||
t: mockT,
|
||||
});
|
||||
@@ -321,7 +320,6 @@ describe("Survey Builder", () => {
|
||||
expect(question).toMatchObject({
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
headline: { default: "Consent Question" },
|
||||
subheader: { default: "" },
|
||||
label: { default: "I agree to terms" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
@@ -369,7 +367,6 @@ describe("Survey Builder", () => {
|
||||
test("creates a CTA question with required fields", () => {
|
||||
const question = buildCTAQuestion({
|
||||
headline: "CTA Question",
|
||||
subheader: "",
|
||||
buttonExternal: false,
|
||||
t: mockT,
|
||||
});
|
||||
@@ -377,7 +374,6 @@ describe("Survey Builder", () => {
|
||||
expect(question).toMatchObject({
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "CTA Question" },
|
||||
subheader: { default: "" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
@@ -402,7 +398,7 @@ describe("Survey Builder", () => {
|
||||
const question = buildCTAQuestion({
|
||||
id: "custom-id",
|
||||
headline: "CTA Question",
|
||||
subheader: "<p>Click the button</p>",
|
||||
html: "<p>Click the button</p>",
|
||||
buttonLabel: "Click me",
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://example.com",
|
||||
@@ -414,7 +410,7 @@ describe("Survey Builder", () => {
|
||||
});
|
||||
|
||||
expect(question.id).toBe("custom-id");
|
||||
expect(question.subheader).toEqual({ default: "<p>Click the button</p>" });
|
||||
expect(question.html).toEqual({ default: "<p>Click the button</p>" });
|
||||
expect(question.buttonLabel).toEqual({ default: "Click me" });
|
||||
expect(question.buttonExternal).toBe(true);
|
||||
expect(question.buttonUrl).toBe("https://example.com");
|
||||
@@ -427,7 +423,6 @@ describe("Survey Builder", () => {
|
||||
test("handles external button with URL", () => {
|
||||
const question = buildCTAQuestion({
|
||||
headline: "CTA Question",
|
||||
subheader: "",
|
||||
buttonExternal: true,
|
||||
buttonUrl: "https://formbricks.com",
|
||||
t: mockT,
|
||||
@@ -538,7 +533,7 @@ describe("Helper Functions", () => {
|
||||
const card = getDefaultWelcomeCard(mockT);
|
||||
expect(card.enabled).toBe(false);
|
||||
expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" });
|
||||
expect(card.subheader).toEqual({ default: "templates.default_welcome_card_html" });
|
||||
expect(card.html).toEqual({ default: "templates.default_welcome_card_html" });
|
||||
expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" });
|
||||
// boolean flags
|
||||
expect(card.timeToFinish).toBe(false);
|
||||
|
||||
@@ -218,7 +218,7 @@ export const buildConsentQuestion = ({
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
subheader: string;
|
||||
subheader?: string;
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
required?: boolean;
|
||||
@@ -229,7 +229,7 @@ export const buildConsentQuestion = ({
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
subheader: createI18nString(subheader, []),
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
@@ -242,7 +242,7 @@ export const buildConsentQuestion = ({
|
||||
export const buildCTAQuestion = ({
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
html,
|
||||
buttonLabel,
|
||||
buttonExternal,
|
||||
backButtonLabel,
|
||||
@@ -255,7 +255,7 @@ export const buildCTAQuestion = ({
|
||||
id?: string;
|
||||
headline: string;
|
||||
buttonExternal: boolean;
|
||||
subheader: string;
|
||||
html?: string;
|
||||
buttonLabel?: string;
|
||||
backButtonLabel?: string;
|
||||
required?: boolean;
|
||||
@@ -267,7 +267,7 @@ export const buildCTAQuestion = ({
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
subheader: createI18nString(subheader, []),
|
||||
html: html ? createI18nString(html, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
@@ -364,7 +364,7 @@ export const getDefaultWelcomeCard = (t: TFnType): TSurveyWelcomeCard => {
|
||||
return {
|
||||
enabled: false,
|
||||
headline: createI18nString(t("templates.default_welcome_card_headline"), []),
|
||||
subheader: createI18nString(t("templates.default_welcome_card_html"), []),
|
||||
html: createI18nString(t("templates.default_welcome_card_html"), []),
|
||||
buttonLabel: createI18nString(t("templates.default_welcome_card_button_label"), []),
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
TSurveyMetaFieldFilter,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
DateRange,
|
||||
@@ -19,8 +18,6 @@ import {
|
||||
QuestionOptions,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
|
||||
const conditionOptions = {
|
||||
openText: ["is"],
|
||||
@@ -83,9 +80,7 @@ export const generateQuestionAndFilterOptions = (
|
||||
survey.questions.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
questionsOptions.push({
|
||||
label: getTextContent(
|
||||
getLocalizedValue(recallToHeadline(q.headline, survey, false, "default"), "default")
|
||||
),
|
||||
label: q.headline,
|
||||
questionType: q.type,
|
||||
type: OptionsType.QUESTIONS,
|
||||
id: q.id,
|
||||
|
||||
@@ -32,7 +32,7 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
|
||||
questions: [
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
subheader: t("templates.card_abandonment_survey_question_1_html"),
|
||||
html: t("templates.card_abandonment_survey_question_1_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||
headline: t("templates.card_abandonment_survey_question_1_headline"),
|
||||
required: false,
|
||||
@@ -92,7 +92,6 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
|
||||
id: reusableQuestionIds[1],
|
||||
logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")],
|
||||
headline: t("templates.card_abandonment_survey_question_6_headline"),
|
||||
subheader: "",
|
||||
required: false,
|
||||
label: t("templates.card_abandonment_survey_question_6_label"),
|
||||
t,
|
||||
@@ -134,7 +133,7 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
|
||||
questions: [
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
subheader: t("templates.site_abandonment_survey_question_1_html"),
|
||||
html: t("templates.site_abandonment_survey_question_1_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||
headline: t("templates.site_abandonment_survey_question_2_headline"),
|
||||
required: false,
|
||||
@@ -193,7 +192,6 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
|
||||
id: reusableQuestionIds[1],
|
||||
logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")],
|
||||
headline: t("templates.site_abandonment_survey_question_7_headline"),
|
||||
subheader: "",
|
||||
required: false,
|
||||
label: t("templates.site_abandonment_survey_question_7_label"),
|
||||
t,
|
||||
@@ -233,7 +231,7 @@ const productMarketFitSuperhuman = (t: TFnType): TTemplate => {
|
||||
questions: [
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
subheader: t("templates.product_market_fit_superhuman_question_1_html"),
|
||||
html: t("templates.product_market_fit_superhuman_question_1_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||
headline: t("templates.product_market_fit_superhuman_question_1_headline"),
|
||||
required: false,
|
||||
@@ -411,7 +409,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
subheader: t("templates.churn_survey_question_3_html"),
|
||||
html: t("templates.churn_survey_question_3_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isClicked")],
|
||||
headline: t("templates.churn_survey_question_3_headline"),
|
||||
required: true,
|
||||
@@ -431,7 +429,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[4],
|
||||
subheader: t("templates.churn_survey_question_5_html"),
|
||||
html: t("templates.churn_survey_question_5_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[4], localSurvey.endings[0].id, "isClicked")],
|
||||
headline: t("templates.churn_survey_question_5_headline"),
|
||||
required: true,
|
||||
@@ -709,7 +707,7 @@ const improveTrialConversion = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[3],
|
||||
subheader: t("templates.improve_trial_conversion_question_4_html"),
|
||||
html: t("templates.improve_trial_conversion_question_4_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[3], localSurvey.endings[0].id, "isClicked")],
|
||||
headline: t("templates.improve_trial_conversion_question_4_headline"),
|
||||
required: true,
|
||||
@@ -804,7 +802,7 @@ const reviewPrompt = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
subheader: t("templates.review_prompt_question_2_html"),
|
||||
html: t("templates.review_prompt_question_2_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isClicked")],
|
||||
headline: t("templates.review_prompt_question_2_headline"),
|
||||
required: true,
|
||||
@@ -842,7 +840,7 @@ const interviewPrompt = (t: TFnType): TTemplate => {
|
||||
buildCTAQuestion({
|
||||
id: createId(),
|
||||
headline: t("templates.interview_prompt_question_1_headline"),
|
||||
subheader: t("templates.interview_prompt_question_1_html"),
|
||||
html: t("templates.interview_prompt_question_1_html"),
|
||||
buttonLabel: t("templates.interview_prompt_question_1_button_label"),
|
||||
buttonUrl: "https://cal.com/johannes",
|
||||
buttonExternal: true,
|
||||
@@ -1345,7 +1343,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
subheader: t("templates.feedback_box_question_3_html"),
|
||||
html: t("templates.feedback_box_question_3_html"),
|
||||
logic: [
|
||||
createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isClicked"),
|
||||
createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isSkipped"),
|
||||
@@ -2024,7 +2022,6 @@ const marketSiteClarity = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
headline: t("templates.market_site_clarity_question_3_headline"),
|
||||
subheader: "",
|
||||
required: false,
|
||||
buttonLabel: t("templates.market_site_clarity_question_3_button_label"),
|
||||
buttonUrl: "https://app.formbricks.com/auth/signup",
|
||||
@@ -2671,7 +2668,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
|
||||
questions: [
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
subheader: t("templates.identify_sign_up_barriers_question_1_html"),
|
||||
html: t("templates.identify_sign_up_barriers_question_1_html"),
|
||||
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||
headline: t("templates.identify_sign_up_barriers_question_1_headline"),
|
||||
required: false,
|
||||
@@ -2796,7 +2793,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[8],
|
||||
subheader: t("templates.identify_sign_up_barriers_question_9_html"),
|
||||
html: t("templates.identify_sign_up_barriers_question_9_html"),
|
||||
headline: t("templates.identify_sign_up_barriers_question_9_headline"),
|
||||
required: false,
|
||||
buttonUrl: "https://app.formbricks.com/auth/signup",
|
||||
@@ -2968,7 +2965,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
subheader: t("templates.improve_newsletter_content_question_3_html"),
|
||||
html: t("templates.improve_newsletter_content_question_3_html"),
|
||||
headline: t("templates.improve_newsletter_content_question_3_headline"),
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com",
|
||||
@@ -3004,7 +3001,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
|
||||
questions: [
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
subheader: t("templates.evaluate_a_product_idea_question_1_html"),
|
||||
html: t("templates.evaluate_a_product_idea_question_1_html"),
|
||||
headline: t("templates.evaluate_a_product_idea_question_1_headline"),
|
||||
required: true,
|
||||
buttonLabel: t("templates.evaluate_a_product_idea_question_1_button_label"),
|
||||
@@ -3037,7 +3034,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[3],
|
||||
subheader: t("templates.evaluate_a_product_idea_question_4_html"),
|
||||
html: t("templates.evaluate_a_product_idea_question_4_html"),
|
||||
headline: t("templates.evaluate_a_product_idea_question_4_headline"),
|
||||
required: true,
|
||||
buttonLabel: t("templates.evaluate_a_product_idea_question_4_button_label"),
|
||||
|
||||
@@ -168,6 +168,12 @@ export const createEnvironment = async (
|
||||
description: "Your contact's last name",
|
||||
type: "default",
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
name: "Language",
|
||||
description: "The language preference of a contact",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,9 +16,7 @@ import {
|
||||
TSurveyQuestion,
|
||||
TSurveyRankingQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { processResponseData } from "../responses";
|
||||
import { getTodaysDateTimeFormatted } from "../time";
|
||||
import { getFormattedDateTimeString } from "../utils/datetime";
|
||||
@@ -661,13 +659,11 @@ export const extracMetadataKeys = (obj: TResponse["meta"]) => {
|
||||
|
||||
export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) => {
|
||||
const metaDataFields = responses.length > 0 ? extracMetadataKeys(responses[0].meta) : [];
|
||||
const modifiedSurvey = replaceHeadlineRecall(survey, "default");
|
||||
|
||||
const questions = modifiedSurvey.questions.map((question, idx) => {
|
||||
const headline = getTextContent(getLocalizedValue(question.headline, "default")) ?? question.id;
|
||||
const questions = survey.questions.map((question, idx) => {
|
||||
const headline = getLocalizedValue(question.headline, "default") ?? question.id;
|
||||
if (question.type === "matrix") {
|
||||
return question.rows.map((row) => {
|
||||
return `${idx + 1}. ${headline} - ${getTextContent(getLocalizedValue(row.label, "default"))}`;
|
||||
return `${idx + 1}. ${headline} - ${getLocalizedValue(row.label, "default")}`;
|
||||
});
|
||||
} else if (
|
||||
question.type === "multipleChoiceMulti" ||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
|
||||
|
||||
@@ -46,11 +45,9 @@ export const getQuestionResponseMapping = (
|
||||
const answer = response.data[question.id];
|
||||
|
||||
questionResponseMapping.push({
|
||||
question: getTextContent(
|
||||
parseRecallInfo(
|
||||
getLocalizedValue(question.headline, responseLanguageCode ?? "default"),
|
||||
response.data
|
||||
)
|
||||
question: parseRecallInfo(
|
||||
getLocalizedValue(question.headline, responseLanguageCode ?? "default"),
|
||||
response.data
|
||||
),
|
||||
response: convertResponseValue(answer, question),
|
||||
type: question.type,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import {
|
||||
checkForEmptyFallBackValue,
|
||||
@@ -21,11 +22,9 @@ import {
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (obj: any, lang: string) => {
|
||||
if (typeof obj === "string") return obj;
|
||||
if (!obj) return "";
|
||||
return obj[lang] || obj["default"] || "";
|
||||
},
|
||||
getLocalizedValue: vi.fn().mockImplementation((obj, lang) => {
|
||||
return typeof obj === "string" ? obj : obj[lang] || obj["default"] || "";
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
||||
@@ -143,12 +142,12 @@ describe("recall utility functions", () => {
|
||||
describe("recallToHeadline", () => {
|
||||
test("converts recall pattern to headline format without slash", () => {
|
||||
const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
|
||||
const survey = {
|
||||
const survey: TSurvey = {
|
||||
id: "test-survey",
|
||||
questions: [{ id: "product", headline: { en: "Product Question" } }],
|
||||
questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as any;
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = recallToHeadline(headline, survey, false, "en");
|
||||
expect(result.en).toBe("How do you like @Product Question?");
|
||||
@@ -156,12 +155,12 @@ describe("recall utility functions", () => {
|
||||
|
||||
test("converts recall pattern to headline format with slash", () => {
|
||||
const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" };
|
||||
const survey = {
|
||||
const survey: TSurvey = {
|
||||
id: "test-survey",
|
||||
questions: [{ id: "product", headline: { en: "Product Question" } }],
|
||||
questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as any;
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = recallToHeadline(headline, survey, true, "en");
|
||||
expect(result.en).toBe("Rate /Product Question\\");
|
||||
@@ -205,12 +204,15 @@ describe("recall utility functions", () => {
|
||||
const headline = {
|
||||
en: "This is #recall:inner/fallback:fallback2#",
|
||||
};
|
||||
const survey = {
|
||||
const survey: TSurvey = {
|
||||
id: "test-survey",
|
||||
questions: [{ id: "inner", headline: { en: "Inner with @outer" } }],
|
||||
questions: [
|
||||
{ id: "inner", headline: { en: "Inner with @outer" } },
|
||||
{ id: "inner", headline: { en: "Inner value" } },
|
||||
] as unknown as TSurveyQuestion[],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
variables: [],
|
||||
} as any;
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const result = recallToHeadline(headline, survey, false, "en");
|
||||
expect(result.en).toBe("This is @Inner with @outer");
|
||||
@@ -240,14 +242,16 @@ describe("recall utility functions", () => {
|
||||
describe("checkForEmptyFallBackValue", () => {
|
||||
test("identifies question with empty fallback value", () => {
|
||||
const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
|
||||
const survey = {
|
||||
const survey: TSurvey = {
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
] as unknown as TSurveyQuestion[],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
|
||||
|
||||
const result = checkForEmptyFallBackValue(survey, "en");
|
||||
expect(result).toBe(survey.questions[0]);
|
||||
@@ -255,15 +259,17 @@ describe("recall utility functions", () => {
|
||||
|
||||
test("identifies question with empty fallback in subheader", () => {
|
||||
const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
|
||||
const survey = {
|
||||
const survey: TSurvey = {
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: { en: "Normal question" },
|
||||
subheader: questionSubheader,
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
] as unknown as TSurveyQuestion[],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionSubheader.en);
|
||||
|
||||
const result = checkForEmptyFallBackValue(survey, "en");
|
||||
expect(result).toBe(survey.questions[0]);
|
||||
@@ -271,14 +277,16 @@ describe("recall utility functions", () => {
|
||||
|
||||
test("returns null when no empty fallback values are found", () => {
|
||||
const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
|
||||
const survey = {
|
||||
const survey: TSurvey = {
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
headline: questionHeadline,
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
] as unknown as TSurveyQuestion[],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
|
||||
|
||||
const result = checkForEmptyFallBackValue(survey, "en");
|
||||
expect(result).toBeNull();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
|
||||
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
|
||||
@@ -60,11 +59,7 @@ const getRecallItemLabel = <T extends TSurvey>(
|
||||
if (isHiddenField) return recallItemId;
|
||||
|
||||
const surveyQuestion = survey.questions.find((question) => question.id === recallItemId);
|
||||
if (surveyQuestion) {
|
||||
const headline = getLocalizedValue(surveyQuestion.headline, languageCode);
|
||||
// Strip HTML tags to prevent raw HTML from showing in nested recalls
|
||||
return headline ? getTextContent(headline) : headline;
|
||||
}
|
||||
if (surveyQuestion) return surveyQuestion.headline[languageCode];
|
||||
|
||||
const variable = survey.variables?.find((variable) => variable.id === recallItemId);
|
||||
if (variable) return variable.name;
|
||||
@@ -131,7 +126,8 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T
|
||||
for (const question of survey.questions) {
|
||||
if (
|
||||
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
|
||||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language)))
|
||||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language))) ||
|
||||
("html" in question && doesTextHaveRecall(getLocalizedValue(question.html, language)))
|
||||
) {
|
||||
return question;
|
||||
}
|
||||
@@ -271,18 +267,3 @@ export const parseRecallInfo = (
|
||||
|
||||
return modifiedText;
|
||||
};
|
||||
|
||||
export const getTextContentWithRecallTruncated = (text: string, maxLength: number = 25): string => {
|
||||
const cleanText = getTextContent(text).replaceAll(/\s+/g, " ").trim();
|
||||
|
||||
if (cleanText.length <= maxLength) {
|
||||
return replaceRecallInfoWithUnderline(cleanText);
|
||||
}
|
||||
|
||||
const recalledCleanText = replaceRecallInfoWithUnderline(cleanText);
|
||||
|
||||
const start = recalledCleanText.slice(0, 10);
|
||||
const end = recalledCleanText.slice(-10);
|
||||
|
||||
return `${start}...${end}`;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { CheckCircle2Icon, ChevronsDownIcon, XCircleIcon } from "lucide-react";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
@@ -73,16 +72,12 @@ export const QuestionSkip = ({
|
||||
{skippedQuestions?.map((questionId) => {
|
||||
return (
|
||||
<p className="my-2" key={questionId}>
|
||||
{getTextContent(
|
||||
parseRecallInfo(
|
||||
getLocalizedValue(
|
||||
questions.find((question) => question.id === questionId)?.headline ?? {
|
||||
default: "",
|
||||
},
|
||||
"default"
|
||||
),
|
||||
responseData
|
||||
)
|
||||
{parseRecallInfo(
|
||||
getLocalizedValue(
|
||||
questions.find((question) => question.id === questionId)!.headline,
|
||||
"default"
|
||||
),
|
||||
responseData
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
@@ -112,16 +107,12 @@ export const QuestionSkip = ({
|
||||
skippedQuestions.map((questionId) => {
|
||||
return (
|
||||
<p className="my-2" key={questionId}>
|
||||
{getTextContent(
|
||||
parseRecallInfo(
|
||||
getLocalizedValue(
|
||||
questions.find((question) => question.id === questionId)?.headline ?? {
|
||||
default: "",
|
||||
},
|
||||
"default"
|
||||
),
|
||||
responseData
|
||||
)
|
||||
{parseRecallInfo(
|
||||
getLocalizedValue(
|
||||
questions.find((question) => question.id === questionId)!.headline,
|
||||
"default"
|
||||
),
|
||||
responseData
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { CheckCircle2Icon } from "lucide-react";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
|
||||
@@ -78,15 +77,13 @@ export const SingleResponseCardBody = ({
|
||||
<div key={`${question.id}`}>
|
||||
{isValidValue(response.data[question.id]) ? (
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-semibold text-slate-600">
|
||||
<p className="mb-1 text-sm text-slate-500">
|
||||
{formatTextWithSlashes(
|
||||
getTextContent(
|
||||
parseRecallInfo(
|
||||
getLocalizedValue(question.headline, "default"),
|
||||
response.data,
|
||||
response.variables,
|
||||
true
|
||||
)
|
||||
parseRecallInfo(
|
||||
getLocalizedValue(question.headline, "default"),
|
||||
response.data,
|
||||
response.variables,
|
||||
true
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("ResponseFeed", () => {
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: "",
|
||||
subheader: "",
|
||||
html: "",
|
||||
},
|
||||
displayLimit: null,
|
||||
autoComplete: null,
|
||||
|
||||
@@ -319,6 +319,51 @@ describe("createContactsFromCSV", () => {
|
||||
createContactsFromCSV(csvData, environmentId, "skip", { email: "email", name: "name" })
|
||||
).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("handles language attribute key like other default attributes", async () => {
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
|
||||
{ key: "email", id: "id-email" },
|
||||
{ key: "userId", id: "id-userId" },
|
||||
{ key: "firstName", id: "id-firstName" },
|
||||
{ key: "lastName", id: "id-lastName" },
|
||||
{ key: "language", id: "id-language" },
|
||||
] as any);
|
||||
vi.mocked(prisma.contact.create).mockResolvedValue({
|
||||
id: "c1",
|
||||
environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "john@example.com" },
|
||||
{ attributeKey: { key: "userId" }, value: "user123" },
|
||||
{ attributeKey: { key: "firstName" }, value: "John" },
|
||||
{ attributeKey: { key: "lastName" }, value: "Doe" },
|
||||
{ attributeKey: { key: "language" }, value: "en" },
|
||||
],
|
||||
} as any);
|
||||
const csvData = [
|
||||
{
|
||||
email: "john@example.com",
|
||||
userId: "user123",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
language: "en",
|
||||
},
|
||||
];
|
||||
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
|
||||
email: "email",
|
||||
userId: "userId",
|
||||
firstName: "firstName",
|
||||
lastName: "lastName",
|
||||
language: "language",
|
||||
});
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result[0].id).toBe("c1");
|
||||
// language attribute key should already exist, no need to create it
|
||||
expect(prisma.contactAttributeKey.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildContactWhereClause", () => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import DOMPurify from "dompurify";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { TI18nString, TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent, isValidHTML } from "@formbricks/types/surveys/validation";
|
||||
import type { TI18nString, TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { extractLanguageCodes, isLabelValidForAllLanguages } from "@/lib/i18n/utils";
|
||||
import { md } from "@/lib/markdownIt";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
|
||||
import { Editor } from "@/modules/ui/components/editor";
|
||||
import { LanguageIndicator } from "./language-indicator";
|
||||
|
||||
@@ -25,21 +25,17 @@ interface LocalizedEditorProps {
|
||||
setFirstRender?: Dispatch<SetStateAction<boolean>>;
|
||||
locale: TUserLocale;
|
||||
questionId: string;
|
||||
isCard?: boolean; // Flag to indicate if this is a welcome/ending card
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
const checkIfValueIsIncomplete = (
|
||||
id: string,
|
||||
isInvalid: boolean,
|
||||
surveyLanguageCodes: TSurveyLanguage[],
|
||||
surveyLanguageCodes: string[],
|
||||
value?: TI18nString
|
||||
) => {
|
||||
const labelIds = ["subheader", "headline", "html"];
|
||||
const labelIds = ["subheader"];
|
||||
if (value === undefined) return false;
|
||||
const isDefaultIncomplete = labelIds.includes(id)
|
||||
? getTextContent(value.default ?? "").trim() !== ""
|
||||
: false;
|
||||
const isDefaultIncomplete = labelIds.includes(id) ? value.default.trim() !== "" : false;
|
||||
return isInvalid && !isLabelValidForAllLanguages(value, surveyLanguageCodes) && isDefaultIncomplete;
|
||||
};
|
||||
|
||||
@@ -56,76 +52,38 @@ export function LocalizedEditor({
|
||||
setFirstRender,
|
||||
locale,
|
||||
questionId,
|
||||
isCard,
|
||||
autoFocus,
|
||||
}: Readonly<LocalizedEditorProps>) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const surveyLanguageCodes = useMemo(
|
||||
() => extractLanguageCodes(localSurvey.languages),
|
||||
[localSurvey.languages]
|
||||
);
|
||||
const isInComplete = useMemo(
|
||||
() => checkIfValueIsIncomplete(id, isInvalid, localSurvey.languages, value),
|
||||
[id, isInvalid, localSurvey.languages, value]
|
||||
() => checkIfValueIsIncomplete(id, isInvalid, surveyLanguageCodes, value),
|
||||
[id, isInvalid, surveyLanguageCodes, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Editor
|
||||
id={id}
|
||||
disableLists
|
||||
excludedToolbarItems={["blockType"]}
|
||||
firstRender={firstRender}
|
||||
autoFocus={autoFocus}
|
||||
getText={() => {
|
||||
const text = value ? (value[selectedLanguageCode] ?? "") : "";
|
||||
let html = md.render(text);
|
||||
|
||||
// For backwards compatibility: wrap plain text headlines in <strong> tags
|
||||
// This ensures old surveys maintain semibold styling when converted to HTML
|
||||
if (id === "headline" && text && !isValidHTML(text)) {
|
||||
// Use [\s\S]*? to match any character including newlines
|
||||
html = html.replaceAll(/<p>([\s\S]*?)<\/p>/g, "<p><strong>$1</strong></p>");
|
||||
}
|
||||
|
||||
return html;
|
||||
}}
|
||||
key={`${questionId}-${id}-${selectedLanguageCode}`}
|
||||
getText={() => md.render(value ? (value[selectedLanguageCode] ?? "") : "")}
|
||||
key={`${questionIdx}-${selectedLanguageCode}`}
|
||||
setFirstRender={setFirstRender}
|
||||
setText={(v: string) => {
|
||||
// Check if the question still exists before updating
|
||||
const currentQuestion = localSurvey.questions[questionIdx];
|
||||
|
||||
// if this is a card, we wanna check if the card exists in the localSurvey
|
||||
if (isCard) {
|
||||
const isWelcomeCard = questionIdx === -1;
|
||||
const isEndingCard = questionIdx >= localSurvey.questions.length;
|
||||
|
||||
// For ending cards, check if the field exists before updating
|
||||
if (isEndingCard) {
|
||||
const ending = localSurvey.endings.find((ending) => ending.id === questionId);
|
||||
// If the field doesn't exist on the ending card, don't create it
|
||||
if (!ending || ending[id] === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For welcome cards, check if it exists
|
||||
if (isWelcomeCard && !localSurvey.welcomeCard) {
|
||||
if (localSurvey.questions[questionIdx] || questionIdx === -1) {
|
||||
const translatedHtml = {
|
||||
...value,
|
||||
[selectedLanguageCode]: v,
|
||||
};
|
||||
if (questionIdx === -1) {
|
||||
// welcome card
|
||||
updateQuestion({ html: translatedHtml });
|
||||
return;
|
||||
}
|
||||
|
||||
const translatedContent = {
|
||||
...(value ?? {}),
|
||||
[selectedLanguageCode]: v,
|
||||
};
|
||||
updateQuestion({ [id]: translatedContent });
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentQuestion && currentQuestion[id] !== undefined) {
|
||||
const translatedContent = {
|
||||
...(value ?? {}),
|
||||
[selectedLanguageCode]: v,
|
||||
};
|
||||
updateQuestion(questionIdx, { [id]: translatedContent });
|
||||
updateQuestion(questionIdx, { html: translatedHtml });
|
||||
}
|
||||
}}
|
||||
localSurvey={localSurvey}
|
||||
@@ -145,9 +103,14 @@ export function LocalizedEditor({
|
||||
{value && selectedLanguageCode !== "default" && value.default ? (
|
||||
<div className="mt-1 flex text-xs text-gray-500">
|
||||
<strong>{t("environments.project.languages.translate")}:</strong>
|
||||
<span className="ml-1">
|
||||
{getTextContent(recallToHeadline(value, localSurvey, false, "default").default ?? "")}
|
||||
</span>
|
||||
<span
|
||||
className="fb-htmlbody ml-1" // styles are in global.css
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(
|
||||
recallToHeadline(value, localSurvey, false, "default").default ?? ""
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Container } from "@react-email/components";
|
||||
import { Text } from "@react-email/components";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface QuestionHeaderProps {
|
||||
@@ -10,13 +10,11 @@ interface QuestionHeaderProps {
|
||||
export function QuestionHeader({ headline, subheader, className }: QuestionHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Container className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
|
||||
<div dangerouslySetInnerHTML={{ __html: headline }} />
|
||||
</Container>
|
||||
<Text className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
|
||||
{headline}
|
||||
</Text>
|
||||
{subheader && (
|
||||
<Container className="text-question-color m-0 mt-2 block p-0 text-sm font-normal leading-6">
|
||||
<div dangerouslySetInnerHTML={{ __html: subheader }} />
|
||||
</Container>
|
||||
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">{subheader}</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -94,7 +94,16 @@ export async function PreviewEmailTemplate({
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
|
||||
<Container className="text-question-color m-0 text-sm font-normal leading-6">
|
||||
<div
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container className="border-input-border-color bg-input-color rounded-custom m-0 mt-4 block w-full max-w-none border border-solid p-4 font-medium text-slate-800">
|
||||
<Text className="text-question-color m-0 inline-block">
|
||||
{getLocalizedValue(firstQuestion.label, defaultLanguageCode)}
|
||||
@@ -172,7 +181,16 @@ export async function PreviewEmailTemplate({
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
|
||||
<Container className="text-question-color ml-0 mt-2 text-sm font-normal leading-6">
|
||||
<div
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-0 mt-4 max-w-none">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { ReactNode } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
||||
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
@@ -53,7 +53,7 @@ export const FallbackInput = ({
|
||||
return (
|
||||
<div key={recallItem.id} className="flex flex-col gap-1">
|
||||
<Label htmlFor={inputId} className="text-xs font-medium text-slate-700">
|
||||
{getTextContentWithRecallTruncated(recallItem.label)}
|
||||
{replaceRecallInfoWithUnderline(recallItem.label)}
|
||||
</Label>
|
||||
<Input
|
||||
className="h-9 bg-white"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { TI18nString, TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -83,7 +82,7 @@ export const MultiLangWrapper = ({
|
||||
{usedLanguageCode !== "default" && value && typeof value["default"] !== "undefined" && (
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
<strong>{t("environments.project.languages.translate")}:</strong>{" "}
|
||||
{getTextContent(recallToHeadline(value, localSurvey, false, "default")["default"] ?? "")}
|
||||
{recallToHeadline(value, localSurvey, false, "default")["default"]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,26 +11,7 @@ import {
|
||||
import { RecallItemSelect } from "./recall-item-select";
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
getTextContentWithRecallTruncated: vi.fn((text: string, maxLength: number = 25) => {
|
||||
// Remove all HTML tags by repeatedly applying the regex
|
||||
let cleaned = text;
|
||||
let prev;
|
||||
do {
|
||||
prev = cleaned;
|
||||
cleaned = cleaned.replace(/<[^>]*>/g, "");
|
||||
} while (cleaned !== prev);
|
||||
cleaned = cleaned.replace(/\s+/g, " ").trim();
|
||||
|
||||
const withRecallReplaced = cleaned.replace(/#recall:[^#]+#/g, "___");
|
||||
|
||||
if (withRecallReplaced.length <= maxLength) {
|
||||
return withRecallReplaced;
|
||||
}
|
||||
|
||||
const start = withRecallReplaced.slice(0, 10);
|
||||
const end = withRecallReplaced.slice(-10);
|
||||
return `${start}...${end}`;
|
||||
}),
|
||||
replaceRecallInfoWithUnderline: vi.fn((text) => `_${text}_`),
|
||||
}));
|
||||
|
||||
describe("RecallItemSelect", () => {
|
||||
@@ -97,15 +78,15 @@ describe("RecallItemSelect", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Question 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Question 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("hidden1")).toBeInTheDocument();
|
||||
expect(screen.getByText("hidden2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Variable 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Variable 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Question 1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Question 2_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_hidden1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_hidden2_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText("Current Question")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("File Upload Question")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("_Current Question_")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("do not render questions if questionId is 'start' (welcome card)", async () => {
|
||||
@@ -121,16 +102,16 @@ describe("RecallItemSelect", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Question 1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Question 2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("_Question 2_")).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("hidden1")).toBeInTheDocument();
|
||||
expect(screen.getByText("hidden2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Variable 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Variable 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("_hidden1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_hidden2_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText("Current Question")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("File Upload Question")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("_Current Question_")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("filters recall items based on search input", async () => {
|
||||
@@ -150,9 +131,9 @@ describe("RecallItemSelect", () => {
|
||||
const searchInput = screen.getByPlaceholderText("Search options");
|
||||
await user.type(searchInput, "Variable");
|
||||
|
||||
expect(screen.getByText("Variable 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Variable 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Question 1")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
|
||||
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
|
||||
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls addRecallItem and setShowRecallItemSelect when item is selected", async () => {
|
||||
@@ -169,7 +150,7 @@ describe("RecallItemSelect", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const firstItem = screen.getByText("Question 1");
|
||||
const firstItem = screen.getByText("_Question 1_");
|
||||
await user.click(firstItem);
|
||||
|
||||
expect(mockAddRecallItem).toHaveBeenCalledWith({
|
||||
@@ -195,8 +176,8 @@ describe("RecallItemSelect", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Question 1")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Question 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("_Question 2_")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'No recall items found' when search has no results", async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
TSurveyQuestionId,
|
||||
TSurveyRecallItem,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
||||
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -130,7 +130,7 @@ export const RecallItemSelect = ({
|
||||
});
|
||||
|
||||
return filteredQuestions;
|
||||
}, [localSurvey.questions, questionId, recallItemIds, selectedLanguageCode]);
|
||||
}, [localSurvey.questions, questionId, recallItemIds]);
|
||||
|
||||
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
|
||||
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...variableRecallItems].filter(
|
||||
@@ -143,6 +143,11 @@ export const RecallItemSelect = ({
|
||||
);
|
||||
}, [surveyQuestionRecallItems, hiddenFieldRecallItems, variableRecallItems, searchValue]);
|
||||
|
||||
// function to modify headline (recallInfo to corresponding headline)
|
||||
const getRecallLabel = (label: string): string => {
|
||||
return replaceRecallInfoWithUnderline(label);
|
||||
};
|
||||
|
||||
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
|
||||
switch (recallItem.type) {
|
||||
case "question":
|
||||
@@ -207,7 +212,7 @@ export const RecallItemSelect = ({
|
||||
}}>
|
||||
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
|
||||
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{getTextContentWithRecallTruncated(recallItem.label)}
|
||||
{getRecallLabel(recallItem.label)}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -162,26 +162,6 @@ vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipRenderer: ({ children, tooltipContent }: any) => (
|
||||
<span data-tooltip={tooltipContent}>{children}</span>
|
||||
),
|
||||
TooltipProvider: ({ children }: any) => <div>{children}</div>,
|
||||
Tooltip: ({ children }: any) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children, asChild }: any) => (asChild ? children : <div>{children}</div>),
|
||||
TooltipContent: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock LocalizedEditor to render as a simple input for testing
|
||||
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
|
||||
LocalizedEditor: ({ id, value, updateQuestion, questionIdx }: any) => (
|
||||
<input
|
||||
data-testid={id}
|
||||
id={id}
|
||||
defaultValue={value?.default || ""}
|
||||
onChange={(e) => {
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, { [id]: { default: e.target.value } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock component imports to avoid rendering real components that might access server-side resources
|
||||
@@ -300,7 +280,7 @@ const mockSurvey = {
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: createI18nString("Welcome", ["en", "fr"]),
|
||||
subheader: createI18nString("<p>Welcome to our survey</p>", ["en", "fr"]),
|
||||
html: createI18nString("<p>Welcome to our survey</p>", ["en", "fr"]),
|
||||
buttonLabel: createI18nString("Start", ["en", "fr"]),
|
||||
fileUrl: "",
|
||||
videoUrl: "",
|
||||
|
||||
@@ -11,14 +11,12 @@ import {
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
|
||||
import { MultiLangWrapper } from "@/modules/survey/components/question-form-input/components/multi-lang-wrapper";
|
||||
import { RecallWrapper } from "@/modules/survey/components/question-form-input/components/recall-wrapper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -57,9 +55,6 @@ interface QuestionFormInputProps {
|
||||
locale: TUserLocale;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
||||
isStorageConfigured: boolean;
|
||||
autoFocus?: boolean;
|
||||
firstRender?: boolean;
|
||||
setFirstRender?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const QuestionFormInput = ({
|
||||
@@ -82,9 +77,6 @@ export const QuestionFormInput = ({
|
||||
locale,
|
||||
onKeyDown,
|
||||
isStorageConfigured = true,
|
||||
autoFocus,
|
||||
firstRender: externalFirstRender,
|
||||
setFirstRender: externalSetFirstRender,
|
||||
}: QuestionFormInputProps) => {
|
||||
const { t } = useTranslate();
|
||||
const defaultLanguageCode =
|
||||
@@ -282,132 +274,13 @@ export const QuestionFormInput = ({
|
||||
const debouncedHandleUpdate = useMemo(() => debounce((value) => handleUpdate(value), 100), [handleUpdate]);
|
||||
|
||||
const [animationParent] = useAutoAnimate();
|
||||
const [internalFirstRender, setInternalFirstRender] = useState(true);
|
||||
|
||||
// Use external firstRender state if provided, otherwise use internal state
|
||||
const firstRender = externalFirstRender ?? internalFirstRender;
|
||||
const setFirstRender = externalSetFirstRender ?? setInternalFirstRender;
|
||||
const renderRemoveDescriptionButton = useMemo(() => {
|
||||
if (id !== "subheader") return false;
|
||||
return !!question?.subheader || (endingCard?.type === "endScreen" && !!endingCard?.subheader);
|
||||
|
||||
const renderRemoveDescriptionButton = () => {
|
||||
if (
|
||||
question &&
|
||||
(question.type === TSurveyQuestionTypeEnum.CTA || question.type === TSurveyQuestionTypeEnum.Consent)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (id === "subheader") {
|
||||
return !!question?.subheader || (endingCard?.type === "endScreen" && !!endingCard?.subheader);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const useRichTextEditor = id === "headline" || id === "subheader" || id === "html";
|
||||
|
||||
// For rich text editor fields, we need either updateQuestion or updateSurvey
|
||||
if (useRichTextEditor && !updateQuestion && !updateSurvey) {
|
||||
throw new Error("Either updateQuestion or updateSurvey must be provided");
|
||||
}
|
||||
|
||||
if (useRichTextEditor) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<div className="mb-2 mt-3">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-4" ref={animationParent}>
|
||||
{showImageUploader && id === "headline" && (
|
||||
<FileInput
|
||||
id="question-image"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
|
||||
environmentId={localSurvey.environmentId}
|
||||
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
|
||||
if (url) {
|
||||
const update =
|
||||
fileType === "video"
|
||||
? { videoUrl: url[0], imageUrl: "" }
|
||||
: { imageUrl: url[0], videoUrl: "" };
|
||||
if ((isWelcomeCard || isEndingCard) && updateSurvey) {
|
||||
updateSurvey(update);
|
||||
} else if (updateQuestion) {
|
||||
updateQuestion(questionIdx, update);
|
||||
}
|
||||
}
|
||||
}}
|
||||
fileUrl={getFileUrl()}
|
||||
videoUrl={getVideoUrl()}
|
||||
isVideoAllowed={true}
|
||||
maxSizeInMB={5}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<LocalizedEditor
|
||||
key={`${questionId}-${id}-${selectedLanguageCode}`}
|
||||
id={id}
|
||||
value={value}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={(isWelcomeCard || isEndingCard ? updateSurvey : updateQuestion)!}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
locale={locale}
|
||||
questionId={questionId}
|
||||
isCard={isWelcomeCard || isEndingCard}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{id === "headline" && !isWelcomeCard && (
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_photo_or_video")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Toggle image uploader"
|
||||
data-testid="toggle-image-uploader-button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowImageUploader((prev) => !prev);
|
||||
}}>
|
||||
<ImagePlusIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
|
||||
{id === "subheader" && renderRemoveDescriptionButton() && (
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Remove description"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (updateSurvey) {
|
||||
updateSurvey({ subheader: undefined });
|
||||
}
|
||||
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, { subheader: undefined });
|
||||
}
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endingCard?.type, id, question?.subheader]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -547,7 +420,7 @@ export const QuestionFormInput = ({
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
{renderRemoveDescriptionButton() ? (
|
||||
{renderRemoveDescriptionButton ? (
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -556,14 +429,12 @@ export const QuestionFormInput = ({
|
||||
className="ml-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (updateSurvey) {
|
||||
updateSurvey({ subheader: undefined });
|
||||
}
|
||||
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, { subheader: undefined });
|
||||
}
|
||||
if (updateSurvey) {
|
||||
updateSurvey({ subheader: undefined });
|
||||
}
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
|
||||
@@ -93,7 +93,6 @@ export const AddressQuestionForm = ({
|
||||
]);
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -108,7 +107,6 @@ export const AddressQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -127,7 +125,6 @@ export const AddressQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,6 @@ export const CalQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
<div>
|
||||
{question.subheader !== undefined && (
|
||||
@@ -81,7 +80,6 @@ export const CalQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,6 +95,7 @@ export const CalQuestionForm = ({
|
||||
subheader: createI18nString("", surveyLanguageCodes),
|
||||
});
|
||||
}}>
|
||||
{" "}
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
{t("environments.surveys.edit.add_description")}
|
||||
</Button>
|
||||
|
||||
@@ -5,11 +5,15 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { ConsentQuestionForm } from "./consent-question-form";
|
||||
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
QuestionFormInput: ({ label, id }: { label: string; id: string }) => (
|
||||
<div data-testid="question-form-input" data-field-id={id}>
|
||||
{label}
|
||||
</div>
|
||||
),
|
||||
QuestionFormInput: ({ label }: { label: string }) => <div data-testid="question-form-input">{label}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
|
||||
LocalizedEditor: ({ id }: { id: string }) => <div data-testid="localized-editor">{id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children }: { children: string }) => <div data-testid="label">{children}</div>,
|
||||
}));
|
||||
|
||||
describe("ConsentQuestionForm", () => {
|
||||
@@ -57,18 +61,9 @@ describe("ConsentQuestionForm", () => {
|
||||
);
|
||||
|
||||
const questionFormInputs = screen.getAllByTestId("question-form-input");
|
||||
expect(questionFormInputs).toHaveLength(3);
|
||||
|
||||
// Check headline field
|
||||
expect(questionFormInputs[0]).toHaveTextContent("environments.surveys.edit.question*");
|
||||
expect(questionFormInputs[0]).toHaveAttribute("data-field-id", "headline");
|
||||
|
||||
// Check html (description) field
|
||||
expect(questionFormInputs[1]).toHaveTextContent("common.description");
|
||||
expect(questionFormInputs[1]).toHaveAttribute("data-field-id", "subheader");
|
||||
|
||||
// Check label (checkbox label) field
|
||||
expect(questionFormInputs[2]).toHaveTextContent("environments.surveys.edit.checkbox_label*");
|
||||
expect(questionFormInputs[2]).toHaveAttribute("data-field-id", "label");
|
||||
expect(screen.getByTestId("label")).toHaveTextContent("common.description");
|
||||
expect(screen.getByTestId("localized-editor")).toHaveTextContent("subheader");
|
||||
expect(questionFormInputs[1]).toHaveTextContent("environments.surveys.edit.checkbox_label*");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { type JSX } from "react";
|
||||
import { type JSX, useState } from "react";
|
||||
import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface ConsentQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -29,45 +31,57 @@ export const ConsentQuestionForm = ({
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
}: ConsentQuestionFormProps): JSX.Element => {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const { t } = useTranslate();
|
||||
|
||||
// Common props shared across all QuestionFormInput components
|
||||
const commonInputProps = {
|
||||
localSurvey,
|
||||
questionIdx,
|
||||
isInvalid,
|
||||
updateQuestion,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured,
|
||||
};
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
{...commonInputProps}
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={t("environments.surveys.edit.question") + "*"}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
value={question.headline}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<QuestionFormInput
|
||||
{...commonInputProps}
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={t("common.description")}
|
||||
/>
|
||||
<Label htmlFor="subheader">{t("common.description")}</Label>
|
||||
<div className="mt-2">
|
||||
<LocalizedEditor
|
||||
id="subheader"
|
||||
value={question.html}
|
||||
localSurvey={localSurvey}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
questionIdx={questionIdx}
|
||||
locale={locale}
|
||||
questionId={question.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuestionFormInput
|
||||
{...commonInputProps}
|
||||
id="label"
|
||||
label={t("environments.surveys.edit.checkbox_label") + "*"}
|
||||
placeholder="I agree to the terms and conditions"
|
||||
value={question.label}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -82,6 +82,7 @@ export const ContactInfoQuestionForm = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [question.firstName, question.lastName, question.email, question.phone, question.company]);
|
||||
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
@@ -98,7 +99,6 @@ export const ContactInfoQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -117,7 +117,6 @@ export const ContactInfoQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,11 @@ vi.mock("@formkit/auto-animate/react", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
QuestionFormInput: ({ id }: { id: string }) => (
|
||||
<div data-testid="question-form-input" data-field-id={id}>
|
||||
QuestionFormInput-{id}
|
||||
</div>
|
||||
),
|
||||
QuestionFormInput: () => <div data-testid="question-form-input">QuestionFormInput</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
|
||||
LocalizedEditor: () => <div data-testid="localized-editor">LocalizedEditor</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/options-switch", () => ({
|
||||
@@ -69,13 +69,8 @@ describe("CTAQuestionForm", () => {
|
||||
);
|
||||
|
||||
const questionFormInputs = screen.getAllByTestId("question-form-input");
|
||||
expect(questionFormInputs.length).toBe(3);
|
||||
|
||||
// Check that we have headline, html (description), and buttonLabel fields
|
||||
expect(questionFormInputs[0]).toHaveAttribute("data-field-id", "headline");
|
||||
expect(questionFormInputs[1]).toHaveAttribute("data-field-id", "subheader");
|
||||
expect(questionFormInputs[2]).toHaveAttribute("data-field-id", "buttonLabel");
|
||||
|
||||
expect(questionFormInputs.length).toBe(2);
|
||||
expect(screen.getByTestId("localized-editor")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { type JSX } from "react";
|
||||
import { type JSX, useState } from "react";
|
||||
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
@@ -42,9 +44,10 @@ export const CTAQuestionForm = ({
|
||||
},
|
||||
{ value: "external", label: t("environments.surveys.edit.button_to_link_to_external_url") },
|
||||
];
|
||||
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const [parent] = useAutoAnimate();
|
||||
return (
|
||||
<form>
|
||||
<form ref={parent}>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
@@ -57,23 +60,26 @@ export const CTAQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={t("common.description")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
<Label htmlFor="subheader">{t("common.description")}</Label>
|
||||
<div className="mt-2">
|
||||
<LocalizedEditor
|
||||
id="subheader"
|
||||
value={question.html}
|
||||
localSurvey={localSurvey}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
questionIdx={questionIdx}
|
||||
locale={locale}
|
||||
questionId={question.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<OptionsSwitch
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
@@ -53,7 +53,6 @@ export const DateQuestionForm = ({
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
const { t } = useTranslate();
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -68,7 +67,6 @@ export const DateQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
@@ -77,7 +75,7 @@ export const DateQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={t("common.description")}
|
||||
label={t("environments.surveys.edit.description")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -86,7 +84,6 @@ export const DateQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { GripIcon, Handshake, Undo2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
TSurveyQuestionId,
|
||||
TSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -65,13 +64,8 @@ export const EditEndingCard = ({
|
||||
isStorageConfigured,
|
||||
quotas,
|
||||
}: EditEndingCardProps) => {
|
||||
const endingCard = localSurvey.endings[endingCardIndex];
|
||||
const { t } = useTranslate();
|
||||
|
||||
const endingCard = useMemo(
|
||||
() => localSurvey.endings[endingCardIndex],
|
||||
[localSurvey.endings, endingCardIndex]
|
||||
);
|
||||
|
||||
const isRedirectToUrlDisabled = isFormbricksCloud
|
||||
? plan === "free" && endingCard.type !== "redirectToUrl"
|
||||
: false;
|
||||
@@ -101,30 +95,10 @@ export const EditEndingCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const updateSurvey = (
|
||||
data: Partial<TSurveyEndScreenCard & { _forceUpdate?: boolean }> | Partial<TSurveyRedirectUrlCard>
|
||||
) => {
|
||||
const updateSurvey = (data: Partial<TSurveyEndScreenCard> | Partial<TSurveyRedirectUrlCard>) => {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const currentEnding = prevSurvey.endings[endingCardIndex];
|
||||
|
||||
// If subheader was explicitly deleted (is undefined) in the current state,
|
||||
// block ALL attempts to recreate it (from Editor cleanup/updates)
|
||||
// UNLESS it's a forced update from the "Add Description" button
|
||||
const filteredData = { ...data };
|
||||
const isForceUpdate = "_forceUpdate" in filteredData;
|
||||
if (isForceUpdate) {
|
||||
delete (filteredData as any)._forceUpdate; // Remove the flag
|
||||
}
|
||||
|
||||
if (!isForceUpdate && currentEnding?.type === "endScreen" && currentEnding.subheader === undefined) {
|
||||
if ("subheader" in filteredData) {
|
||||
// Block subheader updates when it's been deleted (Editor cleanup trying to recreate)
|
||||
delete filteredData.subheader;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedEndings = prevSurvey.endings.map((ending, idx) =>
|
||||
idx === endingCardIndex ? { ...ending, ...filteredData } : ending
|
||||
idx === endingCardIndex ? { ...ending, ...data } : ending
|
||||
);
|
||||
return { ...prevSurvey, endings: updatedEndings };
|
||||
});
|
||||
@@ -242,11 +216,9 @@ export const EditEndingCard = ({
|
||||
selectedLanguageCode
|
||||
]
|
||||
? formatTextWithSlashes(
|
||||
getTextContent(
|
||||
recallToHeadline(endingCard.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
]
|
||||
)
|
||||
recallToHeadline(endingCard.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
]
|
||||
)
|
||||
: t("environments.surveys.edit.ending_card"))}
|
||||
{endingCard.type === "redirectToUrl" &&
|
||||
|
||||
@@ -6,6 +6,12 @@ import { EditWelcomeCard } from "@/modules/survey/editor/components/edit-welcome
|
||||
|
||||
vi.mock("@/lib/cn");
|
||||
|
||||
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
|
||||
LocalizedEditor: vi.fn(({ value, id }) => (
|
||||
<textarea data-testid={`localized-editor-${id}`} defaultValue={value?.default}></textarea>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
QuestionFormInput: vi.fn(({ value, id }) => (
|
||||
<input data-testid={`question-form-input-${id}`} defaultValue={value?.default}></input>
|
||||
@@ -47,7 +53,7 @@ const mockSurvey = {
|
||||
mockSurvey.welcomeCard = {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome!" },
|
||||
subheader: { default: "<p>Thank you for participating.</p>" },
|
||||
html: { default: "<p>Thank you for participating.</p>" },
|
||||
buttonLabel: { default: "Start Survey" },
|
||||
timeToFinish: true,
|
||||
showResponseCount: false,
|
||||
@@ -103,9 +109,7 @@ describe("EditWelcomeCard", () => {
|
||||
expect(screen.getByLabelText("common.on")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("file-input")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("question-form-input-headline")).toHaveValue("Welcome!");
|
||||
expect(screen.getByTestId("question-form-input-subheader")).toHaveValue(
|
||||
"<p>Thank you for participating.</p>"
|
||||
);
|
||||
expect(screen.getByTestId("localized-editor-html")).toHaveValue("<p>Thank you for participating.</p>");
|
||||
expect(screen.getByTestId("question-form-input-buttonLabel")).toHaveValue("Start Survey");
|
||||
expect(screen.getByLabelText("common.time_to_finish")).toBeInTheDocument();
|
||||
const timeToFinishSwitch = screen.getAllByRole("switch")[1]; // Assuming the second switch is for timeToFinish
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useState } from "react";
|
||||
import { TSurvey, TSurveyQuestionId, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
@@ -37,8 +38,8 @@ export const EditWelcomeCard = ({
|
||||
isStorageConfigured = true,
|
||||
}: EditWelcomeCardProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const path = usePathname();
|
||||
const environmentId = path?.split("/environments/")[1]?.split("/")[0];
|
||||
|
||||
@@ -47,6 +48,7 @@ export const EditWelcomeCard = ({
|
||||
const setOpen = (e) => {
|
||||
if (e) {
|
||||
setActiveQuestionId("start");
|
||||
setFirstRender(true);
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
@@ -137,26 +139,26 @@ export const EditWelcomeCard = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={localSurvey.welcomeCard.subheader}
|
||||
label={t("environments.surveys.edit.welcome_message")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={-1}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
<Label htmlFor="subheader">{t("environments.surveys.edit.welcome_message")}</Label>
|
||||
<div className="mt-2">
|
||||
<LocalizedEditor
|
||||
id="html"
|
||||
value={localSurvey.welcomeCard.html}
|
||||
localSurvey={localSurvey}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
questionIdx={-1}
|
||||
locale={locale}
|
||||
questionId="start"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-between gap-8">
|
||||
@@ -176,8 +178,6 @@ export const EditWelcomeCard = ({
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,13 +140,9 @@ describe("EndScreenForm", () => {
|
||||
|
||||
if (buttonElement) {
|
||||
await userEvent.click(buttonElement);
|
||||
// Check that the subheader was added (may be called multiple times due to autoFocus)
|
||||
expect(mockUpdateSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subheader: expect.any(Object),
|
||||
_forceUpdate: true,
|
||||
})
|
||||
);
|
||||
expect(mockUpdateSurvey).toHaveBeenCalledWith({
|
||||
subheader: expect.any(Object),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useRef } from "react";
|
||||
import { TSurvey, TSurveyEndScreenCard } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@/lib/i18n/utils";
|
||||
@@ -20,7 +21,7 @@ interface EndScreenFormProps {
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
updateSurvey: (input: Partial<TSurveyEndScreenCard & { _forceUpdate?: boolean }>) => void;
|
||||
updateSurvey: (input: Partial<TSurveyEndScreenCard>) => void;
|
||||
endingCard: TSurveyEndScreenCard;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
@@ -45,7 +46,6 @@ export const EndScreenForm = ({
|
||||
endingCard.type === "endScreen" &&
|
||||
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
|
||||
);
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -60,7 +60,6 @@ export const EndScreenForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
|
||||
/>
|
||||
<div>
|
||||
{endingCard.subheader !== undefined && (
|
||||
@@ -78,7 +77,6 @@ export const EndScreenForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,10 +89,8 @@ export const EndScreenForm = ({
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Directly update the state, bypassing the guard in updateSurvey
|
||||
updateSurvey({
|
||||
subheader: createI18nString("", surveyLanguageCodes),
|
||||
_forceUpdate: true,
|
||||
});
|
||||
}}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
|
||||
@@ -145,7 +145,6 @@ export const FileUploadQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
@@ -163,7 +162,6 @@ export const FileUploadQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -184,6 +184,7 @@ export const MatrixQuestionForm = ({
|
||||
show: true,
|
||||
},
|
||||
};
|
||||
/// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
@@ -200,7 +201,6 @@ export const MatrixQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
@@ -218,7 +218,6 @@ export const MatrixQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +195,6 @@ export const MultipleChoiceQuestionForm = ({
|
||||
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -210,7 +209,6 @@ export const MultipleChoiceQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -229,7 +227,6 @@ export const MultipleChoiceQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
@@ -38,6 +38,7 @@ export const NPSQuestionForm = ({
|
||||
}: NPSQuestionFormProps): JSX.Element => {
|
||||
const { t } = useTranslate();
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
@@ -54,7 +55,6 @@ export const NPSQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -73,7 +73,6 @@ export const NPSQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +88,7 @@ export const NPSQuestionForm = ({
|
||||
subheader: createI18nString("", surveyLanguageCodes),
|
||||
});
|
||||
}}>
|
||||
{" "}
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
{t("environments.surveys.edit.add_description")}
|
||||
</Button>
|
||||
|
||||
@@ -86,16 +86,15 @@ export const OpenQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={t("environments.surveys.edit.question") + "*"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
label={t("environments.surveys.edit.question") + "*"}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -105,16 +104,15 @@ export const OpenQuestionForm = ({
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={question.subheader}
|
||||
label={t("common.description")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
label={t("common.description")}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -70,14 +70,14 @@ export const PictureSelectionForm = ({
|
||||
choices: updatedChoices,
|
||||
});
|
||||
};
|
||||
|
||||
// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
value={question.headline}
|
||||
label={t("environments.surveys.edit.question") + "*"}
|
||||
value={question.headline}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
isInvalid={isInvalid}
|
||||
@@ -86,7 +86,6 @@ export const PictureSelectionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
<div ref={parent}>
|
||||
{question.subheader !== undefined && (
|
||||
@@ -104,7 +103,6 @@ export const PictureSelectionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -224,17 +223,18 @@ export const QuestionCard = ({
|
||||
aria-label="Toggle question details">
|
||||
<div>
|
||||
<div className="flex grow">
|
||||
{/* <div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
|
||||
{QUESTIONS_ICON_MAP[question.type]}
|
||||
</div> */}
|
||||
<div className="flex grow flex-col justify-center" dir="auto">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
]
|
||||
? formatTextWithSlashes(
|
||||
getTextContent(
|
||||
recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
] ?? ""
|
||||
)
|
||||
recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
] ?? ""
|
||||
)
|
||||
: getTSurveyQuestionTypeEnumName(question.type, t)}
|
||||
</h3>
|
||||
|
||||
@@ -131,7 +131,6 @@ export const RankingQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -150,7 +149,6 @@ export const RankingQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,6 @@ export const RatingQuestionForm = ({
|
||||
const { t } = useTranslate();
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -54,7 +53,6 @@ export const RatingQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
||||
/>
|
||||
|
||||
<div ref={parent}>
|
||||
@@ -73,7 +71,6 @@ export const RatingQuestionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1599,5 +1599,25 @@ describe("Survey Editor Utils", () => {
|
||||
|
||||
expect(result).toBe(10); // Index of question11
|
||||
});
|
||||
|
||||
test("should find recall pattern in question html field", () => {
|
||||
const surveyWithRecall = {
|
||||
...createMockSurvey(),
|
||||
questions: [
|
||||
...createMockSurvey().questions,
|
||||
{
|
||||
id: "question11",
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
headline: { default: "Question" },
|
||||
html: { default: "HTML #recall:question1/fallback:default" },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
} as TSurvey;
|
||||
|
||||
const result = isUsedInRecall(surveyWithRecall, "question1");
|
||||
|
||||
expect(result).toBe(10); // Index of question11
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
TSurveyVariable,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { isConditionGroup } from "@/lib/surveyLogic/utils";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -121,10 +120,9 @@ export const getConditionValueOptions = (
|
||||
.forEach((question) => {
|
||||
if (question.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
// Rows submenu
|
||||
const questionHeadline = getTextContent(getLocalizedValue(question.headline, "default"));
|
||||
const rows = question.rows.map((row, rowIdx) => ({
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: `${getLocalizedValue(row.label, "default")} (${questionHeadline})`,
|
||||
label: `${getLocalizedValue(row.label, "default")} (${getLocalizedValue(question.headline, "default")})`,
|
||||
value: `${question.id}.${rowIdx}`,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -134,7 +132,7 @@ export const getConditionValueOptions = (
|
||||
|
||||
questionOptions.push({
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: questionHeadline,
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -157,7 +155,7 @@ export const getConditionValueOptions = (
|
||||
} else {
|
||||
questionOptions.push({
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -379,7 +377,7 @@ export const getMatchValueProps = (
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -929,7 +927,7 @@ export const getActionTargetOptions = (
|
||||
const questionOptions = questions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
};
|
||||
});
|
||||
@@ -940,8 +938,7 @@ export const getActionTargetOptions = (
|
||||
return {
|
||||
label:
|
||||
ending.type === "endScreen"
|
||||
? getTextContent(getLocalizedValue(ending.headline, "default")) ||
|
||||
t("environments.surveys.edit.end_screen_card")
|
||||
? getLocalizedValue(ending.headline, "default") || t("environments.surveys.edit.end_screen_card")
|
||||
: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
|
||||
value: ending.id,
|
||||
};
|
||||
@@ -1048,7 +1045,7 @@ export const getActionValueOptions = (
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -1106,7 +1103,7 @@ export const getActionValueOptions = (
|
||||
const questionOptions = allowedQuestions.map((question) => {
|
||||
return {
|
||||
icon: getQuestionIconMapping(t)[question.type],
|
||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||
label: getLocalizedValue(question.headline, "default"),
|
||||
value: question.id,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -1280,7 +1277,7 @@ const checkWelcomeCardForRecall = (welcomeCard: TSurveyWelcomeCard, recallPatter
|
||||
|
||||
return (
|
||||
checkTextForRecallPattern(welcomeCard.headline, recallPattern) ||
|
||||
checkTextForRecallPattern(welcomeCard.subheader, recallPattern)
|
||||
checkTextForRecallPattern(welcomeCard.html, recallPattern)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1295,6 +1292,11 @@ const checkQuestionForRecall = (question: TSurveyQuestion, recallPattern: string
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check html field (for consent and CTA questions)
|
||||
if ("html" in question && checkTextForRecallPattern(question.html, recallPattern)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1427,10 +1429,8 @@ export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: strin
|
||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||
};
|
||||
|
||||
export const getSurveyFollowUpActionDefaultBody = (t: TFnType): string => {
|
||||
return t("templates.follow_ups_modal_action_body")
|
||||
.replaceAll(/[\u200B-\u200D\uFEFF]/g, "")
|
||||
.trim();
|
||||
export const getSurveyFollowUpActionDefaultBody = (t: TFnType) => {
|
||||
return t("templates.follow_ups_modal_action_body") as string;
|
||||
};
|
||||
|
||||
export const findEndingCardUsedInLogic = (survey: TSurvey, endingCardId: string): number => {
|
||||
|
||||
@@ -183,7 +183,7 @@ describe("validation.isWelcomeCardValid", () => {
|
||||
const baseWelcomeCard: TSurveyWelcomeCard = {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome", en: "Welcome", de: "Willkommen" },
|
||||
subheader: { default: "<p>Info</p>", en: "<p>Info</p>", de: "<p>Infos</p>" },
|
||||
html: { default: "<p>Info</p>", en: "<p>Info</p>", de: "<p>Infos</p>" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
};
|
||||
@@ -197,13 +197,13 @@ describe("validation.isWelcomeCardValid", () => {
|
||||
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if subheader is invalid (when subheader is provided)", () => {
|
||||
const card = { ...baseWelcomeCard, subheader: { default: "<p>Info</p>", en: "<p>Info</p>", de: " " } };
|
||||
test("should return false if html is invalid (when html is provided)", () => {
|
||||
const card = { ...baseWelcomeCard, html: { default: "<p>Info</p>", en: "<p>Info</p>", de: " " } };
|
||||
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if subheader is undefined", () => {
|
||||
const card = { ...baseWelcomeCard, subheader: undefined };
|
||||
test("should return true if html is undefined", () => {
|
||||
const card = { ...baseWelcomeCard, html: undefined };
|
||||
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -372,7 +372,7 @@ describe("validation.validateQuestion", () => {
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
headline: { default: "Consent", en: "Consent", de: "Zustimmung" },
|
||||
label: { default: "I agree", en: "I agree", de: "Ich stimme zu" },
|
||||
subheader: { default: "Details...", en: "Details...", de: "Details..." },
|
||||
html: { default: "Details...", en: "Details...", de: "Details..." },
|
||||
};
|
||||
|
||||
test("should return true for a valid Consent question", () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
TSurveyRedirectUrlCard,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { findLanguageCodesForDuplicateLabels, getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { findLanguageCodesForDuplicateLabels } from "@formbricks/types/surveys/validation";
|
||||
import { extractLanguageCodes, getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { checkForEmptyFallBackValue } from "@/lib/utils/recall";
|
||||
|
||||
@@ -35,7 +35,7 @@ export const isLabelValidForAllLanguages = (
|
||||
});
|
||||
const languageCodes = extractLanguageCodes(filteredLanguages);
|
||||
const languages = languageCodes.length === 0 ? ["default"] : languageCodes;
|
||||
return languages.every((language) => label?.[language] && getTextContent(label[language]).length > 0);
|
||||
return languages.every((language) => label && label[language] && label[language].trim() !== "");
|
||||
};
|
||||
|
||||
// Validation logic for multiple choice questions
|
||||
@@ -145,7 +145,7 @@ export const validationRules = {
|
||||
let isValid = isHeadlineValid && isSubheaderValid;
|
||||
const defaultLanguageCode = "default";
|
||||
//question specific fields
|
||||
let fieldsToValidate = ["buttonLabel", "upperLabel", "backButtonLabel", "lowerLabel"];
|
||||
let fieldsToValidate = ["html", "buttonLabel", "upperLabel", "backButtonLabel", "lowerLabel"];
|
||||
|
||||
// Remove backButtonLabel from validation if it is the first question
|
||||
if (isFirstQuestion) {
|
||||
@@ -210,7 +210,7 @@ const isContentValid = (content: Record<string, string> | undefined, surveyLangu
|
||||
};
|
||||
|
||||
export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => {
|
||||
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.subheader, surveyLanguages);
|
||||
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.html, surveyLanguages);
|
||||
};
|
||||
|
||||
export const isEndingCardValid = (
|
||||
|
||||
@@ -142,7 +142,7 @@ describe("FollowUpItem", () => {
|
||||
},
|
||||
endings: [],
|
||||
welcomeCard: {
|
||||
subheader: {
|
||||
html: {
|
||||
default: "Thanks for providing your feedback - let's go!",
|
||||
},
|
||||
enabled: false,
|
||||
|
||||
@@ -201,7 +201,7 @@ export const getQuestionTypes = (t: TFnType): TQuestion[] => [
|
||||
icon: MousePointerClickIcon,
|
||||
preset: {
|
||||
headline: createI18nString("", []),
|
||||
subheader: createI18nString("", []),
|
||||
html: createI18nString("", []),
|
||||
buttonLabel: createI18nString(t("templates.book_interview"), []),
|
||||
buttonExternal: false,
|
||||
dismissButtonLabel: createI18nString(t("templates.skip"), []),
|
||||
@@ -215,7 +215,7 @@ export const getQuestionTypes = (t: TFnType): TQuestion[] => [
|
||||
icon: CheckIcon,
|
||||
preset: {
|
||||
headline: createI18nString("", []),
|
||||
subheader: createI18nString("", []),
|
||||
html: createI18nString("", []),
|
||||
label: createI18nString("", []),
|
||||
buttonLabel: createI18nString(t("templates.next"), []),
|
||||
backButtonLabel: createI18nString(t("templates.back"), []),
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Toaster, toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
@@ -175,7 +174,7 @@ export const VerifyEmail = ({
|
||||
{localSurvey.questions.map((question, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className="my-1 text-sm">{`${(index + 1).toString()}. ${getTextContent(getLocalizedValue(question.headline, languageCode))}`}</p>
|
||||
className="my-1 text-sm">{`${(index + 1).toString()}. ${getLocalizedValue(question.headline, languageCode)}`}</p>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" className="mt-6" onClick={handlePreviewClick}>
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("data", () => {
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
headline: { default: "Welcome" },
|
||||
subheader: { default: "" },
|
||||
html: { default: "" },
|
||||
buttonLabel: { default: "Start" },
|
||||
},
|
||||
questions: [],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||
import { getPrefillValue } from "./utils";
|
||||
@@ -85,6 +85,7 @@ describe("survey link utils", () => {
|
||||
required: false,
|
||||
logic: [],
|
||||
buttonLabel: { default: "Click me" },
|
||||
html: { default: "" },
|
||||
subheader: { default: "" },
|
||||
},
|
||||
{
|
||||
@@ -136,7 +137,7 @@ describe("survey link utils", () => {
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome" },
|
||||
subheader: { default: "" },
|
||||
html: { default: "" },
|
||||
buttonLabel: { default: "Start" },
|
||||
},
|
||||
hiddenFields: {},
|
||||
|
||||
@@ -2,7 +2,6 @@ import { CodeHighlightNode, CodeNode } from "@lexical/code";
|
||||
import { AutoLinkNode, LinkNode } from "@lexical/link";
|
||||
import { ListItemNode, ListNode } from "@lexical/list";
|
||||
import { TRANSFORMERS } from "@lexical/markdown";
|
||||
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
|
||||
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
|
||||
@@ -55,8 +54,6 @@ export type TextEditorProps = {
|
||||
selectedLanguageCode?: string;
|
||||
fallbacks?: { [id: string]: string };
|
||||
addFallback?: () => void;
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const editorConfig = {
|
||||
@@ -121,15 +118,10 @@ export const Editor = (props: TextEditorProps) => {
|
||||
style={{ height: props.height }}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
style={{ height: props.height }}
|
||||
className="editor-input"
|
||||
aria-labelledby={props.id}
|
||||
dir="auto"
|
||||
/>
|
||||
<ContentEditable style={{ height: props.height }} className="editor-input" />
|
||||
}
|
||||
placeholder={
|
||||
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400" dir="auto">
|
||||
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400">
|
||||
{props.placeholder ?? ""}
|
||||
</div>
|
||||
}
|
||||
@@ -138,7 +130,6 @@ export const Editor = (props: TextEditorProps) => {
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<AutoLinkPlugin />
|
||||
{props.autoFocus && <AutoFocusPlugin />}
|
||||
{props.localSurvey && props.questionId && props.selectedLanguageCode && (
|
||||
<RecallPlugin
|
||||
localSurvey={props.localSurvey}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cleanup, render } from "@testing-library/react";
|
||||
import { $applyNodeReplacement } from "lexical";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
||||
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
|
||||
import { $createRecallNode, RecallNode, RecallPayload, SerializedRecallNode } from "./recall-node";
|
||||
|
||||
vi.mock("lexical", () => ({
|
||||
@@ -23,19 +23,6 @@ vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceRecallInfoWithUnderline: vi.fn((label: string) => {
|
||||
return label.replace(/#recall:[^#]+#/g, "___");
|
||||
}),
|
||||
getTextContentWithRecallTruncated: vi.fn((text: string, maxLength: number = 25) => {
|
||||
// Mock: strip HTML tags, clean whitespace, truncate, replace recall patterns
|
||||
const cleanText = text.replace(/<|>/g, "").replace(/\s+/g, " ").trim();
|
||||
const withRecallReplaced = cleanText.replace(/#recall:[^#]+#/g, "___");
|
||||
|
||||
if (withRecallReplaced.length <= maxLength) {
|
||||
return withRecallReplaced;
|
||||
}
|
||||
|
||||
const start = withRecallReplaced.slice(0, 10);
|
||||
const end = withRecallReplaced.slice(-10);
|
||||
return `${start}...${end}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("RecallNode", () => {
|
||||
@@ -366,15 +353,15 @@ describe("RecallNode", () => {
|
||||
expect(span?.textContent).toContain("@");
|
||||
});
|
||||
|
||||
test("calls getTextContentWithRecallTruncated with label", () => {
|
||||
test("calls replaceRecallInfoWithUnderline with label", () => {
|
||||
const node = new RecallNode(mockPayload);
|
||||
node.decorate();
|
||||
|
||||
expect(vi.mocked(getTextContentWithRecallTruncated)).toHaveBeenCalledWith("What is your name?");
|
||||
expect(vi.mocked(replaceRecallInfoWithUnderline)).toHaveBeenCalledWith("What is your name?");
|
||||
});
|
||||
|
||||
test("handles label with nested recall patterns", () => {
|
||||
vi.mocked(getTextContentWithRecallTruncated).mockReturnValueOnce("Processed Label");
|
||||
vi.mocked(replaceRecallInfoWithUnderline).mockReturnValueOnce("Processed Label");
|
||||
|
||||
const payloadWithNestedRecall: RecallPayload = {
|
||||
recallItem: {
|
||||
@@ -389,7 +376,7 @@ describe("RecallNode", () => {
|
||||
const decorated = node.decorate();
|
||||
|
||||
const { container } = render(<>{decorated}</>);
|
||||
expect(vi.mocked(getTextContentWithRecallTruncated)).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(replaceRecallInfoWithUnderline)).toHaveBeenCalledWith(
|
||||
"What is your #recall:name/fallback:name# answer?"
|
||||
);
|
||||
expect(container.textContent).toContain("@Processed Label");
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { DOMConversionMap, DOMConversionOutput, DOMExportOutput, NodeKey, S
|
||||
import { $applyNodeReplacement, DecoratorNode } from "lexical";
|
||||
import { ReactNode } from "react";
|
||||
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
||||
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
|
||||
|
||||
export interface RecallPayload {
|
||||
recallItem: TSurveyRecallItem;
|
||||
@@ -134,13 +134,12 @@ export class RecallNode extends DecoratorNode<ReactNode> {
|
||||
}
|
||||
|
||||
decorate(): ReactNode {
|
||||
const displayLabel = getTextContentWithRecallTruncated(this.__recallItem.label);
|
||||
const displayLabel = replaceRecallInfoWithUnderline(this.__recallItem.label);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="recall-node z-30 inline-flex h-fit justify-center whitespace-nowrap rounded-md bg-slate-100 text-sm text-slate-700"
|
||||
aria-label={`Recall: ${displayLabel}`}
|
||||
title={displayLabel}>
|
||||
className="recall-node z-30 inline-flex h-fit justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-slate-700"
|
||||
aria-label={`Recall: ${displayLabel}`}>
|
||||
@{displayLabel}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -223,15 +223,7 @@ export const RecallPlugin = ({
|
||||
}
|
||||
});
|
||||
},
|
||||
[
|
||||
findAllRecallNodes,
|
||||
localSurvey,
|
||||
selectedLanguageCode,
|
||||
setRecallItems,
|
||||
setFallbacks,
|
||||
editor,
|
||||
convertTextToRecallNodes,
|
||||
]
|
||||
[localSurvey, selectedLanguageCode, editor, convertTextToRecallNodes, findAllRecallNodes]
|
||||
);
|
||||
|
||||
// Handle @ key press for recall trigger
|
||||
@@ -268,7 +260,7 @@ export const RecallPlugin = ({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[editor, setShowRecallItemSelect]
|
||||
[editor]
|
||||
);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
@@ -285,7 +277,7 @@ export const RecallPlugin = ({
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
}, [setShowRecallItemSelect, showRecallItemSelect]);
|
||||
}, [showRecallItemSelect]);
|
||||
|
||||
// Clean up when dropdown closes
|
||||
useEffect(() => {
|
||||
@@ -393,13 +385,11 @@ export const RecallPlugin = ({
|
||||
},
|
||||
[
|
||||
editor,
|
||||
setShowRecallItemSelect,
|
||||
recallItems,
|
||||
setRecallItems,
|
||||
atSymbolPosition,
|
||||
replaceAtSymbolWithStoredPosition,
|
||||
replaceAtSymbolWithCurrentSelection,
|
||||
onShowFallbackInput,
|
||||
recallItems,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
PASTE_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from "lexical";
|
||||
import { AtSign, Bold, ChevronDownIcon, Italic, Link, PencilIcon, Underline } from "lucide-react";
|
||||
@@ -310,8 +312,25 @@ export const ToolbarPlugin = (
|
||||
}
|
||||
}, [editor, isLink, props]);
|
||||
|
||||
// Removed custom PASTE_COMMAND handler to allow Lexical's default paste handler
|
||||
// to properly preserve rich text formatting (bold, italic, links, etc.)
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
PASTE_COMMAND,
|
||||
(e: ClipboardEvent) => {
|
||||
const text = e.clipboardData?.getData("text/plain");
|
||||
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.insertRawText(text ?? "");
|
||||
}
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
return true; // Prevent the default paste handler
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
if (!props.editable) return <></>;
|
||||
|
||||
@@ -404,20 +423,18 @@ export const ToolbarPlugin = (
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{items.map(({ key, icon, onClick, active, tooltipText, disabled }) =>
|
||||
!props.excludedToolbarItems?.includes(key) ? (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
icon={icon}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
tooltipText={tooltipText}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
{items.map(({ key, icon, onClick, active, tooltipText, disabled }) =>
|
||||
!props.excludedToolbarItems?.includes(key) ? (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
icon={icon}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
tooltipText={tooltipText}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
position: relative;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
border-color: #cbd5e1;
|
||||
border-width: 1px;
|
||||
padding: 1px;
|
||||
@@ -35,11 +36,11 @@
|
||||
position: relative;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
resize: vertical;
|
||||
height: auto;
|
||||
min-height: var(--editor-min-height, 48px);
|
||||
max-height: 200px;
|
||||
min-height: var(--editor-min-height, 40px);
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.editor-input {
|
||||
@@ -48,7 +49,7 @@
|
||||
position: relative;
|
||||
tab-size: 1;
|
||||
outline: 0;
|
||||
padding: 5px 10px 10px 10px;
|
||||
padding: 10px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -348,4 +349,4 @@ i.link {
|
||||
|
||||
.inactive-button {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { surveys } from "@/playwright/utils/mock";
|
||||
import { test } from "./lib/fixtures";
|
||||
import * as helper from "./utils/helper";
|
||||
import { createSurvey, createSurveyWithLogic, uploadFileForFileUploadQuestion } from "./utils/helper";
|
||||
|
||||
test.use({
|
||||
launchOptions: {
|
||||
slowMo: 150,
|
||||
slowMo: 110,
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
// 5 minutes
|
||||
test.setTimeout(1000 * 60 * 5);
|
||||
|
||||
let url: string | null;
|
||||
|
||||
test("Create survey and submit response", async ({ page, users }) => {
|
||||
@@ -202,9 +198,18 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("cell", { name: "Roses – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Trees – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Ocean – 0" }).locator("div").click();
|
||||
await page
|
||||
.getByRole("cell", { name: "How much do you love these flowers?: Roses – 0" })
|
||||
.locator("div")
|
||||
.click();
|
||||
await page
|
||||
.getByRole("cell", { name: "How much do you love these flowers?: Trees – 0" })
|
||||
.locator("div")
|
||||
.click();
|
||||
await page
|
||||
.getByRole("cell", { name: "How much do you love these flowers?: Ocean – 0" })
|
||||
.locator("div")
|
||||
.click();
|
||||
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Address Question
|
||||
@@ -238,8 +243,8 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
});
|
||||
|
||||
test.describe("Multi Language Survey Create", async () => {
|
||||
// 5 minutes
|
||||
test.setTimeout(1000 * 60 * 5);
|
||||
// 4 minutes
|
||||
test.setTimeout(1000 * 60 * 4);
|
||||
|
||||
test("Create Survey", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
@@ -280,7 +285,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
// Add questions in default language
|
||||
await page.getByText("Add question").click();
|
||||
await page.getByRole("button", { name: "Single-Select" }).click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.singleSelectQuestion.question);
|
||||
await page.getByLabel("Question*").fill(surveys.createAndSubmit.singleSelectQuestion.question);
|
||||
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.singleSelectQuestion.options[1]);
|
||||
|
||||
@@ -290,7 +295,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.multiSelectQuestion.question);
|
||||
await page.getByLabel("Question*").fill(surveys.createAndSubmit.multiSelectQuestion.question);
|
||||
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.multiSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.multiSelectQuestion.options[1]);
|
||||
await page.getByPlaceholder("Option 3").fill(surveys.createAndSubmit.multiSelectQuestion.options[2]);
|
||||
@@ -300,11 +305,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Picture Selection" }).click();
|
||||
await helper.fillRichTextEditor(
|
||||
page,
|
||||
"Question*",
|
||||
surveys.createAndSubmit.pictureSelectQuestion.question
|
||||
);
|
||||
await page.getByLabel("Question*").fill(surveys.createAndSubmit.pictureSelectQuestion.question);
|
||||
|
||||
// Handle file uploads
|
||||
await uploadFileForFileUploadQuestion(page);
|
||||
@@ -315,7 +316,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Rating" }).click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.ratingQuestion.question);
|
||||
await page.getByLabel("Question*").fill(surveys.createAndSubmit.ratingQuestion.question);
|
||||
await page.getByPlaceholder("Not good").fill(surveys.createAndSubmit.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").fill(surveys.createAndSubmit.ratingQuestion.highLabel);
|
||||
|
||||
@@ -325,7 +326,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.npsQuestion.question);
|
||||
await page.getByLabel("Question*").fill(surveys.createAndSubmit.npsQuestion.question);
|
||||
await page.getByLabel("Lower label").fill(surveys.createAndSubmit.npsQuestion.lowLabel);
|
||||
await page.getByLabel("Upper label").fill(surveys.createAndSubmit.npsQuestion.highLabel);
|
||||
|
||||
@@ -335,7 +336,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Date" }).click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.dateQuestion.question);
|
||||
await page.getByLabel("Question*").fill(surveys.createAndSubmit.dateQuestion.question);
|
||||
|
||||
await page
|
||||
.locator("div")
|
||||
@@ -343,7 +344,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "File Upload" }).click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.fileUploadQuestion.question);
|
||||
await page.getByLabel("Question*").fill(surveys.createAndSubmit.fileUploadQuestion.question);
|
||||
|
||||
await page
|
||||
.locator("div")
|
||||
@@ -353,7 +354,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
await page.getByRole("button", { name: "Matrix" }).scrollIntoViewIfNeeded();
|
||||
await page.getByRole("button", { name: "Matrix" }).click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.matrix.question);
|
||||
await page.getByLabel("Question*").fill(surveys.createAndSubmit.matrix.question);
|
||||
await page.locator("#row-0").click();
|
||||
await page.locator("#row-0").fill(surveys.createAndSubmit.matrix.rows[0]);
|
||||
await page.locator("#row-1").click();
|
||||
@@ -378,7 +379,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Address" }).click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.address.question);
|
||||
await page.getByLabel("Question*").fill(surveys.createAndSubmit.address.question);
|
||||
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
||||
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
|
||||
@@ -391,7 +392,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Ranking" }).click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.ranking.question);
|
||||
await page.getByLabel("Question*").fill(surveys.createAndSubmit.ranking.question);
|
||||
await page.getByPlaceholder("Option 1").click();
|
||||
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.ranking.choices[0]);
|
||||
await page.getByPlaceholder("Option 2").click();
|
||||
@@ -411,15 +412,20 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.getByRole("button", { name: "English" }).nth(1).click();
|
||||
await page.getByRole("button", { name: "German" }).click();
|
||||
|
||||
// Fill welcome card in german using rich text editor helper
|
||||
await helper.fillRichTextEditor(page, "Note*", surveys.germanCreate.welcomeCard.headline);
|
||||
await helper.fillRichTextEditor(page, "Welcome message", surveys.germanCreate.welcomeCard.description);
|
||||
// Fill welcome card in german
|
||||
await page.locator(".editor-input").click();
|
||||
await page.locator(".editor-input").fill(surveys.germanCreate.welcomeCard.description);
|
||||
await page.getByLabel("Note*").click();
|
||||
await page.getByLabel("Note*").fill(surveys.germanCreate.welcomeCard.headline);
|
||||
await page.getByPlaceholder("Next").click();
|
||||
await page.getByPlaceholder("Next").fill(surveys.germanCreate.welcomeCard.buttonLabel);
|
||||
|
||||
// Fill Open text question in german
|
||||
await page.getByRole("main").getByText("Free text").click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.openTextQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.openTextQuestion.question);
|
||||
await page.getByLabel("Placeholder").click();
|
||||
await page.getByLabel("Placeholder").fill(surveys.germanCreate.openTextQuestion.placeholder);
|
||||
await page.getByText("Show Advanced settings").first().click();
|
||||
@@ -428,7 +434,10 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
// Fill Single select question in german
|
||||
await page.getByRole("main").getByText("Single-Select").click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.singleSelectQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.singleSelectQuestion.question);
|
||||
await page.getByPlaceholder("Option 1").click();
|
||||
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").click();
|
||||
@@ -442,7 +451,10 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
// Fill Multi select question in german
|
||||
await page.getByRole("main").getByRole("heading", { name: "Multi-Select" }).click();
|
||||
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.multiSelectQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.multiSelectQuestion.question);
|
||||
await page.getByPlaceholder("Option 1").click();
|
||||
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.multiSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").click();
|
||||
@@ -457,7 +469,10 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
// Fill Picture select question in german
|
||||
await page.getByRole("main").getByText("Picture Selection").click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.pictureSelectQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.pictureSelectQuestion.question);
|
||||
await page.getByText("Show Advanced settings").first().click();
|
||||
await page.getByPlaceholder("Next").click();
|
||||
await page.getByPlaceholder("Next").fill(surveys.germanCreate.next);
|
||||
@@ -466,7 +481,10 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
// Fill Rating question in german
|
||||
await page.getByRole("main").getByText("Rating").click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.ratingQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.ratingQuestion.question);
|
||||
await page.getByPlaceholder("Not good").click();
|
||||
await page.getByPlaceholder("Not good").fill(surveys.germanCreate.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").click();
|
||||
@@ -477,7 +495,8 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
// Fill NPS question in german
|
||||
await page.getByRole("main").getByText("Net Promoter Score (NPS)").click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.npsQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.npsQuestion.question);
|
||||
await page.getByLabel("Lower Label").click();
|
||||
await page.getByLabel("Lower Label").fill(surveys.germanCreate.npsQuestion.lowLabel);
|
||||
await page.getByLabel("Upper Label").click();
|
||||
@@ -488,7 +507,10 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
// Fill Date question in german
|
||||
await page.getByRole("main").getByText("Date").click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.dateQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.dateQuestion.question);
|
||||
await page.getByText("Show Advanced settings").first().click();
|
||||
await page.getByPlaceholder("Next").click();
|
||||
await page.getByPlaceholder("Next").fill(surveys.germanCreate.next);
|
||||
@@ -497,7 +519,10 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
// Fill File upload question in german
|
||||
await page.getByRole("main").getByText("File Upload").click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.fileUploadQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.fileUploadQuestion.question);
|
||||
await page.getByText("Show Advanced settings").first().click();
|
||||
await page.getByPlaceholder("Next").click();
|
||||
await page.getByPlaceholder("Next").fill(surveys.germanCreate.next);
|
||||
@@ -506,7 +531,8 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
// Fill Matrix question in german
|
||||
await page.getByRole("main").getByText("Matrix").click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.matrix.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.matrix.question);
|
||||
await page.locator("#row-0").click();
|
||||
await page.locator("#row-0").fill(surveys.germanCreate.matrix.rows[0]);
|
||||
await page.locator("#row-1").click();
|
||||
@@ -529,7 +555,10 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
// Fill Address question in german
|
||||
await page.getByRole("main").getByText("Address").click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.addressQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.addressQuestion.question);
|
||||
await page.locator('[id="addressLine1\\.placeholder"]').click();
|
||||
await page
|
||||
.locator('[id="addressLine1\\.placeholder"]')
|
||||
@@ -560,7 +589,8 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
// Fill Ranking question in german
|
||||
await page.getByRole("main").getByText("Ranking").click();
|
||||
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.ranking.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.ranking.question);
|
||||
await page.getByPlaceholder("Option 1").click();
|
||||
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.ranking.choices[0]);
|
||||
await page.getByPlaceholder("Option 2").click();
|
||||
@@ -579,8 +609,12 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
|
||||
// Fill Thank you card in german
|
||||
await page.getByText("Ending card").first().click();
|
||||
await helper.fillRichTextEditor(page, "Note*", surveys.germanCreate.endingCard.headline);
|
||||
await helper.fillRichTextEditor(page, "Description", surveys.germanCreate.endingCard.description);
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.endingCard.headline);
|
||||
await page.getByPlaceholder("Your description here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your description here. Recall")
|
||||
.fill(surveys.germanCreate.endingCard.description);
|
||||
|
||||
await page.locator("#showButton").check();
|
||||
|
||||
@@ -610,8 +644,8 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
});
|
||||
|
||||
test.describe("Testing Survey with advanced logic", async () => {
|
||||
// 8 minutes
|
||||
test.setTimeout(1000 * 60 * 8);
|
||||
// 6 minutes
|
||||
test.setTimeout(1000 * 60 * 6);
|
||||
let url: string | null;
|
||||
|
||||
test("Create survey and submit response", async ({ page, users }) => {
|
||||
@@ -785,9 +819,9 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("cell", { name: "Roses – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Trees – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Ocean – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "This is my Matrix Question: Roses – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "This is my Matrix Question: Trees – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "This is my Matrix Question: Ocean – 0" }).locator("div").click();
|
||||
await page.locator("#questionCard-7").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// CTA Question
|
||||
|
||||
@@ -157,28 +157,6 @@ export const signupUsingInviteToken = async (page: Page, name: string, email: st
|
||||
await page.getByRole("button", { name: "Login with Email" }).click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to fill content into a rich text editor (contenteditable div).
|
||||
* The rich text editor uses a contenteditable div with class "editor-input" instead of a regular input.
|
||||
*
|
||||
* @param page - Playwright Page object
|
||||
* @param labelText - The label text to find the editor (e.g., "Note*", "Description")
|
||||
* @param content - The text content to fill into the editor
|
||||
*/
|
||||
export const fillRichTextEditor = async (page: Page, labelText: string, content: string): Promise<void> => {
|
||||
// Find the editor by locating the label and then finding the .editor-input within the same form group
|
||||
const label = page.locator(`label:has-text("${labelText}")`);
|
||||
const editorContainer = label.locator("..").locator("..");
|
||||
const editor = editorContainer.locator(".editor-input").first();
|
||||
|
||||
await editor.click();
|
||||
// Clear existing content by selecting all and deleting
|
||||
await editor.press("Meta+a"); // Cmd+A on Mac, Ctrl+A is handled automatically by Playwright
|
||||
await editor.press("Backspace");
|
||||
// Type the new content
|
||||
await editor.pressSequentially(content, { delay: 50 });
|
||||
};
|
||||
|
||||
export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
const addQuestion = "Add questionAdd a new question to your survey";
|
||||
|
||||
@@ -191,19 +169,16 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
await expect(page.locator("#welcome-toggle")).toBeVisible();
|
||||
await page.getByText("Welcome Card").click();
|
||||
await page.locator("#welcome-toggle").check();
|
||||
|
||||
// Use the helper function for rich text editors
|
||||
await fillRichTextEditor(page, "Note*", params.welcomeCard.headline);
|
||||
await fillRichTextEditor(page, "Welcome message", params.welcomeCard.description);
|
||||
|
||||
await page.getByLabel("Note*").fill(params.welcomeCard.headline);
|
||||
await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description);
|
||||
await page.getByText("Welcome CardOn").click();
|
||||
|
||||
// Open Text Question
|
||||
await page.getByRole("main").getByText("What would you like to know?").click();
|
||||
|
||||
await fillRichTextEditor(page, "Question*", params.openTextQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.openTextQuestion.question);
|
||||
await page.getByRole("button", { name: "Add description" }).click();
|
||||
await fillRichTextEditor(page, "Description", params.openTextQuestion.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||
|
||||
await page.locator("h3").filter({ hasText: params.openTextQuestion.question }).click();
|
||||
@@ -215,9 +190,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Single-Select" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.singleSelectQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.singleSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add description" }).click();
|
||||
await fillRichTextEditor(page, "Description", params.singleSelectQuestion.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.singleSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
||||
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
|
||||
@@ -229,9 +204,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.multiSelectQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.multiSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add description", exact: true }).click();
|
||||
await fillRichTextEditor(page, "Description", params.multiSelectQuestion.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.multiSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.multiSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
|
||||
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
|
||||
@@ -243,9 +218,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Rating" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.ratingQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.ratingQuestion.question);
|
||||
await page.getByRole("button", { name: "Add description", exact: true }).click();
|
||||
await fillRichTextEditor(page, "Description", params.ratingQuestion.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.ratingQuestion.description);
|
||||
await page.getByPlaceholder("Not good").fill(params.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
|
||||
|
||||
@@ -256,7 +231,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.npsQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.npsQuestion.question);
|
||||
await page.getByLabel("Lower label").fill(params.npsQuestion.lowLabel);
|
||||
await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel);
|
||||
|
||||
@@ -267,7 +242,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Statement (Call to Action)" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.ctaQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question);
|
||||
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
|
||||
|
||||
// Consent Question
|
||||
@@ -277,7 +252,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Consent" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.consentQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.consentQuestion.question);
|
||||
await page.getByPlaceholder("I agree to the terms and").fill(params.consentQuestion.checkboxLabel);
|
||||
|
||||
// Picture Select Question
|
||||
@@ -287,9 +262,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Picture Selection" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.pictureSelectQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.pictureSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add description" }).click();
|
||||
await fillRichTextEditor(page, "Description", params.pictureSelectQuestion.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.pictureSelectQuestion.description);
|
||||
|
||||
// Handle file uploads
|
||||
await uploadFileForFileUploadQuestion(page);
|
||||
@@ -301,7 +276,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "File Upload" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.fileUploadQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.fileUploadQuestion.question);
|
||||
|
||||
// Matrix Upload Question
|
||||
await page
|
||||
@@ -310,9 +285,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Matrix" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.matrix.question);
|
||||
await page.getByLabel("Question*").fill(params.matrix.question);
|
||||
await page.getByRole("button", { name: "Add description", exact: true }).click();
|
||||
await fillRichTextEditor(page, "Description", params.matrix.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.matrix.description);
|
||||
await page.locator("#row-0").click();
|
||||
await page.locator("#row-0").fill(params.matrix.rows[0]);
|
||||
await page.locator("#row-1").click();
|
||||
@@ -338,7 +313,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Address" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.address.question);
|
||||
await page.getByLabel("Question*").fill(params.address.question);
|
||||
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
||||
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
|
||||
@@ -352,7 +327,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Contact Info" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.contactInfo.question);
|
||||
await page.getByLabel("Question*").fill(params.contactInfo.question);
|
||||
await page.getByRole("row", { name: "Last Name" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Email" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "Phone" }).getByRole("switch").nth(1).click();
|
||||
@@ -365,7 +340,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Ranking" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.ranking.question);
|
||||
await page.getByLabel("Question*").fill(params.ranking.question);
|
||||
await page.getByPlaceholder("Option 1").click();
|
||||
await page.getByPlaceholder("Option 1").fill(params.ranking.choices[0]);
|
||||
await page.getByPlaceholder("Option 2").click();
|
||||
@@ -407,19 +382,16 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await expect(page.locator("#welcome-toggle")).toBeVisible();
|
||||
await page.getByText("Welcome Card").click();
|
||||
await page.locator("#welcome-toggle").check();
|
||||
|
||||
// Use the helper function for rich text editors
|
||||
await fillRichTextEditor(page, "Note*", params.welcomeCard.headline);
|
||||
await fillRichTextEditor(page, "Welcome message", params.welcomeCard.description);
|
||||
|
||||
await page.getByLabel("Note*").fill(params.welcomeCard.headline);
|
||||
await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description);
|
||||
await page.getByText("Welcome CardOn").click();
|
||||
|
||||
// Open Text Question
|
||||
await page.getByRole("main").getByText("What would you like to know?").click();
|
||||
|
||||
await fillRichTextEditor(page, "Question*", params.openTextQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.openTextQuestion.question);
|
||||
await page.getByRole("button", { name: "Add description" }).click();
|
||||
await fillRichTextEditor(page, "Description", params.openTextQuestion.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||
|
||||
await page.locator("h3").filter({ hasText: params.openTextQuestion.question }).click();
|
||||
@@ -431,9 +403,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Single-Select" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.singleSelectQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.singleSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add description" }).click();
|
||||
await fillRichTextEditor(page, "Description", params.singleSelectQuestion.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.singleSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
||||
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
|
||||
@@ -445,9 +417,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.multiSelectQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.multiSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add description" }).click();
|
||||
await fillRichTextEditor(page, "Description", params.multiSelectQuestion.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.multiSelectQuestion.description);
|
||||
await page.getByPlaceholder("Option 1").fill(params.multiSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
|
||||
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
|
||||
@@ -459,9 +431,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Picture Selection" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.pictureSelectQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.pictureSelectQuestion.question);
|
||||
await page.getByRole("button", { name: "Add description" }).click();
|
||||
await fillRichTextEditor(page, "Description", params.pictureSelectQuestion.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.pictureSelectQuestion.description);
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
const response1 = await fetch("https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg");
|
||||
const response2 = await fetch("https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg");
|
||||
@@ -488,9 +460,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Rating" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.ratingQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.ratingQuestion.question);
|
||||
await page.getByRole("button", { name: "Add description" }).click();
|
||||
await fillRichTextEditor(page, "Description", params.ratingQuestion.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.ratingQuestion.description);
|
||||
await page.getByPlaceholder("Not good").fill(params.ratingQuestion.lowLabel);
|
||||
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
|
||||
|
||||
@@ -501,7 +473,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.npsQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.npsQuestion.question);
|
||||
await page.getByLabel("Lower label").fill(params.npsQuestion.lowLabel);
|
||||
await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel);
|
||||
|
||||
@@ -512,7 +484,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Ranking" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.ranking.question);
|
||||
await page.getByLabel("Question*").fill(params.ranking.question);
|
||||
await page.getByPlaceholder("Option 1").click();
|
||||
await page.getByPlaceholder("Option 1").fill(params.ranking.choices[0]);
|
||||
await page.getByPlaceholder("Option 2").click();
|
||||
@@ -534,9 +506,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Matrix" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.matrix.question);
|
||||
await page.getByLabel("Question*").fill(params.matrix.question);
|
||||
await page.getByRole("button", { name: "Add description" }).click();
|
||||
await fillRichTextEditor(page, "Description", params.matrix.description);
|
||||
await page.locator('input[name="subheader"]').fill(params.matrix.description);
|
||||
await page.locator("#row-0").click();
|
||||
await page.locator("#row-0").fill(params.matrix.rows[0]);
|
||||
await page.locator("#row-1").click();
|
||||
@@ -562,7 +534,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Statement (Call to Action)" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.ctaQuestion.question);
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question);
|
||||
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
|
||||
|
||||
// Consent Question
|
||||
@@ -572,7 +544,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Consent" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.consentQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.consentQuestion.question);
|
||||
await page.getByPlaceholder("I agree to the terms and").fill(params.consentQuestion.checkboxLabel);
|
||||
|
||||
// File Upload Question
|
||||
@@ -582,7 +554,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "File Upload" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.fileUploadQuestion.question);
|
||||
await page.getByLabel("Question*").fill(params.fileUploadQuestion.question);
|
||||
|
||||
// Date Question
|
||||
await page
|
||||
@@ -591,7 +563,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Date" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.date.question);
|
||||
await page.getByLabel("Question*").fill(params.date.question);
|
||||
|
||||
// Cal Question
|
||||
await page
|
||||
@@ -600,7 +572,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Schedule a meeting" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.cal.question);
|
||||
await page.getByLabel("Question*").fill(params.cal.question);
|
||||
|
||||
// Fill Address Question
|
||||
await page
|
||||
@@ -609,7 +581,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Address" }).click();
|
||||
await fillRichTextEditor(page, "Question*", params.address.question);
|
||||
await page.getByLabel("Question*").fill(params.address.question);
|
||||
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
|
||||
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
||||
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
|
||||
|
||||
@@ -192,7 +192,6 @@
|
||||
"icon": "lightbulb",
|
||||
"pages": [
|
||||
"xm-and-surveys/xm/best-practices/contact-form",
|
||||
"xm-and-surveys/xm/best-practices/headless-surveys",
|
||||
"xm-and-surveys/xm/best-practices/docs-feedback",
|
||||
"xm-and-surveys/xm/best-practices/feature-chaser",
|
||||
"xm-and-surveys/xm/best-practices/feedback-box",
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
---
|
||||
title: "Headless Surveys"
|
||||
icon: "pen"
|
||||
description: "Using Formbricks as a Headless Survey Platform"
|
||||
---
|
||||
|
||||
This document shows how you can use Formbricks to manage survey definitions and response collection via APIs, while rendering the surveys in your own frontend and forwarding the response data to your own analytics pipelines.
|
||||
|
||||
## In a nutshell
|
||||
|
||||
### What Formbricks handles:
|
||||
|
||||
1. **Survey Management:** Create, update, and host survey definitions through the Formbricks Management API or dashboard.
|
||||
2. **Response Handling:** Receive and securely store responses via the Client or Management API.
|
||||
3. **Webhooks Delivery:** Send real-time response data to your configured endpoints when responses are created, updated, or completed.
|
||||
|
||||
### What you handle:
|
||||
|
||||
1. **Custom Survey Wrapper / UI:** Build your own front-end package that fetches the survey (via API or local cache), renders it, and captures user responses.
|
||||
2. **Analysis & Reporting:** Process incoming webhook data or fetched responses in your own analytics, data warehouse, or visualization tools. You can still make use of Formbricks to view Survey stats and data, but any type of custom dashboards is currently not supported.
|
||||
|
||||
## Why choose this approach?
|
||||
|
||||
1. **Your UI, your brand:** You take full control of survey look, feel, transitions, validations, and logic in your application stack.
|
||||
2. **Separation of concerns:** Formbricks functions like a specialized “Backend-as-a-Service” for survey schemas and response handling; you control the frontend and analytics.
|
||||
3. **OSS, self-hostable**: With Formbricks being open source, you can self-host without vendor lock-in.
|
||||
|
||||
|
||||
## Core components
|
||||
|
||||
1. **Formbricks Backend:** Use the Formbricks app or Management API to create surveys (questions, flows, locales, validations).
|
||||
2. **Your UI Survey Package:** Renders your custom UI, collects the data and sends to Formbricks backend using Formbricks API. For inspiration, you can start looking [here](https://github.com/formbricks/formbricks/tree/main/packages/surveys). With an active Enterprise license you can even fork our surveys package, make changes and keep them private to your organization (freed from AGPL obligation to also release your changes under AGPL)
|
||||
3. **Webhook Integration:** Using in-built Webhook integration forward the data to your Analysis tool or Data warehouse.
|
||||
4. **Your Analysis Tool / Data Warehouse:** Receive all the data from Formbricks integration and process it for analysis.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### **Create Survey with Formbricks:**
|
||||
|
||||
Create a survey in Formbricks (UI) or programmatically via the Management API. Read more about the API endpoint [here](https://formbricks.com/docs/api-reference/management-api--survey/create-survey).
|
||||
Returns: Full survey object with id, schema, and configuration.
|
||||
⚠️ Backend only: Requires API key \- call from your server, not client-side.
|
||||
|
||||
```javascript
|
||||
POST /api/v1/management/surveys
|
||||
Headers:
|
||||
x-api-key: <your-api-key>
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"environmentId": "your-environment-id",
|
||||
"type": "link",
|
||||
"name": "Customer Feedback Survey",
|
||||
"questions": [
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### **Fetch Survey Schema:**
|
||||
|
||||
Get the survey schema using the Formbricks API. Read more about the API endpoint [here](https://formbricks.com/docs/api-reference/management-api--survey/get-survey-by-id).
|
||||
|
||||
GET /api/v1/management/surveys/{surveyId}
|
||||
Headers: x-api-key: \<your-api-key\>
|
||||
|
||||
####
|
||||
|
||||
**Returns**: Complete survey JSON schema including:
|
||||
|
||||
- Questions array with types, logic, and validation
|
||||
- Display settings and styling
|
||||
- Languages and translations
|
||||
- Branching/skip logic
|
||||
- Thank you pages and redirects
|
||||
|
||||
#### **Implementation Options:**
|
||||
|
||||
**Option A (Live)**: Your backend fetches at runtime and serves to your UI
|
||||
|
||||
1. Fresh data on every request
|
||||
2. Requires backend proxy endpoint
|
||||
|
||||
⚠️ Backend only: API key required, cannot be called from browser.
|
||||
|
||||
**Optional:**: Store survey JSON in your CDN/storage
|
||||
|
||||
1. Faster client load times
|
||||
2. Periodically refresh from Management API
|
||||
3. Best for high-traffic scenarios
|
||||
|
||||
⚠️ Backend only: API key required, cannot be called from browser.
|
||||
|
||||
**Option B (Client Environment API)**: You can fetch all the survey schema and surveys from the Client side using the Client Environment API. However, this only works for Website & App surveys since they are the only ones that are made public on the Client API for our SDK to pull into an app. Make sure that:
|
||||
|
||||
1. Survey type: Website & App
|
||||
2. Recontact Options: Overwrite Global Waiting Time & Always show
|
||||
3. Targeting: None
|
||||
|
||||
These are **necessary requirements** for the survey to show up in the endpoint.
|
||||
|
||||
More about the Endpoint [here](https://formbricks.com/docs/api-reference/client-api--environment/get-environment-state).
|
||||
|
||||
```javascript
|
||||
GET /api/v1/client/{environmentId}/environment
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"data": {
|
||||
"actionClasses": [
|
||||
{ ... },
|
||||
{ ... }
|
||||
],
|
||||
"project": {
|
||||
"id": "<project_id>",
|
||||
...
|
||||
},
|
||||
"surveys": [
|
||||
{
|
||||
"id": "<survey_id>",
|
||||
"name": "Start from scratch",
|
||||
"status": "inProgress",
|
||||
"question": "What would you like to know?",
|
||||
"trigger": "code action",
|
||||
"ending": "Thank you! We appreciate your feedback."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Render Survey with Your Custom UI:**
|
||||
|
||||
Your frontend receives the survey JSON and renders it using your own UI components.
|
||||
|
||||
For inspiration, you can start looking [here](https://github.com/formbricks/formbricks/tree/main/packages/surveys). With an active Enterprise license you can even fork our surveys package, make changes and keep them private to your organization (freed from AGPL obligation to also release your changes under AGPL)
|
||||
|
||||
* Question rendering based on type (openText, multipleChoiceSingle, rating, etc.)
|
||||
* Skip logic and conditional branching
|
||||
* Input validation
|
||||
* Progress tracking
|
||||
* Custom styling and branding
|
||||
|
||||
### **Submit Responses to Formbricks:**
|
||||
|
||||
#### **Client-side Submission (Recommended):**
|
||||
|
||||
Post responses directly from the browser to Formbricks Client API. Read more about it [here](https://formbricks.com/docs/api-reference/client-api--response/create-response).
|
||||
✅ No authentication required \- Safe for browser/mobile apps.
|
||||
|
||||
```javascript
|
||||
POST /api/v1/client/{environmentId}/responses
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"surveyId": "survey-xyz",
|
||||
"data": {
|
||||
"question-id-1": "Customer's answer",
|
||||
"question-id-2": 5,
|
||||
"question-id-3": ["option1", "option2"]
|
||||
},
|
||||
"finished": true
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
#### **Server-side Submission (Alternative):**
|
||||
|
||||
Proxy responses through your backend.
|
||||
Use when: You need server-side validation, PII handling, or response enrichment before storage.
|
||||
|
||||
### **Consume Analytics & Response Data:**
|
||||
|
||||
#### **Option A: Real-time Webhooks (Recommended):**
|
||||
|
||||
Configure webhooks in Formbricks to push response data to your system. Read more about Webhooks [here](https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhooks).
|
||||
|
||||
1. Go to Formbricks Settings → Webhooks
|
||||
2. Add your endpoint URL: https://your-domain.com/webhooks/formbricks
|
||||
3. Select triggers:
|
||||
* responseCreated \- New response started
|
||||
* responseUpdated \- Response in progress
|
||||
* responseFinished \- Response completed
|
||||
|
||||
**Webhook payload example:**
|
||||
|
||||
```javascript
|
||||
{
|
||||
"event": "responseFinished",
|
||||
"data": {
|
||||
"id": "response-123",
|
||||
"surveyId": "survey-xyz",
|
||||
"data": {
|
||||
"question-id-1": "answer"
|
||||
...
|
||||
},
|
||||
"createdAt": "2025-01-15T10:30:00Z",
|
||||
"finished": true
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Forward to your analytics tool, data warehouse, or CRM in real-time.
|
||||
|
||||
#### **Option B: Pull from API on Demand:**
|
||||
|
||||
Fetch responses periodically from your backend, read more about the Endpoint [here](https://formbricks.com/docs/api-reference/management-api--response/get-survey-responses).
|
||||
|
||||
```javascript
|
||||
GET /api/v1/management/responses?surveyId={surveyId}
|
||||
Headers:
|
||||
x-api-key: <your-api-key>
|
||||
```
|
||||
|
||||
8
infra/.envrc
Normal file
8
infra/.envrc
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This is a better (faster) alternative to the built-in Nix support
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4="
|
||||
fi
|
||||
|
||||
use flake
|
||||
3
infra/.gitignore
vendored
Normal file
3
infra/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.terraform/
|
||||
builds
|
||||
/.direnv/
|
||||
61
infra/flake.lock
generated
Normal file
61
infra/flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1754767907,
|
||||
"narHash": "sha256-8OnUzRQZkqtUol9vuUuQC30hzpMreKptNyET2T9lB6g=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c5f08b62ed75415439d48152c2a784e36909b1bc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
30
infra/flake.nix
Normal file
30
infra/flake.nix
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
in
|
||||
with pkgs;
|
||||
{
|
||||
devShells.default = mkShell {
|
||||
buildInputs = [
|
||||
awscli
|
||||
terraform
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
33
infra/formbricks-cloud-helm/helmfile.yaml.gotmpl
Normal file
33
infra/formbricks-cloud-helm/helmfile.yaml.gotmpl
Normal file
@@ -0,0 +1,33 @@
|
||||
repositories:
|
||||
- name: helm-charts
|
||||
url: ghcr.io/formbricks/helm-charts
|
||||
oci: true
|
||||
|
||||
releases:
|
||||
- name: formbricks
|
||||
namespace: formbricks
|
||||
chart: helm-charts/formbricks
|
||||
version: ^3.0.0
|
||||
values:
|
||||
- values.yaml.gotmpl
|
||||
set:
|
||||
- name: deployment.image.tag
|
||||
value: {{ requiredEnv "VERSION" }}
|
||||
- name: deployment.image.repository
|
||||
value: {{ requiredEnv "REPOSITORY" }}
|
||||
labels:
|
||||
environment: prod
|
||||
- name: formbricks-stage
|
||||
namespace: formbricks-stage
|
||||
chart: helm-charts/formbricks
|
||||
version: ^3.0.0
|
||||
values:
|
||||
- values-staging.yaml.gotmpl
|
||||
createNamespace: true
|
||||
set:
|
||||
- name: deployment.image.tag
|
||||
value: {{ requiredEnv "VERSION" }}
|
||||
- name: deployment.image.repository
|
||||
value: {{ requiredEnv "REPOSITORY" }}
|
||||
labels:
|
||||
environment: stage
|
||||
91
infra/formbricks-cloud-helm/values-staging.yaml.gotmpl
Normal file
91
infra/formbricks-cloud-helm/values-staging.yaml.gotmpl
Normal file
@@ -0,0 +1,91 @@
|
||||
nameOverride: "formbricks-stage"
|
||||
## Deployment & Autoscaling
|
||||
deployment:
|
||||
image:
|
||||
pullPolicy: Always
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 1
|
||||
memory: 1Gi
|
||||
env:
|
||||
RATE_LIMITING_DISABLED:
|
||||
value: "1"
|
||||
envFrom:
|
||||
app-env:
|
||||
nameSuffix: app-env
|
||||
type: secret
|
||||
nodeSelector:
|
||||
karpenter.sh/capacity-type: spot
|
||||
reloadOnChange: true
|
||||
autoscaling:
|
||||
enabled: true
|
||||
maxReplicas: 95
|
||||
minReplicas: 3
|
||||
metrics:
|
||||
- resource:
|
||||
name: cpu
|
||||
target:
|
||||
averageUtilization: 60
|
||||
type: Utilization
|
||||
type: Resource
|
||||
- resource:
|
||||
name: memory
|
||||
target:
|
||||
averageUtilization: 60
|
||||
type: Utilization
|
||||
type: Resource
|
||||
|
||||
### Secrets
|
||||
secret:
|
||||
enabled: false
|
||||
externalSecret:
|
||||
enabled: true
|
||||
files:
|
||||
app-env:
|
||||
dataFrom:
|
||||
key: stage/formbricks/environment
|
||||
app-secrets:
|
||||
dataFrom:
|
||||
key: stage/formbricks/secrets
|
||||
refreshInterval: 1m
|
||||
secretStore:
|
||||
kind: ClusterSecretStore
|
||||
name: aws-secrets-manager
|
||||
|
||||
## Ingress
|
||||
ingress:
|
||||
annotations:
|
||||
alb.ingress.kubernetes.io/certificate-arn: {{ requiredEnv "FORMBRICKS_INGRESS_CERT_ARN" }}
|
||||
alb.ingress.kubernetes.io/group.name: internal
|
||||
alb.ingress.kubernetes.io/healthcheck-path: /health
|
||||
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
|
||||
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-Res-2021-06
|
||||
alb.ingress.kubernetes.io/ssl-redirect: "443"
|
||||
alb.ingress.kubernetes.io/target-type: ip
|
||||
enabled: true
|
||||
hosts:
|
||||
- host: stage.app.formbricks.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
serviceName: formbricks-stage
|
||||
ingressClassName: alb
|
||||
|
||||
## RBAC
|
||||
rbac:
|
||||
enabled: true
|
||||
serviceAccount:
|
||||
annotations:
|
||||
eks.amazonaws.com/role-arn: {{ requiredEnv "FORMBRICKS_ROLE_ARN" }}
|
||||
additionalLabels: {}
|
||||
enabled: true
|
||||
name: formbricks-stage
|
||||
|
||||
## Dependencies
|
||||
postgresql:
|
||||
enabled: false
|
||||
redis:
|
||||
enabled: false
|
||||
92
infra/formbricks-cloud-helm/values.yaml.gotmpl
Normal file
92
infra/formbricks-cloud-helm/values.yaml.gotmpl
Normal file
@@ -0,0 +1,92 @@
|
||||
## Deployment & Autoscaling
|
||||
deployment:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 1
|
||||
memory: 1Gi
|
||||
env: {}
|
||||
envFrom:
|
||||
app-env:
|
||||
nameSuffix: app-env
|
||||
type: secret
|
||||
nodeSelector:
|
||||
karpenter.sh/capacity-type: on-demand
|
||||
reloadOnChange: true
|
||||
autoscaling:
|
||||
enabled: true
|
||||
maxReplicas: 95
|
||||
minReplicas: 3
|
||||
metrics:
|
||||
- resource:
|
||||
name: cpu
|
||||
target:
|
||||
averageUtilization: 60
|
||||
type: Utilization
|
||||
type: Resource
|
||||
- resource:
|
||||
name: memory
|
||||
target:
|
||||
averageUtilization: 60
|
||||
type: Utilization
|
||||
type: Resource
|
||||
|
||||
### Secrets
|
||||
secret:
|
||||
enabled: false
|
||||
externalSecret:
|
||||
enabled: true
|
||||
files:
|
||||
app-env:
|
||||
dataFrom:
|
||||
key: prod/formbricks/environment
|
||||
app-secrets:
|
||||
dataFrom:
|
||||
key: prod/formbricks/secrets
|
||||
refreshInterval: 1m
|
||||
secretStore:
|
||||
kind: ClusterSecretStore
|
||||
name: aws-secrets-manager
|
||||
|
||||
## Ingress
|
||||
ingress:
|
||||
annotations:
|
||||
alb.ingress.kubernetes.io/certificate-arn: {{ requiredEnv "FORMBRICKS_INGRESS_CERT_ARN" }}
|
||||
alb.ingress.kubernetes.io/group.name: formbricks
|
||||
alb.ingress.kubernetes.io/healthcheck-path: /health
|
||||
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
|
||||
alb.ingress.kubernetes.io/scheme: internet-facing
|
||||
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06
|
||||
alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=600,client_keep_alive.seconds=590
|
||||
alb.ingress.kubernetes.io/ssl-redirect: "443"
|
||||
alb.ingress.kubernetes.io/target-type: ip
|
||||
enabled: true
|
||||
hosts:
|
||||
- host: app.k8s.formbricks.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
serviceName: formbricks
|
||||
- host: app.formbricks.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
serviceName: formbricks
|
||||
ingressClassName: alb
|
||||
|
||||
## RBAC
|
||||
rbac:
|
||||
enabled: true
|
||||
serviceAccount:
|
||||
annotations:
|
||||
eks.amazonaws.com/role-arn: {{ requiredEnv "FORMBRICKS_ROLE_ARN" }}
|
||||
additionalLabels: {}
|
||||
enabled: true
|
||||
name: formbricks
|
||||
|
||||
## Dependencies
|
||||
postgresql:
|
||||
enabled: false
|
||||
redis:
|
||||
enabled: false
|
||||
205
infra/terraform/.terraform.lock.hcl
generated
Normal file
205
infra/terraform/.terraform.lock.hcl
generated
Normal file
@@ -0,0 +1,205 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "5.100.0"
|
||||
constraints = ">= 3.29.0, >= 4.0.0, >= 4.8.0, >= 4.33.0, >= 4.36.0, >= 4.47.0, >= 4.63.0, >= 5.0.0, >= 5.46.0, >= 5.73.0, >= 5.79.0, >= 5.81.0, >= 5.83.0, >= 5.86.0, >= 5.95.0, < 6.0.0"
|
||||
hashes = [
|
||||
"h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=",
|
||||
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
|
||||
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
|
||||
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
|
||||
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
|
||||
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
|
||||
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
|
||||
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
|
||||
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
|
||||
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
|
||||
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
|
||||
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
|
||||
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
|
||||
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/cloudinit" {
|
||||
version = "2.3.7"
|
||||
constraints = ">= 2.0.0"
|
||||
hashes = [
|
||||
"h1:M9TpQxKAE/hyOwytdX9MUNZw30HoD/OXqYIug5fkqH8=",
|
||||
"zh:06f1c54e919425c3139f8aeb8fcf9bceca7e560d48c9f0c1e3bb0a8ad9d9da1e",
|
||||
"zh:0e1e4cf6fd98b019e764c28586a386dc136129fef50af8c7165a067e7e4a31d5",
|
||||
"zh:1871f4337c7c57287d4d67396f633d224b8938708b772abfc664d1f80bd67edd",
|
||||
"zh:2b9269d91b742a71b2248439d5e9824f0447e6d261bfb86a8a88528609b136d1",
|
||||
"zh:3d8ae039af21426072c66d6a59a467d51f2d9189b8198616888c1b7fc42addc7",
|
||||
"zh:3ef4e2db5bcf3e2d915921adced43929214e0946a6fb11793085d9a48995ae01",
|
||||
"zh:42ae54381147437c83cbb8790cc68935d71b6357728a154109d3220b1beb4dc9",
|
||||
"zh:4496b362605ae4cbc9ef7995d102351e2fe311897586ffc7a4a262ccca0c782a",
|
||||
"zh:652a2401257a12706d32842f66dac05a735693abcb3e6517d6b5e2573729ba13",
|
||||
"zh:7406c30806f5979eaed5f50c548eced2ea18ea121e01801d2f0d4d87a04f6a14",
|
||||
"zh:7848429fd5a5bcf35f6fee8487df0fb64b09ec071330f3ff240c0343fe2a5224",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/external" {
|
||||
version = "2.3.5"
|
||||
constraints = ">= 1.0.0"
|
||||
hashes = [
|
||||
"h1:FnUk98MI5nOh3VJ16cHf8mchQLewLfN1qZG/MqNgPrI=",
|
||||
"zh:6e89509d056091266532fa64de8c06950010498adf9070bf6ff85bc485a82562",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:86868aec05b58dc0aa1904646a2c26b9367d69b890c9ad70c33c0d3aa7b1485a",
|
||||
"zh:a2ce38fda83a62fa5fb5a70e6ca8453b168575feb3459fa39803f6f40bd42154",
|
||||
"zh:a6c72798f4a9a36d1d1433c0372006cc9b904e8cfd60a2ae03ac5b7d2abd2398",
|
||||
"zh:a8a3141d2fc71c86bf7f3c13b0b3be8a1b0f0144a47572a15af4dfafc051e28a",
|
||||
"zh:aa20a1242eb97445ad26ebcfb9babf2cd675bdb81cac5f989268ebefa4ef278c",
|
||||
"zh:b58a22445fb8804e933dcf835ab06c29a0f33148dce61316814783ee7f4e4332",
|
||||
"zh:cb5626a661ee761e0576defb2a2d75230a3244799d380864f3089c66e99d0dcc",
|
||||
"zh:d1acb00d20445f682c4e705c965e5220530209c95609194c2dc39324f3d4fcce",
|
||||
"zh:d91a254ba77b69a29d8eae8ed0e9367cbf0ea6ac1a85b58e190f8cb096a40871",
|
||||
"zh:f6592327673c9f85cdb6f20336faef240abae7621b834f189c4a62276ea5db41",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/helm" {
|
||||
version = "2.17.0"
|
||||
constraints = ">= 2.9.0, ~> 2.17, < 3.0.0"
|
||||
hashes = [
|
||||
"h1:kQMkcPVvHOguOqnxoEU2sm1ND9vCHiT8TvZ2x6v/Rsw=",
|
||||
"zh:06fb4e9932f0afc1904d2279e6e99353c2ddac0d765305ce90519af410706bd4",
|
||||
"zh:104eccfc781fc868da3c7fec4385ad14ed183eb985c96331a1a937ac79c2d1a7",
|
||||
"zh:129345c82359837bb3f0070ce4891ec232697052f7d5ccf61d43d818912cf5f3",
|
||||
"zh:3956187ec239f4045975b35e8c30741f701aa494c386aaa04ebabffe7749f81c",
|
||||
"zh:66a9686d92a6b3ec43de3ca3fde60ef3d89fb76259ed3313ca4eb9bb8c13b7dd",
|
||||
"zh:88644260090aa621e7e8083585c468c8dd5e09a3c01a432fb05da5c4623af940",
|
||||
"zh:a248f650d174a883b32c5b94f9e725f4057e623b00f171936dcdcc840fad0b3e",
|
||||
"zh:aa498c1f1ab93be5c8fbf6d48af51dc6ef0f10b2ea88d67bcb9f02d1d80d3930",
|
||||
"zh:bf01e0f2ec2468c53596e027d376532a2d30feb72b0b5b810334d043109ae32f",
|
||||
"zh:c46fa84cc8388e5ca87eb575a534ebcf68819c5a5724142998b487cb11246654",
|
||||
"zh:d0c0f15ffc115c0965cbfe5c81f18c2e114113e7a1e6829f6bfd879ce5744fbb",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/kubernetes" {
|
||||
version = "2.38.0"
|
||||
constraints = ">= 2.20.0, ~> 2.36"
|
||||
hashes = [
|
||||
"h1:soK8Lt0SZ6dB+HsypFRDzuX/npqlMU6M0fvyaR1yW0k=",
|
||||
"zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0",
|
||||
"zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f",
|
||||
"zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b",
|
||||
"zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12",
|
||||
"zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2",
|
||||
"zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc",
|
||||
"zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15",
|
||||
"zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396",
|
||||
"zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d",
|
||||
"zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/local" {
|
||||
version = "2.5.3"
|
||||
constraints = ">= 1.0.0"
|
||||
hashes = [
|
||||
"h1:MCzg+hs1/ZQ32u56VzJMWP9ONRQPAAqAjuHuzbyshvI=",
|
||||
"zh:284d4b5b572eacd456e605e94372f740f6de27b71b4e1fd49b63745d8ecd4927",
|
||||
"zh:40d9dfc9c549e406b5aab73c023aa485633c1b6b730c933d7bcc2fa67fd1ae6e",
|
||||
"zh:6243509bb208656eb9dc17d3c525c89acdd27f08def427a0dce22d5db90a4c8b",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:885d85869f927853b6fe330e235cd03c337ac3b933b0d9ae827ec32fa1fdcdbf",
|
||||
"zh:bab66af51039bdfcccf85b25fe562cbba2f54f6b3812202f4873ade834ec201d",
|
||||
"zh:c505ff1bf9442a889ac7dca3ac05a8ee6f852e0118dd9a61796a2f6ff4837f09",
|
||||
"zh:d36c0b5770841ddb6eaf0499ba3de48e5d4fc99f4829b6ab66b0fab59b1aaf4f",
|
||||
"zh:ddb6a407c7f3ec63efb4dad5f948b54f7f4434ee1a2607a49680d494b1776fe1",
|
||||
"zh:e0dafdd4500bec23d3ff221e3a9b60621c5273e5df867bc59ef6b7e41f5c91f6",
|
||||
"zh:ece8742fd2882a8fc9d6efd20e2590010d43db386b920b2a9c220cfecc18de47",
|
||||
"zh:f4c6b3eb8f39105004cf720e202f04f57e3578441cfb76ca27611139bc116a82",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/null" {
|
||||
version = "3.2.4"
|
||||
constraints = ">= 2.0.0, >= 3.0.0"
|
||||
hashes = [
|
||||
"h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=",
|
||||
"zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43",
|
||||
"zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a",
|
||||
"zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991",
|
||||
"zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f",
|
||||
"zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e",
|
||||
"zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615",
|
||||
"zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442",
|
||||
"zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5",
|
||||
"zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f",
|
||||
"zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/random" {
|
||||
version = "3.7.2"
|
||||
constraints = ">= 2.0.0, >= 3.6.0"
|
||||
hashes = [
|
||||
"h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
|
||||
"zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
|
||||
"zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
|
||||
"zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab",
|
||||
"zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3",
|
||||
"zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212",
|
||||
"zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34",
|
||||
"zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967",
|
||||
"zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d",
|
||||
"zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62",
|
||||
"zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/time" {
|
||||
version = "0.13.1"
|
||||
constraints = ">= 0.9.0"
|
||||
hashes = [
|
||||
"h1:ZT5ppCNIModqk3iOkVt5my8b8yBHmDpl663JtXAIRqM=",
|
||||
"zh:02cb9aab1002f0f2a94a4f85acec8893297dc75915f7404c165983f720a54b74",
|
||||
"zh:04429b2b31a492d19e5ecf999b116d396dac0b24bba0d0fb19ecaefe193fdb8f",
|
||||
"zh:26f8e51bb7c275c404ba6028c1b530312066009194db721a8427a7bc5cdbc83a",
|
||||
"zh:772ff8dbdbef968651ab3ae76d04afd355c32f8a868d03244db3f8496e462690",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:898db5d2b6bd6ca5457dccb52eedbc7c5b1a71e4a4658381bcbb38cedbbda328",
|
||||
"zh:8de913bf09a3fa7bedc29fec18c47c571d0c7a3d0644322c46f3aa648cf30cd8",
|
||||
"zh:9402102c86a87bdfe7e501ffbb9c685c32bbcefcfcf897fd7d53df414c36877b",
|
||||
"zh:b18b9bb1726bb8cfbefc0a29cf3657c82578001f514bcf4c079839b6776c47f0",
|
||||
"zh:b9d31fdc4faecb909d7c5ce41d2479dd0536862a963df434be4b16e8e4edc94d",
|
||||
"zh:c951e9f39cca3446c060bd63933ebb89cedde9523904813973fbc3d11863ba75",
|
||||
"zh:e5b773c0d07e962291be0e9b413c7a22c044b8c7b58c76e8aa91d1659990dfb5",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/tls" {
|
||||
version = "4.1.0"
|
||||
constraints = ">= 3.0.0"
|
||||
hashes = [
|
||||
"h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
|
||||
"zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
|
||||
"zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",
|
||||
"zh:35808142ef850c0c60dd93dc06b95c747720ed2c40c89031781165f0c2baa2fc",
|
||||
"zh:35b5dc95bc75f0b3b9c5ce54d4d7600c1ebc96fbb8dfca174536e8bf103c8cdc",
|
||||
"zh:38aa27c6a6c98f1712aa5cc30011884dc4b128b4073a4a27883374bfa3ec9fac",
|
||||
"zh:51fb247e3a2e88f0047cb97bb9df7c228254a3b3021c5534e4563b4007e6f882",
|
||||
"zh:62b981ce491e38d892ba6364d1d0cdaadcee37cc218590e07b310b1dfa34be2d",
|
||||
"zh:bc8e47efc611924a79f947ce072a9ad698f311d4a60d0b4dfff6758c912b7298",
|
||||
"zh:c149508bd131765d1bc085c75a870abb314ff5a6d7f5ac1035a8892d686b6297",
|
||||
"zh:d38d40783503d278b63858978d40e07ac48123a2925e1a6b47e62179c046f87a",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:fb07f708e3316615f6d218cec198504984c0ce7000b9f1eebff7516e384f4b54",
|
||||
]
|
||||
}
|
||||
177
infra/terraform/bootstrap.tf
Normal file
177
infra/terraform/bootstrap.tf
Normal file
@@ -0,0 +1,177 @@
|
||||
# ################################################################################
|
||||
# # GitOps Bridge: Bootstrap
|
||||
# ################################################################################
|
||||
# locals {
|
||||
# addons = {
|
||||
# enable_cert_manager = true
|
||||
# enable_external_dns = true
|
||||
# enable_istio = false
|
||||
# enable_istio_ingress = false
|
||||
# enable_external_secrets = true
|
||||
# enable_metrics_server = false
|
||||
# enable_keda = false
|
||||
# enable_aws_load_balancer_controller = true
|
||||
# enable_aws_ebs_csi_resources = false
|
||||
# enable_velero = false
|
||||
# enable_observability = false
|
||||
# enable_karpenter = true
|
||||
# }
|
||||
#
|
||||
# addons_default_versions = {
|
||||
# cert_manager = "v1.17.1"
|
||||
# external_dns = "1.15.2"
|
||||
# karpenter = "1.3.0"
|
||||
# external_secrets = "0.14.3"
|
||||
# aws_load_balancer_controller = "1.10.0"
|
||||
# # keda = "2.16.0"
|
||||
# # istio = "1.23.3"
|
||||
# }
|
||||
#
|
||||
# addons_metadata = merge(
|
||||
# # module.addons.gitops_metadata
|
||||
# {
|
||||
# aws_cluster_name = module.eks.cluster_name
|
||||
# aws_region = data.aws_region.selected.name
|
||||
# aws_account_id = data.aws_caller_identity.current.account_id
|
||||
# aws_vpc_id = module.vpc.vpc_id
|
||||
# }
|
||||
# )
|
||||
#
|
||||
# argocd_apps = {
|
||||
# eks-addons = {
|
||||
# project = "default"
|
||||
# repo_url = var.addons_repo_url
|
||||
# target_revision = var.addons_target_revision
|
||||
# addons_repo_revision = var.addons_target_revision
|
||||
# path = var.addons_repo_path
|
||||
# values = merge({
|
||||
# addons_repo_revision = var.addons_target_revision
|
||||
# certManager = {
|
||||
# enabled = local.addons.enable_cert_manager
|
||||
# iamRoleArn = try(module.addons.gitops_metadata.cert_manager_iam_role_arn, "")
|
||||
# values = try(yamldecode(join("\n", var.cert_manager_helm_config.values)), {})
|
||||
# chartVersion = try(var.cert_manager_helm_config.chart_version, local.addons_default_versions.cert_manager)
|
||||
# }
|
||||
# externalDNS = {
|
||||
# enabled = local.addons.enable_external_dns
|
||||
# iamRoleArn = try(module.addons.gitops_metadata.external_dns_iam_role_arn, "")
|
||||
# values = try(yamldecode(join("\n", var.external_dns_helm_config.values)), {})
|
||||
# chartVersion = try(var.external_dns_helm_config.chart_version, local.addons_default_versions.external_dns)
|
||||
# }
|
||||
# externalSecrets = {
|
||||
# enabled = local.addons.enable_external_secrets
|
||||
# iamRoleArn = try(module.addons.gitops_metadata.external_secrets_iam_role_arn, "")
|
||||
# values = try(yamldecode(join("\n", var.external_secrets_helm_config.values)), {})
|
||||
# chartVersion = try(var.external_secrets_helm_config.chart_version, local.addons_default_versions.external_secrets)
|
||||
# }
|
||||
# karpenter = {
|
||||
# enabled = true
|
||||
# iamRoleArn = try(module.addons.gitops_metadata.karpenter_iam_role_arn, "")
|
||||
# values = try(yamldecode(join("\n", var.karpenter_helm_config.values)), {})
|
||||
# chartVersion = try(var.karpenter_helm_config.chart_version, local.addons_default_versions.karpenter)
|
||||
# enableCrdWebhookConfig = true
|
||||
# clusterName = module.eks.cluster_name
|
||||
# clusterEndpoint = module.eks.cluster_endpoint
|
||||
# interruptionQueue = try(module.addons.gitops_metadata.karpenter_interruption_queue, null)
|
||||
# nodeIamRoleName = try(module.addons.gitops_metadata.karpenter_node_iam_role_arn, null)
|
||||
# }
|
||||
# loadBalancerController = {
|
||||
# enabled = local.addons.enable_aws_load_balancer_controller
|
||||
# iamRoleArn = try(module.addons.gitops_metadata.aws_load_balancer_controller_iam_role_arn, "")
|
||||
# values = try(yamldecode(join("\n", var.aws_load_balancer_controller_helm_config.values)), {})
|
||||
# clusterName = module.eks.cluster_name
|
||||
# chartVersion = try(var.aws_load_balancer_controller_helm_config.chart_version, local.addons_default_versions.aws_load_balancer_controller)
|
||||
# vpcId = module.vpc.vpc_id
|
||||
# }
|
||||
# })
|
||||
# }
|
||||
# workloads = {
|
||||
# project = "default"
|
||||
# repo_url = var.workloads_repo_url
|
||||
# target_revision = var.workloads_target_revision
|
||||
# addons_repo_revision = var.workloads_target_revision
|
||||
# path = var.workloads_repo_path
|
||||
# values = merge({
|
||||
# addons_repo_revision = var.workloads_target_revision
|
||||
# formbricks = {
|
||||
# certificateArn = try(module.acm.acm_certificate_arn, "")
|
||||
# ingressHost = "app.k8s.formbricks.com"
|
||||
# env = {
|
||||
# TEST = {
|
||||
# value = "test "
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# })
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# variable "enable_gitops_bridge_bootstrap" {
|
||||
# default = true
|
||||
# }
|
||||
#
|
||||
# module "gitops_bridge_bootstrap" {
|
||||
# count = var.enable_gitops_bridge_bootstrap ? 1 : 0
|
||||
# source = "../modules/argocd-gitops-bridge"
|
||||
#
|
||||
# cluster = {
|
||||
# metadata = local.addons_metadata
|
||||
# }
|
||||
# argocd = {
|
||||
# chart_version = "7.8.7"
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# global:
|
||||
# nodeSelector:
|
||||
# CriticalAddonsOnly: "true"
|
||||
# tolerations:
|
||||
# - key: "CriticalAddonsOnly"
|
||||
# operator: "Exists"
|
||||
# effect: "NoSchedule"
|
||||
# configs:
|
||||
# params:
|
||||
# server.insecure: true
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
# apps = local.argocd_apps
|
||||
# }
|
||||
#
|
||||
# ###############################################################################
|
||||
# # EKS Blueprints Addons
|
||||
# ###############################################################################
|
||||
# module "addons" {
|
||||
# source = "../modules/addons"
|
||||
# oidc_provider_arn = module.eks.oidc_provider_arn
|
||||
# aws_region = data.aws_region.selected.name
|
||||
# aws_account_id = data.aws_caller_identity.current.account_id
|
||||
# aws_partition = data.aws_partition.current.partition
|
||||
# cluster_name = module.eks.cluster_name
|
||||
# cluster_endpoint = module.eks.cluster_endpoint
|
||||
# cluster_certificate_authority_data = module.eks.cluster_certificate_authority_data
|
||||
# cluster_token = data.aws_eks_cluster_auth.eks.token
|
||||
# cluster_version = module.eks.cluster_version
|
||||
# vpc_id = module.vpc.vpc_id
|
||||
# node_security_group_id = module.eks.node_security_group_id
|
||||
# cluster_security_group_id = module.eks.cluster_security_group_id
|
||||
#
|
||||
# # Using GitOps Bridge
|
||||
# create_kubernetes_resources = var.enable_gitops_bridge_bootstrap ? false : true
|
||||
#
|
||||
# # Cert Manager
|
||||
# enable_cert_manager = local.addons.enable_cert_manager
|
||||
#
|
||||
# # External DNS
|
||||
# enable_external_dns = local.addons.enable_external_dns
|
||||
#
|
||||
# # Karpenter
|
||||
# enable_karpenter = local.addons.enable_karpenter
|
||||
#
|
||||
# # External Secrets
|
||||
# enable_external_secrets = local.addons.enable_external_secrets
|
||||
#
|
||||
# # Load Balancer Controller
|
||||
# enable_aws_load_balancer_controller = local.addons.enable_aws_load_balancer_controller
|
||||
#
|
||||
# }
|
||||
252
infra/terraform/cloudwatch.tf
Normal file
252
infra/terraform/cloudwatch.tf
Normal file
@@ -0,0 +1,252 @@
|
||||
data "aws_ssm_parameter" "slack_notification_channel" {
|
||||
name = "/prod/formbricks/slack-webhook-url"
|
||||
with_decryption = true
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_log_group" "cloudwatch_cis_benchmark" {
|
||||
name = "/aws/cis-benchmark-group"
|
||||
retention_in_days = 365
|
||||
}
|
||||
|
||||
module "notify-slack" {
|
||||
source = "terraform-aws-modules/notify-slack/aws"
|
||||
version = "6.6.0"
|
||||
|
||||
slack_channel = "kubernetes"
|
||||
slack_username = "formbricks-cloudwatch"
|
||||
slack_webhook_url = data.aws_ssm_parameter.slack_notification_channel.value
|
||||
sns_topic_name = "cloudwatch-alarms"
|
||||
create_sns_topic = true
|
||||
}
|
||||
|
||||
module "cloudwatch_cis-alarms" {
|
||||
source = "terraform-aws-modules/cloudwatch/aws//modules/cis-alarms"
|
||||
version = "5.7.1"
|
||||
log_group_name = aws_cloudwatch_log_group.cloudwatch_cis_benchmark.name
|
||||
alarm_actions = [module.notify-slack.slack_topic_arn]
|
||||
}
|
||||
|
||||
locals {
|
||||
alb_id = "app/k8s-formbricks-21ab9ecd60/342ed65d128ce4cb"
|
||||
alarms = {
|
||||
ALB_HTTPCode_Target_5XX_Count = {
|
||||
alarm_description = "Average API 5XX target group error code count is too high"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 5
|
||||
period = 600
|
||||
unit = "Count"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "HTTPCode_Target_5XX_Count"
|
||||
statistic = "Sum"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
ALB_HTTPCode_ELB_5XX_Count = {
|
||||
alarm_description = "Average API 5XX load balancer error code count is too high"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 10
|
||||
period = 600
|
||||
unit = "Count"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "HTTPCode_ELB_5XX_Count"
|
||||
statistic = "Sum"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
ALB_TargetResponseTime = {
|
||||
alarm_description = format("Average API response time is greater than %s", 5)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 5
|
||||
period = 60
|
||||
unit = "Seconds"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "TargetResponseTime"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
ALB_UnHealthyHostCount = {
|
||||
alarm_description = format("Unhealthy host count is greater than %s", 2)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 2
|
||||
period = 60
|
||||
unit = "Count"
|
||||
namespace = "AWS/ApplicationELB"
|
||||
metric_name = "UnHealthyHostCount"
|
||||
statistic = "Minimum"
|
||||
dimensions = {
|
||||
LoadBalancer = local.alb_id
|
||||
}
|
||||
}
|
||||
RDS_CPUUtilization = {
|
||||
alarm_description = format("Average RDS CPU utilization is greater than %s", 80)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 80
|
||||
period = 60
|
||||
unit = "Percent"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "CPUUtilization"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
RDS_FreeStorageSpace = {
|
||||
alarm_description = format("Average RDS free storage space is less than %s", 5)
|
||||
comparison_operator = "LessThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 5
|
||||
period = 60
|
||||
unit = "Gigabytes"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "FreeStorageSpace"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
RDS_FreeableMemory = {
|
||||
alarm_description = format("Average RDS freeable memory is less than %s", 100)
|
||||
comparison_operator = "LessThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 100
|
||||
period = 60
|
||||
unit = "Megabytes"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "FreeableMemory"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
RDS_DiskQueueDepth = {
|
||||
alarm_description = format("Average RDS disk queue depth is greater than %s", 1)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 1
|
||||
period = 60
|
||||
unit = "Count"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "DiskQueueDepth"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
RDS_ReadIOPS = {
|
||||
alarm_description = format("Average RDS read IOPS is greater than %s", 1000)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 1000
|
||||
period = 60
|
||||
unit = "Count/Second"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "ReadIOPS"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
RDS_WriteIOPS = {
|
||||
alarm_description = format("Average RDS write IOPS is greater than %s", 1000)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 1000
|
||||
period = 60
|
||||
unit = "Count/Second"
|
||||
namespace = "AWS/RDS"
|
||||
metric_name = "WriteIOPS"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
|
||||
}
|
||||
}
|
||||
SQS_ApproximateAgeOfOldestMessage = {
|
||||
alarm_description = format("Average SQS approximate age of oldest message is greater than %s", 300)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 300
|
||||
period = 60
|
||||
unit = "Seconds"
|
||||
namespace = "AWS/SQS"
|
||||
metric_name = "ApproximateAgeOfOldestMessage"
|
||||
statistic = "Maximum"
|
||||
dimensions = {
|
||||
QueueName = module.karpenter.queue_name
|
||||
}
|
||||
}
|
||||
DynamoDB_ConsumedReadCapacityUnits = {
|
||||
alarm_description = format("Average DynamoDB consumed read capacity units is greater than %s", 90)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 90
|
||||
period = 60
|
||||
unit = "Count"
|
||||
namespace = "AWS/DynamoDB"
|
||||
metric_name = "ConsumedReadCapacityUnits"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
TableName = "terraform-lock"
|
||||
}
|
||||
}
|
||||
DynamoDB_ConsumedWriteCapacityUnits = {
|
||||
alarm_description = format("Average DynamoDB consumed write capacity units is greater than %s", 90)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 90
|
||||
period = 60
|
||||
unit = "Count"
|
||||
namespace = "AWS/DynamoDB"
|
||||
metric_name = "ConsumedWriteCapacityUnits"
|
||||
statistic = "Average"
|
||||
dimensions = {
|
||||
TableName = "terraform-lock"
|
||||
}
|
||||
}
|
||||
Lambda_Errors = {
|
||||
alarm_description = format("Average Lambda errors is greater than %s", 1)
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 5
|
||||
threshold = 1
|
||||
period = 60
|
||||
unit = "Count"
|
||||
namespace = "AWS/Lambda"
|
||||
metric_name = "Errors"
|
||||
statistic = "Sum"
|
||||
dimensions = {
|
||||
FunctionName = module.notify-slack.notify_slack_lambda_function_name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module "metric_alarm" {
|
||||
source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm"
|
||||
version = "5.7.1"
|
||||
|
||||
for_each = local.alarms
|
||||
alarm_name = each.key
|
||||
alarm_description = each.value.alarm_description
|
||||
comparison_operator = each.value.comparison_operator
|
||||
evaluation_periods = each.value.evaluation_periods
|
||||
threshold = each.value.threshold
|
||||
period = each.value.period
|
||||
unit = each.value.unit
|
||||
insufficient_data_actions = []
|
||||
|
||||
namespace = each.value.namespace
|
||||
metric_name = each.value.metric_name
|
||||
statistic = each.value.statistic
|
||||
|
||||
dimensions = each.value.dimensions
|
||||
|
||||
alarm_actions = [module.notify-slack.slack_topic_arn]
|
||||
}
|
||||
24
infra/terraform/data.tf
Normal file
24
infra/terraform/data.tf
Normal file
@@ -0,0 +1,24 @@
|
||||
data "aws_region" "selected" {}
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_availability_zones" "available" {}
|
||||
data "aws_partition" "current" {}
|
||||
|
||||
data "aws_eks_cluster_auth" "eks" {
|
||||
name = module.eks.cluster_name
|
||||
}
|
||||
|
||||
data "aws_ecrpublic_authorization_token" "token" {
|
||||
provider = aws.virginia
|
||||
}
|
||||
|
||||
data "aws_iam_roles" "administrator" {
|
||||
name_regex = "AWSReservedSSO_AdministratorAccess"
|
||||
}
|
||||
|
||||
data "aws_iam_roles" "github" {
|
||||
name_regex = "formbricks-prod-github"
|
||||
}
|
||||
|
||||
data "aws_acm_certificate" "formbricks" {
|
||||
domain = local.domain
|
||||
}
|
||||
78
infra/terraform/elasticache.tf
Normal file
78
infra/terraform/elasticache.tf
Normal file
@@ -0,0 +1,78 @@
|
||||
################################################################################
|
||||
# ElastiCache Module
|
||||
################################################################################
|
||||
locals {
|
||||
valkey_major_version = 8
|
||||
}
|
||||
|
||||
moved {
|
||||
from = random_password.valkey
|
||||
to = random_password.valkey["prod"]
|
||||
}
|
||||
|
||||
resource "random_password" "valkey" {
|
||||
for_each = local.envs
|
||||
length = 20
|
||||
special = false
|
||||
}
|
||||
|
||||
module "valkey_sg" {
|
||||
source = "terraform-aws-modules/security-group/aws"
|
||||
version = "~> 5.0"
|
||||
|
||||
name = "valkey-sg"
|
||||
description = "Security group for VPC traffic"
|
||||
vpc_id = module.vpc.vpc_id
|
||||
|
||||
ingress_cidr_blocks = [module.vpc.vpc_cidr_block]
|
||||
ingress_rules = ["redis-tcp"]
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
module "elasticache_user_group" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/elasticache/aws//modules/user-group"
|
||||
version = "1.4.1"
|
||||
|
||||
user_group_id = "${each.value}-valkey"
|
||||
create_default_user = false
|
||||
default_user = {
|
||||
user_id = each.value
|
||||
passwords = [random_password.valkey[each.key].result]
|
||||
}
|
||||
users = {
|
||||
"${each.value}" = {
|
||||
access_string = "on ~* +@all"
|
||||
passwords = [random_password.valkey[each.key].result]
|
||||
}
|
||||
}
|
||||
engine = "redis"
|
||||
tags = merge(local.tags, {
|
||||
terraform-aws-modules = "elasticache"
|
||||
})
|
||||
}
|
||||
|
||||
module "valkey_serverless" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/elasticache/aws//modules/serverless-cache"
|
||||
version = "1.4.1"
|
||||
|
||||
engine = "valkey"
|
||||
cache_name = "${each.value}-valkey-serverless"
|
||||
major_engine_version = local.valkey_major_version
|
||||
# cache_usage_limits = {
|
||||
# data_storage = {
|
||||
# maximum = 2
|
||||
# }
|
||||
# ecpu_per_second = {
|
||||
# maximum = 1000
|
||||
# }
|
||||
# }
|
||||
subnet_ids = module.vpc.database_subnets
|
||||
|
||||
security_group_ids = [
|
||||
module.valkey_sg.security_group_id
|
||||
]
|
||||
user_group_id = module.elasticache_user_group[each.key].group_id
|
||||
}
|
||||
30
infra/terraform/iam.tf
Normal file
30
infra/terraform/iam.tf
Normal file
@@ -0,0 +1,30 @@
|
||||
################################################################################
|
||||
# GitHub OIDC Provider
|
||||
# Note: This is one per AWS account
|
||||
################################################################################
|
||||
module "iam_github_oidc_provider" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-github-oidc-provider"
|
||||
version = "5.54.0"
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# GitHub OIDC Role
|
||||
################################################################################
|
||||
|
||||
module "iam_github_oidc_role" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-github-oidc-role"
|
||||
version = "5.54.0"
|
||||
|
||||
name = "${local.name}-github"
|
||||
|
||||
subjects = [
|
||||
"repo:formbricks/*:*",
|
||||
]
|
||||
policies = {
|
||||
Administrator = "arn:aws:iam::aws:policy/AdministratorAccess"
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
533
infra/terraform/main.tf
Normal file
533
infra/terraform/main.tf
Normal file
@@ -0,0 +1,533 @@
|
||||
locals {
|
||||
project = "formbricks"
|
||||
environment = "prod"
|
||||
name = "${local.project}-${local.environment}"
|
||||
envs = {
|
||||
prod = "${local.project}-prod"
|
||||
stage = "${local.project}-stage"
|
||||
}
|
||||
vpc_cidr = "10.0.0.0/16"
|
||||
azs = slice(data.aws_availability_zones.available.names, 0, 3)
|
||||
tags = {
|
||||
Project = local.project
|
||||
Environment = local.environment
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = local.name
|
||||
}
|
||||
tags_map = {
|
||||
prod = {
|
||||
Project = local.project
|
||||
Environment = "prod"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-prod"
|
||||
}
|
||||
stage = {
|
||||
Project = local.project
|
||||
Environment = "stage"
|
||||
ManagedBy = "Terraform"
|
||||
Blueprint = "${local.project}-stage"
|
||||
}
|
||||
}
|
||||
domain = "k8s.formbricks.com"
|
||||
karpetner_helm_version = "1.3.1"
|
||||
karpenter_namespace = "karpenter"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Route53 Hosted Zone
|
||||
################################################################################
|
||||
module "route53_zones" {
|
||||
source = "terraform-aws-modules/route53/aws//modules/zones"
|
||||
version = "4.1.0"
|
||||
|
||||
zones = {
|
||||
"k8s.formbricks.com" = {
|
||||
comment = "${local.domain} (testing)"
|
||||
tags = {
|
||||
Name = local.domain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# VPC
|
||||
################################################################################
|
||||
module "vpc" {
|
||||
source = "terraform-aws-modules/vpc/aws"
|
||||
version = "5.19.0"
|
||||
|
||||
name = "${local.name}-vpc"
|
||||
cidr = local.vpc_cidr
|
||||
|
||||
azs = local.azs
|
||||
private_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 4, k)] # /20
|
||||
public_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 48)] # Public LB /24
|
||||
intra_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 52)] # eks interface /24
|
||||
database_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 56)] # RDS / Elastic cache /24
|
||||
database_subnet_group_name = "${local.name}-subnet-group"
|
||||
|
||||
enable_nat_gateway = true
|
||||
single_nat_gateway = true
|
||||
|
||||
public_subnet_tags = {
|
||||
"kubernetes.io/role/elb" = 1
|
||||
}
|
||||
|
||||
private_subnet_tags = {
|
||||
"kubernetes.io/role/internal-elb" = 1
|
||||
# Tags subnets for Karpenter auto-discovery
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# VPC Endpoints Module
|
||||
################################################################################
|
||||
module "vpc_vpc-endpoints" {
|
||||
source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints"
|
||||
version = "5.19.0"
|
||||
|
||||
vpc_id = module.vpc.vpc_id
|
||||
|
||||
endpoints = {
|
||||
"s3" = {
|
||||
service = "s3"
|
||||
service_type = "Gateway"
|
||||
route_table_ids = flatten([
|
||||
module.vpc.intra_route_table_ids,
|
||||
module.vpc.private_route_table_ids,
|
||||
module.vpc.public_route_table_ids
|
||||
])
|
||||
tags = { Name = "s3-vpc-endpoint" }
|
||||
}
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EKS Module
|
||||
################################################################################
|
||||
module "ebs_csi_driver_irsa" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
|
||||
version = "~> 5.52"
|
||||
|
||||
role_name_prefix = "${local.name}-ebs-csi-driver-"
|
||||
|
||||
attach_ebs_csi_policy = true
|
||||
|
||||
oidc_providers = {
|
||||
main = {
|
||||
provider_arn = module.eks.oidc_provider_arn
|
||||
namespace_service_accounts = ["kube-system:ebs-csi-controller-sa"]
|
||||
}
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
module "eks" {
|
||||
source = "terraform-aws-modules/eks/aws"
|
||||
version = "20.37.2"
|
||||
|
||||
cluster_name = "${local.name}-eks"
|
||||
cluster_version = "1.32"
|
||||
|
||||
enable_cluster_creator_admin_permissions = false
|
||||
cluster_endpoint_public_access = false
|
||||
cloudwatch_log_group_retention_in_days = 365
|
||||
|
||||
cluster_addons = {
|
||||
coredns = {
|
||||
most_recent = true
|
||||
|
||||
}
|
||||
eks-pod-identity-agent = {
|
||||
most_recent = true
|
||||
}
|
||||
aws-ebs-csi-driver = {
|
||||
addon_version = "v1.46.0-eksbuild.1"
|
||||
service_account_role_arn = module.ebs_csi_driver_irsa.iam_role_arn
|
||||
}
|
||||
kube-proxy = {
|
||||
most_recent = true
|
||||
}
|
||||
vpc-cni = {
|
||||
most_recent = true
|
||||
}
|
||||
}
|
||||
|
||||
cluster_security_group_additional_rules = {
|
||||
ingress_from_vpc_cidr = {
|
||||
description = "Allow all traffic from the VPC CIDR"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
type = "ingress"
|
||||
cidr_blocks = [local.vpc_cidr]
|
||||
}
|
||||
}
|
||||
|
||||
kms_key_administrators = [
|
||||
tolist(data.aws_iam_roles.github.arns)[0],
|
||||
tolist(data.aws_iam_roles.administrator.arns)[0]
|
||||
]
|
||||
|
||||
kms_key_users = [
|
||||
tolist(data.aws_iam_roles.github.arns)[0],
|
||||
tolist(data.aws_iam_roles.administrator.arns)[0]
|
||||
]
|
||||
|
||||
access_entries = {
|
||||
administrator = {
|
||||
principal_arn = tolist(data.aws_iam_roles.administrator.arns)[0]
|
||||
policy_associations = {
|
||||
Admin = {
|
||||
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
|
||||
access_scope = {
|
||||
type = "cluster"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
github = {
|
||||
principal_arn = tolist(data.aws_iam_roles.github.arns)[0]
|
||||
policy_associations = {
|
||||
Admin = {
|
||||
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
|
||||
access_scope = {
|
||||
type = "cluster"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vpc_id = module.vpc.vpc_id
|
||||
subnet_ids = module.vpc.private_subnets
|
||||
control_plane_subnet_ids = module.vpc.intra_subnets
|
||||
|
||||
eks_managed_node_group_defaults = {
|
||||
iam_role_additional_policies = {
|
||||
AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
|
||||
}
|
||||
}
|
||||
|
||||
eks_managed_node_groups = {
|
||||
system = {
|
||||
ami_type = "BOTTLEROCKET_ARM_64"
|
||||
instance_types = ["t4g.small"]
|
||||
|
||||
min_size = 2
|
||||
max_size = 3
|
||||
desired_size = 2
|
||||
|
||||
labels = {
|
||||
CriticalAddonsOnly = "true"
|
||||
"karpenter.sh/controller" = "true"
|
||||
}
|
||||
|
||||
taints = {
|
||||
addons = {
|
||||
key = "CriticalAddonsOnly"
|
||||
value = "true"
|
||||
effect = "NO_SCHEDULE"
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node_security_group_tags = merge(local.tags, {
|
||||
# NOTE - if creating multiple security groups with this module, only tag the
|
||||
# security group that Karpenter should utilize with the following tag
|
||||
# (i.e. - at most, only one security group should have this tag in your account)
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
})
|
||||
|
||||
tags = local.tags
|
||||
|
||||
}
|
||||
|
||||
module "karpenter" {
|
||||
source = "terraform-aws-modules/eks/aws//modules/karpenter"
|
||||
version = "20.34.0"
|
||||
|
||||
cluster_name = module.eks.cluster_name
|
||||
enable_v1_permissions = true
|
||||
|
||||
# Name needs to match role name passed to the EC2NodeClass
|
||||
node_iam_role_use_name_prefix = false
|
||||
node_iam_role_name = local.name
|
||||
create_pod_identity_association = true
|
||||
namespace = local.karpenter_namespace
|
||||
|
||||
# Used to attach additional IAM policies to the Karpenter node IAM role
|
||||
node_iam_role_additional_policies = {
|
||||
AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
output "karpenter_node_role" {
|
||||
value = module.karpenter.node_iam_role_name
|
||||
}
|
||||
|
||||
|
||||
|
||||
# resource "helm_release" "karpenter_crds" {
|
||||
# name = "karpenter-crds"
|
||||
# repository = "oci://public.ecr.aws/karpenter"
|
||||
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
# repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
# chart = "karpenter-crd"
|
||||
# version = "1.3.1"
|
||||
# namespace = local.karpenter_namespace
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# webhook:
|
||||
# enabled: true
|
||||
# serviceNamespace: ${local.karpenter_namespace}
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
|
||||
# resource "helm_release" "karpenter" {
|
||||
# name = "karpenter"
|
||||
# repository = "oci://public.ecr.aws/karpenter"
|
||||
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
# repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
# chart = "karpenter"
|
||||
# version = "1.3.1"
|
||||
# namespace = local.karpenter_namespace
|
||||
# skip_crds = true
|
||||
#
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# nodeSelector:
|
||||
# karpenter.sh/controller: 'true'
|
||||
# dnsPolicy: Default
|
||||
# settings:
|
||||
# clusterName: ${module.eks.cluster_name}
|
||||
# clusterEndpoint: ${module.eks.cluster_endpoint}
|
||||
# interruptionQueue: ${module.karpenter.queue_name}
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# resource "kubernetes_manifest" "ec2_node_class" {
|
||||
# manifest = {
|
||||
# apiVersion = "karpenter.k8s.aws/v1"
|
||||
# kind = "EC2NodeClass"
|
||||
# metadata = {
|
||||
# name = "default"
|
||||
# }
|
||||
# spec = {
|
||||
# amiSelectorTerms = [
|
||||
# {
|
||||
# alias = "bottlerocket@latest"
|
||||
# }
|
||||
# ]
|
||||
# role = module.karpenter.node_iam_role_name
|
||||
# subnetSelectorTerms = [
|
||||
# {
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# securityGroupSelectorTerms = [
|
||||
# {
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
# resource "kubernetes_manifest" "node_pool" {
|
||||
# manifest = {
|
||||
# apiVersion = "karpenter.sh/v1"
|
||||
# kind = "NodePool"
|
||||
# metadata = {
|
||||
# name = "default"
|
||||
# }
|
||||
# spec = {
|
||||
# template = {
|
||||
# spec = {
|
||||
# nodeClassRef = {
|
||||
# group = "karpenter.k8s.aws"
|
||||
# kind = "EC2NodeClass"
|
||||
# name = "default"
|
||||
# }
|
||||
# requirements = [
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-family"
|
||||
# operator = "In"
|
||||
# values = ["c8g", "c7g", "m8g", "m7g", "r8g", "r7g"]
|
||||
# },
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-cpu"
|
||||
# operator = "In"
|
||||
# values = ["2", "4", "8"]
|
||||
# },
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-hypervisor"
|
||||
# operator = "In"
|
||||
# values = ["nitro"]
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
# limits = {
|
||||
# cpu = 1000
|
||||
# }
|
||||
# disruption = {
|
||||
# consolidationPolicy = "WhenEmptyOrUnderutilized"
|
||||
# consolidateAfter = "30s"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
module "eks_blueprints_addons" {
|
||||
source = "aws-ia/eks-blueprints-addons/aws"
|
||||
version = "~> 1"
|
||||
|
||||
cluster_name = module.eks.cluster_name
|
||||
cluster_endpoint = module.eks.cluster_endpoint
|
||||
cluster_version = module.eks.cluster_version
|
||||
oidc_provider_arn = module.eks.oidc_provider_arn
|
||||
|
||||
enable_metrics_server = true
|
||||
metrics_server = {
|
||||
chart_version = "3.12.2"
|
||||
}
|
||||
|
||||
enable_aws_load_balancer_controller = true
|
||||
aws_load_balancer_controller = {
|
||||
chart_version = "1.10.0"
|
||||
values = [
|
||||
<<-EOT
|
||||
vpcId: ${module.vpc.vpc_id}
|
||||
EOT
|
||||
]
|
||||
}
|
||||
enable_external_dns = true
|
||||
external_dns_route53_zone_arns = [module.route53_zones.route53_zone_zone_arn[local.domain]]
|
||||
external_dns = {
|
||||
chart_version = "1.15.2"
|
||||
}
|
||||
enable_cert_manager = false
|
||||
cert_manager = {
|
||||
chart_version = "v1.17.1"
|
||||
values = [
|
||||
<<-EOT
|
||||
installCRDs: false
|
||||
crds:
|
||||
enabled: true
|
||||
keep: true
|
||||
EOT
|
||||
]
|
||||
}
|
||||
|
||||
enable_external_secrets = true
|
||||
external_secrets = {
|
||||
chart_version = "0.14.3"
|
||||
}
|
||||
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
### Formbricks App
|
||||
|
||||
moved {
|
||||
from = module.formbricks_s3_bucket
|
||||
to = module.formbricks_s3_bucket["prod"]
|
||||
}
|
||||
|
||||
module "formbricks_s3_bucket" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/s3-bucket/aws"
|
||||
version = "4.6.0"
|
||||
|
||||
bucket = each.key == "prod" ? "formbricks-cloud-eks" : "formbricks-cloud-eks-${each.key}"
|
||||
force_destroy = true
|
||||
control_object_ownership = true
|
||||
object_ownership = "BucketOwnerPreferred"
|
||||
versioning = {
|
||||
enabled = true
|
||||
}
|
||||
cors_rule = [
|
||||
{
|
||||
allowed_methods = ["POST"]
|
||||
allowed_origins = ["https://*"]
|
||||
allowed_headers = ["*"]
|
||||
expose_headers = []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
moved {
|
||||
from = module.formbricks_app_iam_policy
|
||||
to = module.formbricks_app_iam_policy["prod"]
|
||||
}
|
||||
|
||||
module "formbricks_app_iam_policy" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
|
||||
version = "5.53.0"
|
||||
|
||||
name_prefix = each.key == "prod" ? "formbricks-" : "formbricks-${each.key}-"
|
||||
path = "/"
|
||||
description = "Policy for fombricks app"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"s3:*",
|
||||
]
|
||||
Resource = [
|
||||
module.formbricks_s3_bucket[each.key].s3_bucket_arn,
|
||||
"${module.formbricks_s3_bucket[each.key].s3_bucket_arn}/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
moved {
|
||||
from = module.formbricks_app_iam_role
|
||||
to = module.formbricks_app_iam_role["prod"]
|
||||
}
|
||||
|
||||
module "formbricks_app_iam_role" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
|
||||
version = "5.53.0"
|
||||
|
||||
role_name_prefix = each.key == "prod" ? "formbricks-" : "formbricks-${each.key}-"
|
||||
|
||||
role_policy_arns = {
|
||||
"formbricks" = module.formbricks_app_iam_policy[each.key].arn
|
||||
}
|
||||
assume_role_condition_test = "StringLike"
|
||||
|
||||
oidc_providers = {
|
||||
eks = {
|
||||
provider_arn = module.eks.oidc_provider_arn
|
||||
namespace_service_accounts = each.key == "prod" ? ["formbricks:*"] : ["formbricks-${each.key}:*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
136
infra/terraform/observability.tf
Normal file
136
infra/terraform/observability.tf
Normal file
@@ -0,0 +1,136 @@
|
||||
module "loki_s3_bucket" {
|
||||
source = "terraform-aws-modules/s3-bucket/aws"
|
||||
version = "4.6.0"
|
||||
|
||||
bucket_prefix = "loki-"
|
||||
force_destroy = true
|
||||
control_object_ownership = true
|
||||
object_ownership = "BucketOwnerPreferred"
|
||||
}
|
||||
|
||||
module "observability_loki_iam_policy" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
|
||||
version = "5.53.0"
|
||||
|
||||
name_prefix = "loki-"
|
||||
path = "/"
|
||||
description = "Policy for fombricks observability apps"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"s3:*",
|
||||
]
|
||||
Resource = [
|
||||
module.loki_s3_bucket.s3_bucket_arn,
|
||||
"${module.loki_s3_bucket.s3_bucket_arn}/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
module "observability_loki_iam_role" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
|
||||
version = "5.53.0"
|
||||
|
||||
role_name_prefix = "loki-"
|
||||
|
||||
role_policy_arns = {
|
||||
"formbricks" = module.observability_loki_iam_policy.arn
|
||||
}
|
||||
assume_role_condition_test = "StringLike"
|
||||
|
||||
oidc_providers = {
|
||||
eks = {
|
||||
provider_arn = module.eks.oidc_provider_arn
|
||||
namespace_service_accounts = ["monitoring:loki"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module "observability_grafana_iam_policy" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
|
||||
version = "5.53.0"
|
||||
|
||||
name_prefix = "grafana-"
|
||||
path = "/"
|
||||
description = "Policy for Formbricks observability apps - Grafana"
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowReadingMetricsFromCloudWatch"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"cloudwatch:DescribeAlarmsForMetric",
|
||||
"cloudwatch:DescribeAlarmHistory",
|
||||
"cloudwatch:DescribeAlarms",
|
||||
"cloudwatch:ListMetrics",
|
||||
"cloudwatch:GetMetricData",
|
||||
"cloudwatch:GetInsightRuleReport"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "AllowReadingResourceMetricsFromPerformanceInsights"
|
||||
Effect = "Allow"
|
||||
Action = "pi:GetResourceMetrics"
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "AllowReadingLogsFromCloudWatch"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"logs:DescribeLogGroups",
|
||||
"logs:GetLogGroupFields",
|
||||
"logs:StartQuery",
|
||||
"logs:StopQuery",
|
||||
"logs:GetQueryResults",
|
||||
"logs:GetLogEvents"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "AllowReadingTagsInstancesRegionsFromEC2"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"ec2:DescribeTags",
|
||||
"ec2:DescribeInstances",
|
||||
"ec2:DescribeRegions"
|
||||
]
|
||||
Resource = "*"
|
||||
},
|
||||
{
|
||||
Sid = "AllowReadingResourcesForTags"
|
||||
Effect = "Allow"
|
||||
Action = "tag:GetResources"
|
||||
Resource = "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
module "observability_grafana_iam_role" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
|
||||
version = "5.53.0"
|
||||
|
||||
role_name_prefix = "grafana-"
|
||||
|
||||
role_policy_arns = {
|
||||
"formbricks" = module.observability_grafana_iam_policy.arn
|
||||
}
|
||||
assume_role_condition_test = "StringLike"
|
||||
|
||||
oidc_providers = {
|
||||
eks = {
|
||||
provider_arn = module.eks.oidc_provider_arn
|
||||
namespace_service_accounts = ["monitoring:grafana"]
|
||||
}
|
||||
}
|
||||
}
|
||||
31
infra/terraform/provider.tf
Normal file
31
infra/terraform/provider.tf
Normal file
@@ -0,0 +1,31 @@
|
||||
provider "aws" {
|
||||
region = "eu-central-1"
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = "us-east-1"
|
||||
alias = "virginia"
|
||||
}
|
||||
|
||||
terraform {
|
||||
backend "s3" {
|
||||
bucket = "715841356175-terraform"
|
||||
key = "terraform.tfstate"
|
||||
region = "eu-central-1"
|
||||
dynamodb_table = "terraform-lock"
|
||||
}
|
||||
}
|
||||
|
||||
provider "kubernetes" {
|
||||
host = module.eks.cluster_endpoint
|
||||
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
|
||||
token = data.aws_eks_cluster_auth.eks.token
|
||||
}
|
||||
|
||||
provider "helm" {
|
||||
kubernetes {
|
||||
host = module.eks.cluster_endpoint
|
||||
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
|
||||
token = data.aws_eks_cluster_auth.eks.token
|
||||
}
|
||||
}
|
||||
79
infra/terraform/rds.tf
Normal file
79
infra/terraform/rds.tf
Normal file
@@ -0,0 +1,79 @@
|
||||
################################################################################
|
||||
# PostgreSQL Serverless v2
|
||||
################################################################################
|
||||
data "aws_rds_engine_version" "postgresql" {
|
||||
engine = "aurora-postgresql"
|
||||
version = "16.6"
|
||||
}
|
||||
|
||||
moved {
|
||||
from = random_password.postgres
|
||||
to = random_password.postgres["prod"]
|
||||
}
|
||||
|
||||
resource "random_password" "postgres" {
|
||||
for_each = local.envs
|
||||
length = 20
|
||||
special = false
|
||||
}
|
||||
|
||||
moved {
|
||||
from = module.rds-aurora
|
||||
to = module.rds-aurora["prod"]
|
||||
}
|
||||
|
||||
module "rds-aurora" {
|
||||
for_each = local.envs
|
||||
source = "terraform-aws-modules/rds-aurora/aws"
|
||||
version = "9.12.0"
|
||||
|
||||
name = "${each.value}-postgres"
|
||||
engine = data.aws_rds_engine_version.postgresql.engine
|
||||
engine_mode = "provisioned"
|
||||
engine_version = data.aws_rds_engine_version.postgresql.version
|
||||
storage_encrypted = true
|
||||
master_username = "formbricks"
|
||||
master_password = random_password.postgres[each.key].result
|
||||
manage_master_user_password = false
|
||||
create_db_cluster_parameter_group = true
|
||||
db_cluster_parameter_group_family = data.aws_rds_engine_version.postgresql.parameter_group_family
|
||||
db_cluster_parameter_group_parameters = [
|
||||
{
|
||||
name = "shared_preload_libraries"
|
||||
value = "pglogical"
|
||||
apply_method = "pending-reboot"
|
||||
}
|
||||
]
|
||||
|
||||
vpc_id = module.vpc.vpc_id
|
||||
db_subnet_group_name = module.vpc.database_subnet_group_name
|
||||
security_group_rules = {
|
||||
vpc_ingress = {
|
||||
cidr_blocks = [module.vpc.vpc_cidr_block]
|
||||
}
|
||||
}
|
||||
performance_insights_enabled = true
|
||||
cluster_performance_insights_enabled = true
|
||||
|
||||
backup_retention_period = 7
|
||||
apply_immediately = true
|
||||
skip_final_snapshot = false
|
||||
|
||||
deletion_protection = true
|
||||
|
||||
enable_http_endpoint = true
|
||||
|
||||
serverlessv2_scaling_configuration = {
|
||||
min_capacity = 0.5
|
||||
max_capacity = 50
|
||||
}
|
||||
|
||||
instance_class = "db.serverless"
|
||||
|
||||
instances = {
|
||||
one = {}
|
||||
}
|
||||
|
||||
tags = local.tags_map[each.key]
|
||||
|
||||
}
|
||||
24
infra/terraform/secrets.tf
Normal file
24
infra/terraform/secrets.tf
Normal file
@@ -0,0 +1,24 @@
|
||||
# Create the first AWS Secrets Manager secret for environment variables
|
||||
moved {
|
||||
from = aws_secretsmanager_secret.formbricks_app_secrets
|
||||
to = aws_secretsmanager_secret.formbricks_app_secrets["prod"]
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "formbricks_app_secrets" {
|
||||
for_each = local.envs
|
||||
name = "${each.key}/formbricks/secrets"
|
||||
}
|
||||
|
||||
moved {
|
||||
from = aws_secretsmanager_secret_version.formbricks_app_secrets
|
||||
to = aws_secretsmanager_secret_version.formbricks_app_secrets["prod"]
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "formbricks_app_secrets" {
|
||||
for_each = local.envs
|
||||
secret_id = aws_secretsmanager_secret.formbricks_app_secrets[each.key].id
|
||||
secret_string = jsonencode({
|
||||
REDIS_URL = "rediss://${each.value}:${random_password.valkey[each.key].result}@${module.valkey_serverless[each.key].serverless_cache_endpoint[0].address}:6379"
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user