Compare commits

..

1 Commits

Author SHA1 Message Date
Johannes
fe07a0689c bring back 2nd merge state 2025-10-16 15:16:09 +02:00
130 changed files with 3067 additions and 1719 deletions

View File

@@ -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.

View File

@@ -1 +0,0 @@
Open a draft PR with a concise description of what weve done in this PR and what remains to be done.

View File

@@ -1,8 +0,0 @@
Run the unit tests of all files weve 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.

View File

@@ -1 +0,0 @@
Run /unit-test and if complete /build and if complete /pr

View 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

View File

@@ -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
});
}

View 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
View File

@@ -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

View File

@@ -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(),

View File

@@ -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 });

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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"]
)}

View File

@@ -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,

View File

@@ -22,7 +22,7 @@ const mockSurvey: TSurvey = {
welcomeCard: {
enabled: false,
headline: { default: "Welcome" },
subheader: { default: "" },
html: { default: "" },
timeToFinish: false,
showResponseCount: false,
buttonLabel: { default: "Start" },

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"),

View File

@@ -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",
},
],
},
},

View File

@@ -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" ||

View File

@@ -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,

View File

@@ -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();

View File

@@ -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}`;
};

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -54,7 +54,7 @@ describe("ResponseFeed", () => {
welcomeCard: {
enabled: false,
headline: "",
subheader: "",
html: "",
},
displayLimit: null,
autoComplete: null,

View File

@@ -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", () => {

View File

@@ -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>

View File

@@ -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>
)}
</>
);

View File

@@ -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

View File

@@ -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"

View File

@@ -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>
)}

View File

@@ -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 () => {

View File

@@ -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>
);

View File

@@ -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: "",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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*");
});
});

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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>

View File

@@ -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" &&

View File

@@ -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

View File

@@ -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>

View File

@@ -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),
});
}
});

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
});
});
});

View File

@@ -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 => {

View File

@@ -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", () => {

View File

@@ -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 = (

View File

@@ -142,7 +142,7 @@ describe("FollowUpItem", () => {
},
endings: [],
welcomeCard: {
subheader: {
html: {
default: "Thanks for providing your feedback - let's go!",
},
enabled: false,

View File

@@ -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"), []),

View File

@@ -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}>

View File

@@ -79,7 +79,7 @@ describe("data", () => {
timeToFinish: false,
showResponseCount: false,
headline: { default: "Welcome" },
subheader: { default: "" },
html: { default: "" },
buttonLabel: { default: "Start" },
},
questions: [],

View File

@@ -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: {},

View File

@@ -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}

View File

@@ -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");

View File

@@ -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>
);

View File

@@ -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,
]
);

View File

@@ -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>
);
};

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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",

View File

@@ -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
View 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
View File

@@ -0,0 +1,3 @@
.terraform/
builds
/.direnv/

61
infra/flake.lock generated Normal file
View 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
View 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
];
};
}
);
}

View 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

View 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

View 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
View 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",
]
}

View 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
#
# }

View 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
View 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
}

View 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
View 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
View 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}:*"]
}
}
}

View 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"]
}
}
}

View 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
View 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]
}

View 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