Compare commits

...

88 Commits

Author SHA1 Message Date
Matti Nannt
3eee5bb50f chore(backport): updated release workflow (#6627) 2025-09-30 17:39:44 +02:00
Victor Hugo dos Santos
d341370b0d refactor: enhance JWT handling and improve security tests (#6619) (#6626) 2025-09-30 09:40:29 -03:00
Anshuman Pandey
391b3a3fb0 fix: backports formbricks.sh fix (#6550) 2025-09-15 17:58:45 +02:00
Victor Santos
0bcd85d658 refactor: enhance removeCondition logic and add comprehensive tests
- Updated the removeCondition function to return a boolean indicating success or failure.
- Implemented cleanup logic to remove empty condition groups after a condition is removed.
- Added tests to ensure correct behavior of removeCondition, including scenarios with nested groups and empty groups.
- Enhanced ConditionsEditor to disable the 'Create Group' button when only one condition exists, and ensure it is enabled when multiple conditions are present.
2025-09-01 08:58:11 -03:00
Matthias Nannt
f59df49588 chore: backport ecr build and push action 2025-08-20 14:55:58 +02:00
Matti Nannt
f08fabfb13 fix(backport): github release action fix (#6425) 2025-08-15 13:26:09 +02:00
Dhruwang Jariwala
ee8af9dd74 chore: metadata tweaks backport (#6421) 2025-08-15 13:02:28 +02:00
Dhruwang Jariwala
1091b40bd1 fix(backport): cross button hover (#6416) 2025-08-14 14:30:05 +02:00
Anshuman Pandey
87a2d727ed fix: disables tabs when single use is enabled [Backport] (#6412) 2025-08-14 04:07:35 -07:00
Dhruwang Jariwala
4786ab61e7 feat: customizable link previews (#6361)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-12 06:37:30 +00:00
Dhruwang Jariwala
819380d21c chore: sonarqube label fixes (#6381) 2025-08-11 18:22:21 +00:00
Anshuman Pandey
fd3fedb6ed fix: fixes follow up UI when email is hidden in the contact info question (#6388) 2025-08-11 14:48:32 +00:00
Dhruwang Jariwala
88b1e63771 chore: updated nextjs version (#6389) 2025-08-11 13:24:35 +00:00
Piyush Gupta
3132fe74f1 chore: remove response note feature (#6390) 2025-08-11 12:01:31 +00:00
Harsh Bhat
a27a2a67c8 chore: Change pricing form link (#6394) 2025-08-11 05:13:43 -07:00
Piyush Gupta
4a7ace5a0a feat: adds metadata columns in response table (#6368) 2025-08-11 11:25:06 +00:00
Victor Hugo dos Santos
43628caa3b feat: Add rate limiting to API V1 (#6355)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-08-11 09:10:45 +00:00
Matti Nannt
9d84bc0c8d fix: Uncontrolled data used in path expression in storage service (#6375)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-11 08:37:33 +00:00
Dhruwang Jariwala
babc020085 chore: short url legacy removal (#6391) 2025-08-11 07:55:52 +00:00
Matti Nannt
95ee83ef31 chore: remove semantic PR thank you comment to reduce PR spam (#6380) 2025-08-11 07:24:59 +00:00
Matti Nannt
d994af2dfd chore: Add Docker Image Vulnerability Scanning for SOC-2 Compliance (#6371) 2025-08-08 16:42:55 +00:00
Matti Nannt
4b5b5bf59f chore: add cursor rule for github workflows & actions (#6382) 2025-08-08 13:30:27 +00:00
Anshuman Pandey
62166dc4b1 fix: tidying up the survey card header (#6341)
Co-authored-by: Jakob Schott <jakob@formbricks.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
2025-08-08 10:18:56 +00:00
Matti Nannt
ec6d88bf11 fix: OneLeet Code Scanning Sentry action issues (#6378) 2025-08-08 08:06:57 +00:00
Dhruwang Jariwala
c0240d60a1 feat: romanian translations (#6369) 2025-08-08 04:03:55 +00:00
Dhruwang Jariwala
cd2884d83e chore: app connection info alert (#6370)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-08-07 15:57:49 +00:00
StepSecurity Bot
f7aea2e706 chore: Harden GitHub Actions (#6373)
Signed-off-by: StepSecurity Bot <bot@stepsecurity.io>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-07 16:33:42 +02:00
Matti Nannt
e80fc2ee61 chore: remove unused github workflows/actions (#6372) 2025-08-07 15:26:11 +02:00
Jakob Schott
9b489b0682 chore: Optimize styling for MultiLanguageCard (#6353) 2025-08-07 10:23:25 +00:00
Jakob Schott
2ee0efa1c2 fix: dynamic width for InputCombobox (#6365) 2025-08-06 23:52:09 -07:00
Anshuman Pandey
9ffd67262c fix: updates tolgee key (#6367) 2025-08-07 06:30:00 +00:00
Dhruwang Jariwala
68dc63ce0b chore: search bar and preview on survey list page (#6349)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-07 04:57:28 +00:00
Piyush Gupta
f239ee9697 feat: adds multiLanguageSurveys and accessControl license features (#6331) 2025-08-06 14:35:28 +00:00
Piyush Gupta
282b3e070c fix: sonarqube medium vulnerability issues (#6362) 2025-08-06 11:23:27 +00:00
Johannes
b5f0bd8f9a fix: update wording to match actual behaviour (#6364) 2025-08-06 03:38:47 -07:00
Piyush Gupta
3784bd6b5e fix: Missing space in Access Control Modal (#6356)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-06 08:17:47 +00:00
Piyush Gupta
41d27c2093 fix: use full width on sidebar elements (#6357) 2025-08-06 07:58:28 +00:00
Piyush Gupta
7400ce2e67 fix: secure cookies fix for callback URL (#6358) 2025-08-05 17:44:13 +00:00
Piyush Gupta
355782f404 chore: sonarqube low reliability issues (#6359) 2025-08-05 10:06:53 +00:00
Anshuman Pandey
de70e97940 fix: adds loading state to the responses download button (#6352) 2025-08-05 04:22:22 +00:00
Dhruwang Jariwala
287c45f996 feat: surface option ids (#6339) 2025-08-05 04:03:12 +00:00
Harsh Bhat
3b07a6d013 docs: update multi-language surveys (#6354) 2025-08-04 10:02:31 -07:00
Jonas Höbenreich
0cc2606ec6 fix: Remove rounded-lg Class from Company Logo (#6347) 2025-08-04 01:42:05 -07:00
Dhruwang Jariwala
0fada94b80 chore: Replace entity ids (#6317) 2025-08-04 04:10:41 +00:00
Piyush Gupta
a59ede20c7 fix: one leet security issues (#6303) 2025-08-01 14:35:11 +00:00
Piyush Gupta
84294f9df2 feat: adds debug logs (#6237)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-01 11:10:21 +00:00
Johannes
855e7c78ce docs: add quota docs (#6343) 2025-07-31 06:25:34 -07:00
Piotr Gaczkowski
6c506d90c7 fix: Make EKS endpoint private (#6333)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-31 13:08:18 +00:00
Piyush Gupta
53f6e02ca1 fix: XLSX security vulnerability | Update XLSX to SheetJS (#6321) 2025-07-31 12:12:17 +00:00
Jakob Schott
14de2eab42 feat: 733 warn users when switching survey type (#6336)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-31 08:30:06 +00:00
Piyush Gupta
ad1f80331a fix: Low severity vulnerability in on-headers@1.0.2 (#6319) 2025-07-31 06:42:03 +00:00
Piyush Gupta
3527ac337b feat: adds response status select in filters (#6325) 2025-07-31 06:33:11 +00:00
Victor Hugo dos Santos
23c2d3dce9 feat: Add Regex No Code Action Page Filter (#6305)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-31 05:48:12 +00:00
Anshuman Pandey
da652bd860 fix: adds proxy agent to next-auth (#6326) 2025-07-31 05:08:33 +00:00
Harsh Bhat
6f88dde1a0 chore: SUS template (#6328)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-30 05:27:58 -07:00
Jakob Schott
3b90223101 style: scroll indicator update (#6310) 2025-07-30 05:27:15 -07:00
Victor Hugo dos Santos
e29a67b1f6 chore: run checks for PR 6304 (#6309)
Co-authored-by: ompharate <ompharate31@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-29 22:20:01 +00:00
Anshuman Pandey
78f5de2f35 fix: adds swift and kotlin language conventions to formbricks docs (#6316) 2025-07-29 11:09:01 +00:00
Dhruwang Jariwala
b1a35d4a69 fix: unformatted db message in client display api (#6176) 2025-07-29 04:08:16 +00:00
Dhruwang Jariwala
2166c44470 feat: ID badge component (#6281) 2025-07-28 09:44:43 +00:00
Anshuman Pandey
080cf741e9 fix: adds api v1/responses docs for limit and skip parameters (#6314) 2025-07-28 07:44:04 +00:00
Anshuman Pandey
8881691509 refactor: refurbish logic editor UI (#6216) 2025-07-25 12:05:49 +00:00
Anshuman Pandey
3045f4437f fix: fixes status schedule updation (#6312) 2025-07-25 10:27:28 +00:00
Dhruwang Jariwala
91ace0e821 fix: scroll to bottom on error (#6301) 2025-07-25 09:11:41 +00:00
Dhruwang Jariwala
6ef281647a fix: unauthorised error on survey list page (#6302) 2025-07-25 06:10:48 +00:00
Dhruwang Jariwala
0aaaaa54ee chore: Don't force Project Onboarding for each project (#6299)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-25 06:10:30 +00:00
Harsh Bhat
b1f78e7bf2 docs: webhook payload (#6307) 2025-07-25 06:00:00 +00:00
Piyush Gupta
7086ce2ca3 fix: removes unused translations (#6308) 2025-07-24 12:55:02 +00:00
Piyush Gupta
8f8b549b1d chore: Remove the public result sharing page. (#6298)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-24 12:06:59 +00:00
Piyush Gupta
28514487e0 chore: sunset weekly summary (#6282) 2025-07-24 12:01:39 +00:00
Piyush Gupta
ee20af54c3 feat: adds an underline option in the rich text editor (#6274)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-23 10:54:05 +00:00
Johannes
d08ec4c9ab docs: Fix domain split docs (#6300)
Co-authored-by: Victor Hugo dos Santos <115753265+victorvhs017@users.noreply.github.com>
2025-07-23 03:54:53 -07:00
Piyush Gupta
891c83e232 fix: CTA question button URL validation (#6284)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-23 05:48:18 +00:00
Johannes
0b02b00b72 fix: link input length and accessibility error (#6283)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-23 05:01:16 +00:00
Harsh Thakur
a217cdd501 fix: email embed preview spacing issue (#6262) 2025-07-22 17:14:07 +00:00
om pharate
ebe50a4821 fix: render copy link button based on single use survey (#6288) 2025-07-22 16:31:54 +00:00
Johannes
cb68d9defc chore: enable blank issue (#6291) 2025-07-22 10:02:49 -07:00
Victor Hugo dos Santos
c42a706789 fix: Experimental workflow package.json version update (#6287) 2025-07-22 15:19:38 +00:00
Anshuman Pandey
3803111b19 fix: fixes personalized links when single use id is enabled (#6270) 2025-07-22 12:08:45 +00:00
Dhruwang Jariwala
30fdcff737 feat: reset survey (#6267) 2025-07-22 12:04:26 +00:00
Dhruwang Jariwala
e83cfa85a4 fix: github annotations (#6240) 2025-07-22 10:38:34 +00:00
Piyush Gupta
eee9ee8995 chore: Replaces Unkey and Update rate limiting in the management API v2. (#6273) 2025-07-22 09:33:29 +00:00
Dhruwang Jariwala
ed89f12af8 chore: rate limiting for server actions (#6271) 2025-07-22 09:18:12 +00:00
Piyush Gupta
f043314537 fix: required action revert logic (#6269) 2025-07-22 04:10:09 +00:00
Victor Hugo dos Santos
2ce842dd8d chore: updated SAML SSO docs (#6280) 2025-07-22 04:09:11 +00:00
Johannes
43b43839c5 chore: auto-add bug to eng project (#6277) 2025-07-21 08:33:27 -07:00
Piyush Gupta
8b6e3fec37 fix: response filters icons and text (#6266) 2025-07-21 08:48:10 +00:00
Anshuman Pandey
31bcf98779 fix: fixes PIN 4 digit length error (#6265) 2025-07-21 07:30:03 +00:00
626 changed files with 32114 additions and 15748 deletions

View File

@@ -18,7 +18,6 @@ apps/web/
│ ├── (app)/ # Main application routes
│ ├── (auth)/ # Authentication routes
│ ├── api/ # API routes
│ └── share/ # Public sharing routes
├── components/ # Shared components
├── lib/ # Utility functions and services
└── modules/ # Feature-specific modules
@@ -43,7 +42,6 @@ The application uses Next.js 13+ app router with route groups:
### Dynamic Routes
- `[environmentId]` - Environment-specific routes
- `[surveyId]` - Survey-specific routes
- `[sharingKey]` - Public sharing routes
## Service Layer Pattern

View File

@@ -0,0 +1,232 @@
---
description: Security best practices and guidelines for writing GitHub Actions and workflows
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
---
# GitHub Actions Security Best Practices
## Required Security Measures
### 1. Set Minimum GITHUB_TOKEN Permissions
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
```yaml
permissions:
contents: read
# Only add additional permissions if absolutely necessary:
# pull-requests: write # for commenting on PRs
# issues: write # for creating/updating issues
# checks: write # for publishing check results
```
### 2. Add Harden-Runner as First Step
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
```yaml
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit # or 'block' for stricter security
```
### 3. Pin Actions to Full Commit SHA
**Always** pin third-party actions to their full commit SHA, not tags:
```yaml
# ❌ BAD - uses mutable tag
- uses: actions/checkout@v4
# ✅ GOOD - pinned to immutable commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
### 4. Secure Variable Handling
Prevent command injection by properly quoting variables:
```yaml
# ❌ BAD - potential command injection
run: echo "Processing ${{ inputs.user_input }}"
# ✅ GOOD - properly quoted
env:
USER_INPUT: ${{ inputs.user_input }}
run: echo "Processing ${USER_INPUT}"
```
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
### 5. Environment Variables for Secrets
Store sensitive data in environment variables, not inline:
```yaml
# ❌ BAD
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
# ✅ GOOD
env:
API_TOKEN: ${{ secrets.TOKEN }}
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
```
## Workflow Structure Best Practices
### Required Workflow Elements
```yaml
name: "Descriptive Workflow Name"
on:
# Define specific triggers
push:
branches: [main]
pull_request:
branches: [main]
# Always set explicit permissions
permissions:
contents: read
jobs:
job-name:
name: "Descriptive Job Name"
runs-on: ubuntu-latest
timeout-minutes: 30 # tune per job; standardize repo-wide
# Set job-level permissions if different from workflow level
permissions:
contents: read
steps:
# Always start with Harden-Runner on ubuntu-latest
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
# Pin all actions to commit SHA
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
### Input Validation for Actions
For composite actions, always validate inputs:
```yaml
inputs:
user_input:
description: "User provided input"
required: true
runs:
using: "composite"
steps:
- name: Validate input
shell: bash
run: |
# Harden shell and validate input format/content before use
set -euo pipefail
USER_INPUT="${{ inputs.user_input }}"
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "❌ Invalid input format"
exit 1
fi
```
## Docker Security in Actions
### Pin Docker Images to Digests
```yaml
# ❌ BAD - mutable tag
container: node:18
# ✅ GOOD - pinned to digest
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
```
## Common Patterns
### Secure File Operations
```yaml
- name: Process files securely
shell: bash
env:
FILE_PATH: ${{ inputs.file_path }}
run: |
set -euo pipefail # Fail on errors, undefined vars, pipe failures
# Use absolute paths and validate
SAFE_PATH=$(realpath "${FILE_PATH}")
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
echo "❌ Path outside workspace"
exit 1
fi
```
### Artifact Handling
```yaml
- name: Upload artifacts securely
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: build-artifacts
path: |
dist/
!dist/**/*.log # Exclude sensitive files
retention-days: 30
```
### GHCR authentication for pulls/scans
```yaml
# Minimal permissions required for GHCR pulls/scans
permissions:
contents: read
packages: read
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
```
## Security Checklist
- [ ] Minimum GITHUB_TOKEN permissions set
- [ ] Harden-Runner added to all ubuntu-latest jobs
- [ ] All third-party actions pinned to commit SHA
- [ ] Input validation implemented for custom actions
- [ ] Variables properly quoted in shell scripts
- [ ] Secrets stored in environment variables
- [ ] Docker images pinned to digests (if used)
- [ ] Error handling with `set -euo pipefail`
- [ ] File paths validated and sanitized
- [ ] No sensitive data in logs or outputs
- [ ] GHCR login performed before pulls/scans (packages: read)
- [ ] Job timeouts configured (`timeout-minutes`)
## Recommended Additional Workflows
Consider adding these security-focused workflows to your repository:
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
3. **Dependabot Configuration** - Automated dependency updates
## Resources
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)

View File

@@ -90,7 +90,7 @@ When testing hooks that use React Context:
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [],
onlyComplete: false,
responseStatus: "all",
},
setSelectedFilter: vi.fn(),
selectedOptions: {
@@ -291,11 +291,6 @@ test("handles different modes", async () => {
expect(vi.mocked(regularApi)).toHaveBeenCalled();
});
// Test sharing mode
vi.mocked(useParams).mockReturnValue({
surveyId: "123",
sharingKey: "share-123"
});
rerender();
await waitFor(() => {

View File

@@ -194,9 +194,6 @@ REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=

View File

@@ -1,6 +1,7 @@
name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D"
type: bug
projects: "formbricks/8"
labels: ["bug"]
body:
- type: textarea

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: Questions
url: https://github.com/formbricks/formbricks/discussions

View File

@@ -0,0 +1,312 @@
name: Build and Push Docker Image
description: |
Unified Docker build and push action for both ECR and GHCR registries.
Supports:
- ECR builds for Formbricks Cloud deployment
- GHCR builds for community self-hosting
- Automatic version resolution and tagging
- Conditional signing and deployment tags
inputs:
registry_type:
description: "Registry type: 'ecr' or 'ghcr'"
required: true
# Version input
version:
description: "Explicit version (SemVer only, e.g., 1.2.3). If provided, this version is used directly. If empty, version is auto-generated from branch name."
required: false
experimental_mode:
description: "Enable experimental timestamped versions"
required: false
default: "false"
# ECR specific inputs
ecr_registry:
description: "ECR registry URL (required for ECR builds)"
required: false
ecr_repository:
description: "ECR repository name (required for ECR builds)"
required: false
ecr_region:
description: "ECR AWS region (required for ECR builds)"
required: false
aws_role_arn:
description: "AWS role ARN for ECR authentication (required for ECR builds)"
required: false
# GHCR specific inputs
ghcr_image_name:
description: "GHCR image name (required for GHCR builds)"
required: false
# Deployment options
deploy_production:
description: "Tag image for production deployment"
required: false
default: "false"
deploy_staging:
description: "Tag image for staging deployment"
required: false
default: "false"
is_prerelease:
description: "Whether this is a prerelease (auto-tags for staging/production)"
required: false
default: "false"
# Build options
dockerfile:
description: "Path to Dockerfile"
required: false
default: "apps/web/Dockerfile"
context:
description: "Build context"
required: false
default: "."
outputs:
image_tag:
description: "Resolved image tag used for the build"
value: ${{ steps.version.outputs.version }}
registry_tags:
description: "Complete registry tags that were pushed"
value: ${{ steps.build.outputs.tags }}
image_digest:
description: "Image digest from the build"
value: ${{ steps.build.outputs.digest }}
runs:
using: "composite"
steps:
- name: Validate inputs
shell: bash
env:
REGISTRY_TYPE: ${{ inputs.registry_type }}
ECR_REGISTRY: ${{ inputs.ecr_registry }}
ECR_REPOSITORY: ${{ inputs.ecr_repository }}
ECR_REGION: ${{ inputs.ecr_region }}
AWS_ROLE_ARN: ${{ inputs.aws_role_arn }}
GHCR_IMAGE_NAME: ${{ inputs.ghcr_image_name }}
run: |
set -euo pipefail
if [[ "$REGISTRY_TYPE" != "ecr" && "$REGISTRY_TYPE" != "ghcr" ]]; then
echo "ERROR: registry_type must be 'ecr' or 'ghcr', got: $REGISTRY_TYPE"
exit 1
fi
if [[ "$REGISTRY_TYPE" == "ecr" ]]; then
if [[ -z "$ECR_REGISTRY" || -z "$ECR_REPOSITORY" || -z "$ECR_REGION" || -z "$AWS_ROLE_ARN" ]]; then
echo "ERROR: ECR builds require ecr_registry, ecr_repository, ecr_region, and aws_role_arn"
exit 1
fi
fi
if [[ "$REGISTRY_TYPE" == "ghcr" ]]; then
if [[ -z "$GHCR_IMAGE_NAME" ]]; then
echo "ERROR: GHCR builds require ghcr_image_name"
exit 1
fi
fi
echo "SUCCESS: Input validation passed for $REGISTRY_TYPE build"
- name: Resolve Docker version
id: version
uses: ./.github/actions/resolve-docker-version
with:
version: ${{ inputs.version }}
current_branch: ${{ github.ref_name }}
experimental_mode: ${{ inputs.experimental_mode }}
- name: Update package.json version
uses: ./.github/actions/update-package-version
with:
version: ${{ steps.version.outputs.version }}
- name: Configure AWS credentials (ECR only)
if: ${{ inputs.registry_type == 'ecr' }}
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.2.0
with:
role-to-assume: ${{ inputs.aws_role_arn }}
aws-region: ${{ inputs.ecr_region }}
- name: Log in to Amazon ECR (ECR only)
if: ${{ inputs.registry_type == 'ecr' }}
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1
- name: Set up Docker build tools
uses: ./.github/actions/docker-build-setup
with:
registry: ${{ inputs.registry_type == 'ghcr' && 'ghcr.io' || '' }}
setup_cosign: ${{ inputs.registry_type == 'ghcr' && 'true' || 'false' }}
skip_login_on_pr: ${{ inputs.registry_type == 'ghcr' && 'true' || 'false' }}
- name: Build ECR tag list
if: ${{ inputs.registry_type == 'ecr' }}
id: ecr-tags
shell: bash
env:
IMAGE_TAG: ${{ steps.version.outputs.version }}
ECR_REGISTRY: ${{ inputs.ecr_registry }}
ECR_REPOSITORY: ${{ inputs.ecr_repository }}
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
IS_PRERELEASE: ${{ inputs.is_prerelease }}
run: |
set -euo pipefail
# Start with the base image tag
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
# Handle automatic tagging based on release type
if [[ "${IS_PRERELEASE}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
echo "Adding staging tag for prerelease"
elif [[ "${IS_PRERELEASE}" == "false" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
echo "Adding production tag for stable release"
fi
# Handle manual deployment overrides
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
echo "Adding production tag (manual override)"
fi
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
echo "Adding staging tag (manual override)"
fi
echo "ECR tags generated:"
echo -e "${TAGS}"
{
echo "tags<<EOF"
echo -e "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Generate additional GHCR tags for releases
if: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
id: ghcr-extra-tags
shell: bash
env:
VERSION: ${{ steps.version.outputs.version }}
IMAGE_NAME: ${{ inputs.ghcr_image_name }}
IS_PRERELEASE: ${{ inputs.is_prerelease }}
run: |
set -euo pipefail
# Start with base version tag
TAGS="ghcr.io/${IMAGE_NAME}:${VERSION}"
# For proper SemVer releases, add major.minor and major tags
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
# Extract major and minor versions
MAJOR=$(echo "${VERSION}" | cut -d. -f1)
MINOR=$(echo "${VERSION}" | cut -d. -f2)
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:${MAJOR}.${MINOR}"
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:${MAJOR}"
echo "Added SemVer tags: ${MAJOR}.${MINOR}, ${MAJOR}"
fi
# Add latest tag for stable releases
if [[ "${IS_PRERELEASE}" == "false" ]]; then
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:latest"
echo "Added latest tag for stable release"
fi
echo "Generated GHCR tags:"
echo -e "${TAGS}"
# Debug: Show what will be passed to Docker build
echo "DEBUG: Tags for Docker build step:"
echo -e "${TAGS}"
{
echo "tags<<EOF"
echo -e "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Build GHCR metadata (experimental)
if: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' }}
id: ghcr-meta-experimental
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ inputs.ghcr_image_name }}
tags: |
type=ref,event=branch
type=raw,value=${{ steps.version.outputs.version }}
- name: Debug Docker build tags
shell: bash
run: |
echo "=== DEBUG: Docker Build Configuration ==="
echo "Registry Type: ${{ inputs.registry_type }}"
echo "Experimental Mode: ${{ inputs.experimental_mode }}"
echo "Event Name: ${{ github.event_name }}"
echo "Is Prerelease: ${{ inputs.is_prerelease }}"
echo "Version: ${{ steps.version.outputs.version }}"
if [[ "${{ inputs.registry_type }}" == "ecr" ]]; then
echo "ECR Tags: ${{ steps.ecr-tags.outputs.tags }}"
elif [[ "${{ inputs.experimental_mode }}" == "true" ]]; then
echo "GHCR Experimental Tags: ${{ steps.ghcr-meta-experimental.outputs.tags }}"
else
echo "GHCR Extra Tags: ${{ steps.ghcr-extra-tags.outputs.tags }}"
fi
- name: Build and push Docker image
id: build
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ env.DEPOT_PROJECT_TOKEN }}
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ inputs.registry_type == 'ecr' && steps.ecr-tags.outputs.tags || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags) || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && steps.ghcr-extra-tags.outputs.tags) || (inputs.registry_type == 'ghcr' && format('ghcr.io/{0}:{1}', inputs.ghcr_image_name, steps.version.outputs.version)) || (inputs.registry_type == 'ecr' && format('{0}/{1}:{2}', inputs.ecr_registry, inputs.ecr_repository, steps.version.outputs.version)) }}
labels: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.labels || '' }}
secrets: |
database_url=${{ env.DUMMY_DATABASE_URL }}
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
- name: Sign GHCR image (GHCR only)
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
shell: bash
env:
TAGS: ${{ inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags || steps.ghcr-extra-tags.outputs.tags }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
- name: Output build summary
shell: bash
env:
REGISTRY_TYPE: ${{ inputs.registry_type }}
IMAGE_TAG: ${{ steps.version.outputs.version }}
VERSION_SOURCE: ${{ steps.version.outputs.source }}
run: |
echo "SUCCESS: Built and pushed Docker image to $REGISTRY_TYPE"
echo "Image Tag: $IMAGE_TAG (source: $VERSION_SOURCE)"
if [[ "$REGISTRY_TYPE" == "ecr" ]]; then
echo "ECR Registry: ${{ inputs.ecr_registry }}"
echo "ECR Repository: ${{ inputs.ecr_repository }}"
else
echo "GHCR Image: ghcr.io/${{ inputs.ghcr_image_name }}"
fi

View File

@@ -62,10 +62,12 @@ runs:
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
shell: bash
- run: |

View File

@@ -0,0 +1,106 @@
name: Docker Build Setup
description: |
Sets up common Docker build tools and authentication with security validation.
Security Features:
- Registry URL validation
- Input sanitization
- Conditional setup based on event type
- Post-setup verification
Supports Depot CLI, Cosign signing, and Docker registry authentication.
inputs:
registry:
description: "Docker registry hostname to login to (e.g., ghcr.io, registry.example.com:5000). No paths allowed."
required: false
default: "ghcr.io"
setup_cosign:
description: "Whether to install cosign for image signing"
required: false
default: "true"
skip_login_on_pr:
description: "Whether to skip registry login on pull requests"
required: false
default: "true"
runs:
using: "composite"
steps:
- name: Validate inputs
shell: bash
env:
REGISTRY: ${{ inputs.registry }}
SETUP_COSIGN: ${{ inputs.setup_cosign }}
SKIP_LOGIN_ON_PR: ${{ inputs.skip_login_on_pr }}
run: |
set -euo pipefail
# Security: Validate registry input - must be hostname[:port] only, no paths
# Allow empty registry for cases where login is handled externally (e.g., ECR)
if [[ -n "$REGISTRY" ]]; then
if [[ "$REGISTRY" =~ / ]]; then
echo "ERROR: Invalid registry format: $REGISTRY"
echo "Registry must be host[:port] with no path (e.g., 'ghcr.io' or 'registry.example.com:5000')"
echo "Path components like 'ghcr.io/org' are not allowed as they break docker login"
exit 1
fi
# Validate hostname with optional port format
if [[ ! "$REGISTRY" =~ ^[a-zA-Z0-9.-]+(\:[0-9]+)?$ ]]; then
echo "ERROR: Invalid registry hostname format: $REGISTRY"
echo "Registry must be a valid hostname optionally with port (e.g., 'ghcr.io' or 'registry.example.com:5000')"
exit 1
fi
fi
# Validate boolean inputs
if [[ "$SETUP_COSIGN" != "true" && "$SETUP_COSIGN" != "false" ]]; then
echo "ERROR: setup_cosign must be 'true' or 'false', got: $SETUP_COSIGN"
exit 1
fi
if [[ "$SKIP_LOGIN_ON_PR" != "true" && "$SKIP_LOGIN_ON_PR" != "false" ]]; then
echo "ERROR: skip_login_on_pr must be 'true' or 'false', got: $SKIP_LOGIN_ON_PR"
exit 1
fi
echo "SUCCESS: Input validation passed"
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Install cosign
# Install cosign when requested AND when we might actually sign images
# (i.e., non-PR contexts or when we login on PRs)
if: ${{ inputs.setup_cosign == 'true' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
- name: Log into registry
if: ${{ inputs.registry != '' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ inputs.registry }}
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Verify setup completion
shell: bash
run: |
set -euo pipefail
# Verify Depot CLI is available
if ! command -v depot >/dev/null 2>&1; then
echo "ERROR: Depot CLI not found in PATH"
exit 1
fi
# Verify cosign if it should be installed (same conditions as install step)
if [[ "${{ inputs.setup_cosign }}" == "true" ]] && [[ "${{ inputs.skip_login_on_pr }}" == "false" || "${{ github.event_name }}" != "pull_request" ]]; then
if ! command -v cosign >/dev/null 2>&1; then
echo "ERROR: Cosign not found in PATH despite being requested"
exit 1
fi
fi
echo "SUCCESS: Docker build setup completed successfully"

View File

@@ -0,0 +1,192 @@
name: Resolve Docker Version
description: |
Resolves and validates Docker-compatible SemVer versions for container builds with comprehensive security.
Security Features:
- Command injection protection
- Input sanitization and validation
- Docker tag character restrictions
- Length limits and boundary checks
- Safe branch name handling
Supports multiple modes: release, manual override, branch auto-detection, and experimental timestamped versions.
inputs:
version:
description: "Explicit version (SemVer only, e.g., 1.2.3-beta). If provided, this version is used directly. If empty, version is auto-generated from branch name."
required: false
current_branch:
description: "Current branch name for auto-detection"
required: true
experimental_mode:
description: "Enable experimental mode with timestamp-based versions"
required: false
default: "false"
outputs:
version:
description: "Resolved Docker-compatible SemVer version"
value: ${{ steps.resolve.outputs.version }}
source:
description: "Source of version (release|override|branch)"
value: ${{ steps.resolve.outputs.source }}
normalized:
description: "Whether the version was normalized (true/false)"
value: ${{ steps.resolve.outputs.normalized }}
runs:
using: "composite"
steps:
- name: Resolve and validate Docker version
id: resolve
shell: bash
env:
EXPLICIT_VERSION: ${{ inputs.version }}
CURRENT_BRANCH: ${{ inputs.current_branch }}
EXPERIMENTAL_MODE: ${{ inputs.experimental_mode }}
run: |
set -euo pipefail
# Function to validate SemVer format (Docker-compatible, no '+' build metadata)
validate_semver() {
local version="$1"
local context="$2"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid $context format. Must be semver without build metadata (e.g., 1.2.3, 1.2.3-alpha)"
echo "Provided: $version"
echo "Note: Docker tags cannot contain '+' characters. Use prerelease identifiers instead."
exit 1
fi
}
# Function to generate branch-based version
generate_branch_version() {
local branch="$1"
local use_timestamp="${2:-true}"
local timestamp
if [[ "$use_timestamp" == "true" ]]; then
timestamp=$(date +%s)
else
timestamp=""
fi
# Sanitize branch name for Docker compatibility
local sanitized_branch=$(echo "$branch" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
# Additional safety: truncate if too long (reserve space for prefix and timestamp)
if (( ${#sanitized_branch} > 80 )); then
sanitized_branch="${sanitized_branch:0:80}"
echo "INFO: Branch name truncated for Docker compatibility" >&2
fi
local version
# Generate version based on branch name (unified approach)
# All branches get alpha versions with sanitized branch name
if [[ -n "$timestamp" ]]; then
version="0.0.0-alpha-$sanitized_branch-$timestamp"
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
else
version="0.0.0-alpha-$sanitized_branch"
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
fi
echo "$version"
}
# Input validation and sanitization
if [[ -z "$CURRENT_BRANCH" ]]; then
echo "ERROR: current_branch input is required"
exit 1
fi
# Security: Validate inputs to prevent command injection
# Use grep to check for dangerous characters (more reliable than bash regex)
validate_input() {
local input="$1"
local name="$2"
# Check for dangerous characters using grep
if echo "$input" | grep -q '[;|&`$(){}\\[:space:]]'; then
echo "ERROR: $name contains potentially dangerous characters: $input"
echo "Input should only contain letters, numbers, hyphens, underscores, dots, and forward slashes"
return 1
fi
return 0
}
# Validate current branch
if ! validate_input "$CURRENT_BRANCH" "Branch name"; then
exit 1
fi
# Validate explicit version if provided
if [[ -n "$EXPLICIT_VERSION" ]] && ! validate_input "$EXPLICIT_VERSION" "Explicit version"; then
exit 1
fi
# Main resolution logic (ultra-simplified)
NORMALIZED="false"
if [[ -n "$EXPLICIT_VERSION" ]]; then
# Use provided explicit version (from either workflow_call or manual input)
validate_semver "$EXPLICIT_VERSION" "explicit version"
# Normalize to lowercase for Docker/ECR compatibility
RESOLVED_VERSION="${EXPLICIT_VERSION,,}"
if [[ "$EXPLICIT_VERSION" != "$RESOLVED_VERSION" ]]; then
NORMALIZED="true"
echo "INFO: Original version contained uppercase characters, normalized: $EXPLICIT_VERSION -> $RESOLVED_VERSION"
fi
SOURCE="explicit"
echo "INFO: Using explicit version: $RESOLVED_VERSION"
else
# Auto-generate version from branch name
if [[ "$EXPERIMENTAL_MODE" == "true" ]]; then
# Use timestamped version generation
echo "INFO: Experimental mode: generating timestamped version from branch: $CURRENT_BRANCH"
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "true")
SOURCE="experimental"
else
# Standard branch version (no timestamp)
echo "INFO: Auto-detecting version from branch: $CURRENT_BRANCH"
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "false")
SOURCE="branch"
fi
echo "Generated version: $RESOLVED_VERSION"
fi
# Final validation - ensure result is valid Docker tag
if [[ -z "$RESOLVED_VERSION" ]]; then
echo "ERROR: Failed to resolve version"
exit 1
fi
if (( ${#RESOLVED_VERSION} > 128 )); then
echo "ERROR: Version must be at most 128 characters (Docker limitation)"
echo "Generated version: $RESOLVED_VERSION (${#RESOLVED_VERSION} chars)"
exit 1
fi
if [[ ! "$RESOLVED_VERSION" =~ ^[a-z0-9._-]+$ ]]; then
echo "ERROR: Version contains invalid characters for Docker tags"
echo "Version: $RESOLVED_VERSION"
exit 1
fi
if [[ "$RESOLVED_VERSION" =~ ^[.-] || "$RESOLVED_VERSION" =~ [.-]$ ]]; then
echo "ERROR: Version must not start or end with '.' or '-'"
echo "Version: $RESOLVED_VERSION"
exit 1
fi
# Output results
echo "SUCCESS: Resolved Docker version: $RESOLVED_VERSION (source: $SOURCE)"
echo "version=$RESOLVED_VERSION" >> $GITHUB_OUTPUT
echo "source=$SOURCE" >> $GITHUB_OUTPUT
echo "normalized=$NORMALIZED" >> $GITHUB_OUTPUT

View File

@@ -0,0 +1,160 @@
name: Update Package Version
description: |
Safely updates package.json version with comprehensive validation and atomic operations.
Security Features:
- Path traversal protection
- SemVer validation with length limits
- Atomic file operations with backup/recovery
- JSON validation before applying changes
This action is designed to be secure by default and prevent common attack vectors.
inputs:
version:
description: "Version to set in package.json (must be valid SemVer)"
required: true
package_path:
description: "Path to package.json file"
required: false
default: "./apps/web/package.json"
outputs:
updated_version:
description: "The version that was actually set in package.json"
value: ${{ steps.update.outputs.updated_version }}
runs:
using: "composite"
steps:
- name: Update and verify package.json version
id: update
shell: bash
env:
VERSION: ${{ inputs.version }}
PACKAGE_PATH: ${{ inputs.package_path }}
run: |
set -euo pipefail
# Validate inputs
if [[ -z "$VERSION" ]]; then
echo "ERROR: version input is required"
exit 1
fi
# Security: Validate package_path to prevent path traversal attacks
# Only allow paths within the workspace and must end with package.json
if [[ "$PACKAGE_PATH" =~ \.\./|^/|^~ ]]; then
echo "ERROR: Invalid package path - path traversal detected: $PACKAGE_PATH"
echo "Package path must be relative to workspace root and cannot contain '../', start with '/', or '~'"
exit 1
fi
if [[ ! "$PACKAGE_PATH" =~ package\.json$ ]]; then
echo "ERROR: Package path must end with 'package.json': $PACKAGE_PATH"
exit 1
fi
# Resolve to absolute path within workspace for additional security
WORKSPACE_ROOT="${GITHUB_WORKSPACE:-$(pwd)}"
# Use realpath to resolve both paths and handle symlinks properly
WORKSPACE_ROOT=$(realpath "$WORKSPACE_ROOT")
RESOLVED_PATH=$(realpath "${WORKSPACE_ROOT}/${PACKAGE_PATH}")
# Ensure WORKSPACE_ROOT has a trailing slash for proper prefix matching
WORKSPACE_ROOT="${WORKSPACE_ROOT}/"
# Use shell string matching to ensure RESOLVED_PATH is within workspace
# This is more secure than regex and handles edge cases properly
if [[ "$RESOLVED_PATH" != "$WORKSPACE_ROOT"* ]]; then
echo "ERROR: Resolved path is outside workspace: $RESOLVED_PATH"
echo "Workspace root: $WORKSPACE_ROOT"
exit 1
fi
if [[ ! -f "$RESOLVED_PATH" ]]; then
echo "ERROR: package.json not found at: $RESOLVED_PATH"
exit 1
fi
# Use resolved path for operations
PACKAGE_PATH="$RESOLVED_PATH"
# Validate SemVer format with additional security checks
if [[ ${#VERSION} -gt 128 ]]; then
echo "ERROR: Version string too long (${#VERSION} chars, max 128): $VERSION"
exit 1
fi
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid SemVer format: $VERSION"
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE]"
echo "Only alphanumeric characters, dots, and hyphens allowed in prerelease"
exit 1
fi
# Additional validation: Check for reasonable version component sizes
# Extract base version (MAJOR.MINOR.PATCH) without prerelease/build metadata
if [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
else
echo "ERROR: Could not extract base version from: $VERSION"
exit 1
fi
# Split version components safely
IFS='.' read -ra VERSION_PARTS <<< "$BASE_VERSION"
# Validate component sizes (should have exactly 3 parts due to regex above)
if (( ${VERSION_PARTS[0]} > 999 || ${VERSION_PARTS[1]} > 999 || ${VERSION_PARTS[2]} > 999 )); then
echo "ERROR: Version components too large (max 999 each): $VERSION"
echo "Components: ${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
exit 1
fi
echo "Updating package.json version to: $VERSION"
# Create backup for atomic operations
BACKUP_PATH="${PACKAGE_PATH}.backup.$$"
cp "$PACKAGE_PATH" "$BACKUP_PATH"
# Use jq to safely update the version field with error handling
if ! jq --arg version "$VERSION" '.version = $version' "$PACKAGE_PATH" > "${PACKAGE_PATH}.tmp"; then
echo "ERROR: jq failed to process package.json"
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
exit 1
fi
# Validate the generated JSON before applying changes
if ! jq empty "${PACKAGE_PATH}.tmp" 2>/dev/null; then
echo "ERROR: Generated invalid JSON"
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
exit 1
fi
# Atomic move operation
if ! mv "${PACKAGE_PATH}.tmp" "$PACKAGE_PATH"; then
echo "ERROR: Failed to update package.json"
# Restore backup
mv "$BACKUP_PATH" "$PACKAGE_PATH"
exit 1
fi
# Verify the update was successful
UPDATED_VERSION=$(jq -r '.version' "$PACKAGE_PATH" 2>/dev/null)
if [[ "$UPDATED_VERSION" != "$VERSION" ]]; then
echo "ERROR: Version update failed!"
echo "Expected: $VERSION"
echo "Actual: $UPDATED_VERSION"
# Restore backup
mv "$BACKUP_PATH" "$PACKAGE_PATH"
exit 1
fi
# Clean up backup on success
rm -f "$BACKUP_PATH"
echo "SUCCESS: Updated package.json version to: $UPDATED_VERSION"
echo "updated_version=$UPDATED_VERSION" >> $GITHUB_OUTPUT

View File

@@ -1,125 +0,0 @@
name: 'Upload Sentry Sourcemaps'
description: 'Extract sourcemaps from Docker image and upload to Sentry'
inputs:
docker_image:
description: 'Docker image to extract sourcemaps from'
required: true
release_version:
description: 'Sentry release version (e.g., v1.2.3)'
required: true
sentry_auth_token:
description: 'Sentry authentication token'
required: true
environment:
description: 'Sentry environment (e.g., production, staging)'
required: false
default: 'staging'
runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate Sentry auth token
shell: bash
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
"https://sentry.io/api/0/organizations/formbricks/")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" != "200" ]; then
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
if [ -f /tmp/sentry_response.json ]; then
echo "Response body:"
cat /tmp/sentry_response.json
fi
exit 1
fi
echo "✅ Sentry auth token validated successfully"
# Clean up temp file
rm -f /tmp/sentry_response.json
- name: Extract sourcemaps from Docker image
shell: bash
run: |
set -euo pipefail
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
echo "Container created with ID: $CONTAINER_ID"
# Set up cleanup function to ensure container is removed on script exit
cleanup_container() {
# Capture the current exit code to preserve it
local original_exit_code=$?
echo "🧹 Cleaning up Docker container..."
# Remove the container if it exists (ignore errors if already removed)
if [ -n "$CONTAINER_ID" ]; then
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
echo "Container $CONTAINER_ID removed"
fi
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
# Extract .next directory containing sourcemaps
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
# Verify sourcemaps exist
if [ ! -d "./extracted-next/static/chunks" ]; then
echo "❌ Error: .next/static/chunks directory not found in Docker image"
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
exit 1
fi
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
echo "✅ Found $sourcemap_count sourcemap files"
if [ "$sourcemap_count" -eq 0 ]; then
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
exit 1
fi
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud
with:
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'
- name: Clean up extracted files
shell: bash
if: always()
run: |
set -euo pipefail
# Clean up extracted files
rm -rf ./extracted-next
echo "🧹 Cleaned up extracted files"

View File

@@ -1,82 +0,0 @@
name: "Apply issue labels to PR"
on:
pull_request_target:
types:
- opened
permissions:
contents: read
jobs:
label_on_pr:
runs-on: ubuntu-latest
permissions:
contents: none
issues: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Apply labels from linked issue to PR
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
async function getLinkedIssues(owner, repo, prNumber) {
const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
closingIssuesReferences(first: 10) {
nodes {
number
labels(first: 10) {
nodes {
name
}
}
}
}
}
}
}`;
const variables = {
owner: owner,
repo: repo,
prNumber: prNumber,
};
const result = await github.graphql(query, variables);
return result.repository.pullRequest.closingIssuesReferences.nodes;
}
const pr = context.payload.pull_request;
const linkedIssues = await getLinkedIssues(
context.repo.owner,
context.repo.repo,
pr.number
);
const labelsToAdd = new Set();
for (const issue of linkedIssues) {
if (issue.labels && issue.labels.nodes) {
for (const label of issue.labels.nodes) {
labelsToAdd.add(label.name);
}
}
}
if (labelsToAdd.size) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: Array.from(labelsToAdd),
});
}

View File

@@ -0,0 +1,88 @@
name: Build Cloud Deployment Images
# This workflow builds Formbricks Docker images for ECR deployment:
# - workflow_call: Used by releases with explicit SemVer versions
# - workflow_dispatch: Auto-detects version from current branch or uses override
on:
workflow_dispatch:
inputs:
version_override:
description: "Override version (SemVer only, e.g., 1.2.3). Leave empty to auto-detect from branch."
required: false
type: string
deploy_production:
description: "Tag image for production deployment"
required: false
default: false
type: boolean
deploy_staging:
description: "Tag image for staging deployment"
required: false
default: false
type: boolean
workflow_call:
inputs:
image_tag:
description: "Image tag to push (required for workflow_call)"
required: true
type: string
IS_PRERELEASE:
description: "Whether this is a prerelease (auto-tags for staging/production)"
required: false
type: boolean
default: false
outputs:
IMAGE_TAG:
description: "Normalized image tag used for the build"
value: ${{ jobs.build-and-push.outputs.IMAGE_TAG }}
TAGS:
description: "Newline-separated list of ECR tags pushed"
value: ${{ jobs.build-and-push.outputs.TAGS }}
permissions:
contents: read
id-token: write
env:
ECR_REGION: ${{ vars.ECR_REGION }}
# ECR settings are sourced from repository/environment variables for portability across envs/forks
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
IMAGE_TAG: ${{ steps.build.outputs.image_tag }}
TAGS: ${{ steps.build.outputs.registry_tags }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build and push cloud deployment image
id: build
uses: ./.github/actions/build-and-push-docker
with:
registry_type: "ecr"
ecr_registry: ${{ env.ECR_REGISTRY }}
ecr_repository: ${{ env.ECR_REPOSITORY }}
ecr_region: ${{ env.ECR_REGION }}
aws_role_arn: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
version: ${{ inputs.version_override || inputs.image_tag }}
deploy_production: ${{ inputs.deploy_production }}
deploy_staging: ${{ inputs.deploy_staging }}
is_prerelease: ${{ inputs.IS_PRERELEASE }}
env:
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -6,12 +6,14 @@ on:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read

View File

@@ -1,27 +0,0 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
VERSION:
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
description: "The version of the Docker image to release (clean SemVer, e.g., 1.2.3)"
required: true
type: string
REPOSITORY:
@@ -37,17 +37,22 @@ on:
permissions:
id-token: write
contents: write
contents: read
jobs:
helmfile-deploy:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale
uses: tailscale/github-action@v3
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
@@ -66,7 +71,7 @@ jobs:
env:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@v2
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
name: Deploy Formbricks Cloud Production
if: inputs.ENVIRONMENT == 'production'
env:
@@ -84,7 +89,7 @@ jobs:
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@v2
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
name: Deploy Formbricks Cloud Staging
if: inputs.ENVIRONMENT == 'staging'
env:
@@ -106,15 +111,16 @@ jobs:
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
run: |
# Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
if [[ "$ENVIRONMENT" == "production" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"
fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
# Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF

View File

@@ -21,10 +21,10 @@ jobs:
name: Validate Docker Build
runs-on: ubuntu-latest
# Add PostgreSQL service container
# Add PostgreSQL and Redis service containers
services:
postgres:
image: pgvector/pgvector:pg17
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
@@ -38,43 +38,98 @@ jobs:
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
ports:
- 6379:6379
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build Docker Image
uses: docker/build-push-action@v6
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
env:
GITHUB_SHA: ${{ github.sha }}
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ github.sha }}
tags: formbricks-test:${{ env.GITHUB_SHA }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
redis_url=redis://localhost:6379
- name: Verify PostgreSQL Connection
- name: Verify and Initialize PostgreSQL
run: |
echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
# Test connection using psql with timeout and proper error handling
echo "Testing PostgreSQL connection with 30 second timeout..."
if timeout 30 bash -c 'until PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" >/dev/null 2>&1; do
echo "Waiting for PostgreSQL to be ready..."
sleep 2
done'; then
echo "✅ PostgreSQL connection successful"
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "SELECT version();"
# Enable necessary extensions that might be required by migrations
echo "Enabling required PostgreSQL extensions..."
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "CREATE EXTENSION IF NOT EXISTS vector;" || echo "Vector extension already exists or not available"
else
echo "❌ PostgreSQL connection failed after 30 seconds"
exit 1
fi
# Show network configuration
echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Verify Redis/Valkey Connection
run: |
echo "Verifying Redis/Valkey connection..."
# Install Redis client to test connection
sudo apt-get update && sudo apt-get install -y redis-tools
# Test connection using redis-cli with timeout and proper error handling
echo "Testing Redis connection with 30 second timeout..."
if timeout 30 bash -c 'until redis-cli -h localhost -p 6379 ping >/dev/null 2>&1; do
echo "Waiting for Redis to be ready..."
sleep 2
done'; then
echo "✅ Redis connection successful"
redis-cli -h localhost -p 6379 info server | head -5
else
echo "❌ Redis connection failed after 30 seconds"
exit 1
fi
# Show network configuration for Redis
echo "Redis network configuration:"
netstat -tulpn | grep 6379 || echo "No process listening on port 6379"
- name: Test Docker Image with Health Check
shell: bash
env:
GITHUB_SHA: ${{ github.sha }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
run: |
echo "🧪 Testing if the Docker image starts correctly..."
@@ -86,29 +141,13 @@ jobs:
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-e REDIS_URL="redis://host.docker.internal:6379" \
-d "formbricks-test:$GITHUB_SHA"
# Give it more time to start up
echo "Waiting 45 seconds for application to start..."
sleep 45
# Check if the container is running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
echo "❌ Container failed to start properly!"
docker logs formbricks-test
exit 1
else
echo "✅ Container started successfully!"
fi
# Try connecting to PostgreSQL from inside the container
echo "Testing PostgreSQL connection from inside container..."
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
# Try to access the health endpoint
echo "🏥 Testing /health endpoint..."
MAX_RETRIES=10
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."
MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes
RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false
@@ -116,38 +155,32 @@ jobs:
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Show container logs before each attempt to help debugging
if [ $RETRY_COUNT -gt 1 ]; then
echo "📋 Current container logs:"
docker logs --tail 20 formbricks-test
# Check if container is still running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then
echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!"
echo "📋 Container logs:"
docker logs formbricks-test
exit 1
fi
# Get detailed curl output for debugging
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
CURL_EXIT_CODE=$?
echo "Curl exit code: $CURL_EXIT_CODE"
echo "Curl output: $HTTP_OUTPUT"
if [ $CURL_EXIT_CODE -eq 0 ]; then
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
echo "Status code detected: $STATUS_CODE"
if [ "$STATUS_CODE" = "200" ]; then
echo "✅ Health check successful!"
HEALTH_CHECK_SUCCESS=true
break
else
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
fi
else
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
# Show progress and diagnostic info every 12 attempts (1 minute intervals)
if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then
echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..."
echo "📋 Recent container logs:"
docker logs --tail 10 formbricks-test
fi
echo "Waiting 15 seconds before next attempt..."
sleep 15
# Try health endpoint with shorter timeout for faster polling
# Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx)
if curl -f -s -m 10 http://localhost:3000/health >/dev/null 2>&1; then
echo "✅ Health check successful after $((RETRY_COUNT * 5)) seconds!"
HEALTH_CHECK_SUCCESS=true
break
fi
# Wait 5 seconds before next attempt
sleep 5
done
# Show full container logs for debugging
@@ -160,7 +193,7 @@ jobs:
# Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $MAX_RETRIES attempts"
echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)"
exit 1
fi

View File

@@ -0,0 +1,70 @@
name: Docker Security Scan
on:
schedule:
- cron: "0 2 * * *" # Daily at 2 AM UTC
workflow_dispatch:
workflow_run:
workflows: ["Docker Release to Github"]
types: [completed]
permissions:
contents: read
packages: read
security-events: write
jobs:
scan:
name: Vulnerability Scan
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout (for SARIF fingerprinting only)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
- name: Determine ref and commit for upload
id: gitref
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "workflow_run" ]]; then
echo "ref=refs/heads/${HEAD_BRANCH}" >> "$GITHUB_OUTPUT"
echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
else
echo "ref=${GITHUB_REF}" >> "$GITHUB_OUTPUT"
echo "sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
with:
image-ref: "ghcr.io/${{ github.repository }}:latest"
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH,MEDIUM,LOW"
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
if: ${{ always() }}
with:
sarif_file: "trivy-results.sarif"
ref: ${{ steps.gitref.outputs.ref }}
sha: ${{ steps.gitref.outputs.sha }}
category: "trivy-container-scan"

View File

@@ -182,4 +182,4 @@ jobs:
- name: Output App Logs
if: failure()
run: cat app.log
run: cat app.log

View File

@@ -7,56 +7,75 @@ on:
permissions:
contents: read
env:
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
jobs:
docker-build:
name: Build & release docker image
docker-build-community:
name: Build & release community docker image
permissions:
contents: read
packages: write
id-token: write
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
docker-build-cloud:
name: Build & push Formbricks Cloud to ECR
permissions:
contents: read
id-token: write
uses: ./.github/workflows/build-and-push-ecr.yml
secrets: inherit
with:
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
needs:
- docker-build-community
helm-chart-release:
name: Release Helm Chart
permissions:
contents: read
packages: write
uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit
needs:
- docker-build
- docker-build-community
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud
secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml
needs:
- docker-build
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ env.ENVIRONMENT }}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
verify-cloud-build:
name: Verify Cloud Build Outputs
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 5 # Simple verification should be quick
needs:
- docker-build
- deploy-formbricks-cloud
- docker-build-cloud
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
fetch-depth: 0
egress-policy: audit
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: ${{ env.ENVIRONMENT }}
- name: Display ECR build outputs
env:
IMAGE_TAG: ${{ needs.docker-build-cloud.outputs.IMAGE_TAG }}
TAGS: ${{ needs.docker-build-cloud.outputs.TAGS }}
run: |
set -euo pipefail
echo "✅ ECR Build Completed Successfully"
echo "Image Tag: ${IMAGE_TAG}"
echo "ECR Tags:"
printf '%s\n' "${TAGS}"
move-stable-tag:
name: Move stable tag to release
permissions:
contents: write # Required for tag push operations in called workflow
uses: ./.github/workflows/move-stable-tag.yml
needs:
- docker-build-community # Ensure release is successful first
with:
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}

96
.github/workflows/move-stable-tag.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
name: Move Stable Tag
on:
workflow_call:
inputs:
release_tag:
description: "The release tag name (e.g., 1.2.3)"
required: true
type: string
commit_sha:
description: "The commit SHA to point the stable tag to"
required: true
type: string
is_prerelease:
description: "Whether this is a prerelease (stable tag won't be moved for prereleases)"
required: false
type: boolean
default: false
permissions:
contents: read
# Prevent concurrent stable tag operations to avoid race conditions
concurrency:
group: move-stable-tag-${{ github.repository }}
cancel-in-progress: true
jobs:
move-stable-tag:
name: Move stable tag to release
runs-on: ubuntu-latest
timeout-minutes: 10 # Prevent hung git operations
permissions:
contents: write # Required to push tags
# Only move stable tag for non-prerelease versions
if: ${{ !inputs.is_prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0 # Full history needed for tag operations
- name: Validate inputs
env:
RELEASE_TAG: ${{ inputs.release_tag }}
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
set -euo pipefail
# Validate release tag format
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format. Expected format: 1.2.3, 1.2.3-alpha"
echo "Provided: $RELEASE_TAG"
exit 1
fi
# Validate commit SHA format (40 character hex)
if [[ ! "$COMMIT_SHA" =~ ^[a-f0-9]{40}$ ]]; then
echo "❌ Error: Invalid commit SHA format. Expected 40 character hex string"
echo "Provided: $COMMIT_SHA"
exit 1
fi
echo "✅ Input validation passed"
echo "Release tag: $RELEASE_TAG"
echo "Commit SHA: $COMMIT_SHA"
- name: Move stable tag
env:
RELEASE_TAG: ${{ inputs.release_tag }}
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
set -euo pipefail
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Verify the commit exists
if ! git cat-file -e "$COMMIT_SHA"; then
echo "❌ Error: Commit $COMMIT_SHA does not exist in this repository"
exit 1
fi
# Move stable tag to the release commit
echo "📌 Moving stable tag to commit: $COMMIT_SHA (release: $RELEASE_TAG)"
git tag -f stable "$COMMIT_SHA"
git push origin stable --force
echo "✅ Successfully moved stable tag to release $RELEASE_TAG"
echo "🔗 Stable tag now points to: https://github.com/${{ github.repository }}/commit/$COMMIT_SHA"

View File

@@ -1,190 +1,50 @@
name: Docker Release to Github Experimental
name: Build Community Testing Images
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow builds experimental/testing versions of Formbricks for self-hosting customers
# to test fixes and features before official releases. Images are pushed to GHCR with
# timestamped experimental versions for easy identification and testing.
on:
workflow_dispatch:
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}-experimental
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
inputs:
version_override:
description: "Override version (SemVer only, e.g., 1.2.3-beta). Leave empty for auto-generated experimental version."
required: false
type: string
permissions:
contents: read
packages: write
id-token: write
jobs:
build:
build-community-testing:
name: Build Community Testing Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
outputs:
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
timeout-minutes: 45
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Generate SemVer version from branch or tag
id: generate_version
run: |
# Get reference name and type
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
if [[ "$REF_TYPE" == "tag" ]]; then
# If running from a tag, use the tag name
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
echo "Using SemVer tag: $VERSION"
else
# Tag is not SemVer, treat as prerelease
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_TAG"
echo "Using tag as prerelease: $VERSION"
fi
else
# Running from branch, use branch name as prerelease
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_BRANCH"
echo "Using branch as prerelease: $VERSION"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
env:
VERSION: ${{ env.VERSION }}
run: |
cd ./apps/web
npm version $VERSION --no-git-tag-version
echo "Updated version to: $(npm pkg get version)"
- name: Set Sentry environment in .env
run: |
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
echo "SENTRY_ENVIRONMENT=staging" >> .env
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
else
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
fi
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Extract image info for sourcemap upload
id: extract_image_info
run: |
# Use the first readable tag from metadata action output
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
# Use the generated version for Sentry release
RELEASE_VERSION="$VERSION"
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
echo "Docker image: $DOCKER_IMAGE"
echo "Release version: $RELEASE_VERSION"
echo "Available tags: ${{ steps.meta.outputs.tags }}"
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- build
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
- name: Build and push community testing image
uses: ./.github/actions/build-and-push-docker
with:
docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }}
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: staging
registry_type: "ghcr"
ghcr_image_name: "${{ github.repository }}-experimental"
experimental_mode: "true"
version: ${{ inputs.version_override }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -1,4 +1,4 @@
name: Docker Release to Github
name: Release Community Docker Images
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
@@ -9,7 +9,7 @@ on:
workflow_call:
inputs:
IS_PRERELEASE:
description: 'Whether this is a prerelease (affects latest tag)'
description: "Whether this is a prerelease (affects latest tag)"
required: false
type: boolean
default: false
@@ -23,12 +23,14 @@ env:
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
@@ -41,91 +43,60 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get Release Tag
- name: Extract release version from tag
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
set -euo pipefail
# Extract tag name with fallback logic for different trigger contexts
if [[ -n "${RELEASE_TAG:-}" ]]; then
TAG="$RELEASE_TAG"
echo "Using RELEASE_TAG override: $TAG"
elif [[ "$GITHUB_REF_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] || [[ "$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then
TAG="$GITHUB_REF_NAME"
echo "Using GITHUB_REF_NAME (looks like tag): $TAG"
else
# Fallback: extract from GITHUB_REF for direct tag triggers
TAG="${GITHUB_REF#refs/tags/}"
if [[ -z "$TAG" || "$TAG" == "$GITHUB_REF" ]]; then
TAG="$GITHUB_REF_NAME"
echo "Using GITHUB_REF_NAME as final fallback: $TAG"
else
echo "Extracted from GITHUB_REF: $TAG"
fi
fi
# Strip v-prefix if present (normalize to clean SemVer)
TAG=${TAG#[vV]}
# Validate SemVer format (supports prereleases like 4.0.0-rc.1)
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "ERROR: Invalid tag format '$TAG'. Expected SemVer (e.g., 1.2.3, 4.0.0-rc.1)"
exit 1
fi
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"
echo "Using version: $TAG"
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
- name: Build and push community release image
id: build
uses: ./.github/actions/build-and-push-docker
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Default semver tags (version, major.minor, major)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Only tag as 'latest' for stable releases (not prereleases)
type=raw,value=latest,enable=${{ inputs.IS_PRERELEASE != 'true' }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
registry_type: "ghcr"
ghcr_image_name: ${{ env.IMAGE_NAME }}
version: ${{ steps.extract_release_tag.outputs.VERSION }}
is_prerelease: ${{ inputs.IS_PRERELEASE }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -19,15 +19,30 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Extract release version
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- name: Validate input version
env:
INPUT_VERSION: ${{ inputs.VERSION }}
run: |
set -euo pipefail
# Validate input version format (expects clean semver without 'v' prefix)
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Expected: clean version without 'v' prefix"
echo "Provided: $INPUT_VERSION"
exit 1
fi
# Store validated version in environment variable
echo "VERSION<<EOF" >> $GITHUB_ENV
echo "$INPUT_VERSION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
@@ -35,20 +50,44 @@ jobs:
version: latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
env:
VERSION: ${{ env.VERSION }}
run: |
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
set -euo pipefail
echo "Updating Chart.yaml with version: ${VERSION}"
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
echo "✅ Successfully updated Chart.yaml"
- name: Package Helm chart
env:
VERSION: ${{ env.VERSION }}
run: |
set -euo pipefail
echo "Packaging Helm chart version: ${VERSION}"
helm package ./helm-chart
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
- name: Push Helm chart to GitHub Container Registry
env:
VERSION: ${{ env.VERSION }}
run: |
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
set -euo pipefail
echo "Pushing Helm chart to registry: formbricks-${VERSION}.tgz"
helm push "formbricks-${VERSION}.tgz" oci://ghcr.io/formbricks/helm-charts
echo "✅ Successfully pushed Helm chart to registry"

View File

@@ -1,81 +0,0 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: "17 17 * * 6"
push:
branches: ["main"]
workflow_dispatch:
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Add this permission
actions: write # Required for artifact upload
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: sarif
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
sarif_file: results.sarif

View File

@@ -56,11 +56,3 @@ jobs:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
# Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions for pull request titles! 🙏

View File

@@ -14,12 +14,14 @@ on:
paths:
- "infra/terraform/**"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -33,7 +35,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale
uses: tailscale/github-action@v3
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}

View File

@@ -27,10 +27,18 @@ jobs:
- name: Get source branch name
id: branch-name
env:
RAW_BRANCH: ${{ github.head_ref }}
run: |
RAW_BRANCH="${{ github.head_ref }}"
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
# Additional validation - ensure branch name is not empty after sanitization
if [[ -z "$SOURCE_BRANCH" ]]; then
echo "❌ Error: Branch name is empty after sanitization"
echo "Original branch: $RAW_BRANCH"
exit 1
fi
# Safely add to environment variables using GitHub's recommended method
# This prevents environment variable injection attacks

View File

@@ -1,46 +0,0 @@
name: Upload Sentry Sourcemaps (Manual)
on:
workflow_dispatch:
inputs:
docker_image:
description: "Docker image to extract sourcemaps from"
required: true
type: string
release_version:
description: "Release version (e.g., v1.2.3)"
required: true
type: string
tag_version:
description: "Docker image tag (leave empty to use release_version)"
required: false
type: string
permissions:
contents: read
jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Set Docker Image
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -1,32 +0,0 @@
name: "Welcome new contributors"
on:
issues:
types: opened
pull_request_target:
types: opened
permissions:
pull-requests: write
issues: write
jobs:
welcome-message:
name: Welcoming New Users
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event.action == 'opened'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you so much for making your first Pull Request and taking the time to improve Formbricks! 🚀🙏❤️
Feel free to join the conversation on [Github Discussions](https://github.com/formbricks/formbricks/discussions) if you need any help or have any questions. 😊
issue-message: |
Thank you for opening your first issue! 🙏❤️ One of our team members will review it and get back to you as soon as it possible. 😊

View File

@@ -31,6 +31,10 @@
{
"language": "pt-PT",
"path": "./apps/web/locales/pt-PT.json"
},
{
"language": "ro-RO",
"path": "./apps/web/locales/ro-RO.json"
}
],
"forceMode": "OVERRIDE"

View File

@@ -1,13 +1,16 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join } from "path";
const require = createRequire(import.meta.url);
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
const getAbsolutePath = (value: string) => {
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, "package.json")));
};
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],

View File

@@ -80,25 +80,25 @@ export const LandingSidebar = ({
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center gap-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<>
<div>
<div className="grow overflow-hidden">
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="max-w-28 truncate text-sm text-slate-500">
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
</>
</div>
</DropdownMenuTrigger>

View File

@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
industry: "ind",
defaultBrandColor: "#fff",
organizationTeams: [],
canDoRoleManagement: false,
isAccessControlAllowed: false,
userProjectsCount: 0,
} as any;

View File

@@ -42,7 +42,7 @@ interface ProjectSettingsProps {
industry: TProjectConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
canDoRoleManagement: boolean;
isAccessControlAllowed: boolean;
userProjectsCount: number;
}
@@ -53,7 +53,7 @@ export const ProjectSettings = ({
industry,
defaultBrandColor,
organizationTeams,
canDoRoleManagement = false,
isAccessControlAllowed = false,
userProjectsCount,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -174,7 +174,7 @@ export const ProjectSettings = ({
)}
/>
{canDoRoleManagement && userProjectsCount > 0 && (
{isAccessControlAllowed && userProjectsCount > 0 && (
<FormField
control={form.control}
name="teamIds"

View File

@@ -1,6 +1,6 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
// Mocks before component import
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
@@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any);
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
});
@@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);
@@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);

View File

@@ -2,7 +2,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
@@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
@@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
canDoRoleManagement={canDoRoleManagement}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
/>
{projects.length >= 1 && (

View File

@@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod";
@@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!canDoRoleManagement) {
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
@@ -71,10 +71,6 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {

View File

@@ -24,14 +24,17 @@ export const ActionClassesTable = ({
otherEnvActionClasses,
otherEnvironment,
}: ActionClassesTableProps) => {
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => {
const handleOpenActionDetailModalClick = (
e: React.MouseEvent<HTMLButtonElement>,
actionClass: TActionClass
) => {
e.preventDefault();
setActiveActionClass(actionClass);
setActionDetailModalOpen(true);
setIsActionDetailModalOpen(true);
};
return (
@@ -42,7 +45,7 @@ export const ActionClassesTable = ({
{actionClasses.length > 0 ? (
actionClasses.map((actionClass, index) => (
<button
onClick={(e) => {
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
handleOpenActionDetailModalClick(e, actionClass);
}}
className="w-full"
@@ -63,7 +66,7 @@ export const ActionClassesTable = ({
environmentId={environmentId}
environment={environment}
open={isActionDetailModalOpen}
setOpen={setActionDetailModalOpen}
setOpen={setIsActionDetailModalOpen}
actionClasses={actionClasses}
actionClass={activeActionClass}
isReadOnly={isReadOnly}

View File

@@ -70,15 +70,13 @@ export const ActionDetailModal = ({
};
return (
<>
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={typeDescription()}
/>
</>
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={typeDescription()}
/>
);
};

View File

@@ -11,6 +11,21 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
updateActionClassAction: vi.fn(),
}));
// Mock action utils
vi.mock("@/modules/survey/editor/lib/action-utils", () => ({
useActionClassKeys: vi.fn(() => ["existing-key"]),
createActionClassZodResolver: vi.fn(() => vi.fn()),
validatePermissions: vi.fn(),
}));
// Mock action builder
vi.mock("@/modules/survey/editor/lib/action-builder", () => ({
buildActionObject: vi.fn((data, environmentId, t) => ({
...data,
environmentId,
})),
}));
// Mock utils
vi.mock("@/app/lib/actionClass/actionClass", () => ({
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
@@ -24,6 +39,7 @@ vi.mock("@/modules/ui/components/button", () => ({
</button>
),
}));
vi.mock("@/modules/ui/components/code-action-form", () => ({
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="code-action-form" data-readonly={isReadOnly}>
@@ -31,6 +47,7 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
open ? (
@@ -43,6 +60,26 @@ vi.mock("@/modules/ui/components/delete-dialog", () => ({
</div>
) : null,
}));
vi.mock("@/modules/ui/components/action-name-description-fields", () => ({
ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => (
<div data-testid="action-name-description-fields">
<input
data-testid={`name-input-${nameInputId}`}
placeholder="environments.actions.eg_clicked_download"
disabled={isReadOnly}
defaultValue="Test Action"
/>
<input
data-testid={`description-input-${descriptionInputId}`}
placeholder="environments.actions.user_clicked_download_button"
disabled={isReadOnly}
defaultValue="Test Description"
/>
</div>
),
}));
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
@@ -56,6 +93,23 @@ vi.mock("lucide-react", () => ({
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
}));
// Mock react-hook-form
const mockHandleSubmit = vi.fn();
const mockForm = {
handleSubmit: mockHandleSubmit,
control: {},
formState: { errors: {} },
};
vi.mock("react-hook-form", async () => {
const actual = await vi.importActual("react-hook-form");
return {
...actual,
useForm: vi.fn(() => mockForm),
FormProvider: ({ children }: any) => <div>{children}</div>,
};
});
const mockSetOpen = vi.fn();
const mockActionClasses: TActionClass[] = [
{
@@ -88,6 +142,7 @@ const createMockActionClass = (id: string, type: TActionClassType, name: string)
describe("ActionSettingsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHandleSubmit.mockImplementation((fn) => fn);
});
afterEach(() => {
@@ -105,13 +160,9 @@ describe("ActionSettingsTab", () => {
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
expect(
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
@@ -131,18 +182,104 @@ describe("ActionSettingsTab", () => {
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
});
test("renders correctly for other action types (fallback)", () => {
const actionClass = {
...createMockActionClass("auto1", "noCode", "Auto Action"),
type: "automatic" as any,
};
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(
screen.getByText(
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
)
).toBeInTheDocument();
});
test("calls utility functions on initialization", async () => {
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
});
test("handles successful form submission", async () => {
const { updateActionClassAction } = await import(
"@/app/(app)/environments/[environmentId]/actions/actions"
);
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
vi.mocked(updateActionClassAction).mockResolvedValue({ data: {} } as any);
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
// Check that utility functions were called during component initialization
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
});
test("handles permission validation error", async () => {
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
vi.mocked(actionUtilsMock.validatePermissions).mockImplementation(() => {
throw new Error("Not authorized");
});
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
const submitButton = screen.getByRole("button", { name: "common.save_changes" });
mockHandleSubmit.mockImplementation((fn) => (e) => {
e.preventDefault();
return fn({ name: "Test", type: "noCode" });
});
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Not authorized");
});
});
test("handles successful deletion", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import(
@@ -209,17 +346,16 @@ describe("ActionSettingsTab", () => {
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={true} // Set to read-only
isReadOnly={true}
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled();
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument();
});
test("prevents delete when read-only", async () => {
@@ -228,7 +364,6 @@ describe("ActionSettingsTab", () => {
"@/app/(app)/environments/[environmentId]/actions/actions"
);
// Render with isReadOnly=true, but simulate a delete attempt
render(
<ActionSettingsTab
actionClass={actionClass}
@@ -238,12 +373,6 @@ describe("ActionSettingsTab", () => {
/>
);
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
// This test primarily checks the logic within handleDeleteAction if it were called.
// A better approach might be to export handleDeleteAction for direct testing,
// but for now, we assume the UI prevents calling it.
// We can assert that the delete button isn't there to prevent the flow
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(deleteActionClassAction).not.toHaveBeenCalled();
});
@@ -262,4 +391,19 @@ describe("ActionSettingsTab", () => {
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
expect(docsLink).toHaveAttribute("target", "_blank");
});
test("uses correct input IDs for ActionNameDescriptionFields", () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
});
});

View File

@@ -4,14 +4,17 @@ import {
deleteActionClassAction,
updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/actions/actions";
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
import { buildActionObject } from "@/modules/survey/editor/lib/action-builder";
import {
createActionClassZodResolver,
useActionClassKeys,
validatePermissions,
} from "@/modules/survey/editor/lib/action-utils";
import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields";
import { Button } from "@/modules/ui/components/button";
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { TrashIcon } from "lucide-react";
import Link from "next/link";
@@ -19,8 +22,7 @@ import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { z } from "zod";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
interface ActionSettingsTabProps {
actionClass: TActionClass;
@@ -48,63 +50,51 @@ export const ActionSettingsTab = ({
[actionClass.id, actionClasses]
);
const actionClassKeys = useActionClassKeys(actionClasses);
const form = useForm<TActionClassInput>({
defaultValues: {
...restActionClass,
},
resolver: zodResolver(
ZActionClassInput.superRefine((data, ctx) => {
if (data.name && actionClassNames.includes(data.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
});
}
})
),
resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t),
mode: "onChange",
});
const { handleSubmit, control } = form;
const renderActionForm = () => {
if (actionClass.type === "code") {
return (
<>
<CodeActionForm form={form} isReadOnly={true} />
<p className="text-sm text-slate-600">
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
</p>
</>
);
}
if (actionClass.type === "noCode") {
return <NoCodeActionForm form={form} isReadOnly={isReadOnly} />;
}
return (
<p className="text-sm text-slate-600">
{t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")}
</p>
);
};
const onSubmit = async (data: TActionClassInput) => {
try {
if (isReadOnly) {
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
}
setIsUpdatingAction(true);
validatePermissions(isReadOnly, t);
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
if (data.name && actionClassNames.includes(data.name)) {
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
}
if (
data.type === "noCode" &&
data.noCodeConfig?.type === "click" &&
data.noCodeConfig.elementSelector.cssSelector &&
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
) {
throw new Error(t("environments.actions.invalid_css_selector"));
}
const updatedData: TActionClassInput = {
...data,
...(data.type === "noCode" &&
data.noCodeConfig?.type === "click" && {
noCodeConfig: {
...data.noCodeConfig,
elementSelector: {
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
},
},
}),
};
await updateActionClassAction({
actionClassId: actionClass.id,
updatedAction: updatedData,
updatedAction: updatedAction,
});
setOpen(false);
router.refresh();
@@ -123,7 +113,7 @@ export const ActionSettingsTab = ({
router.refresh();
toast.success(t("environments.actions.action_deleted_successfully"));
setOpen(false);
} catch (error) {
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDeletingAction(false);
@@ -135,79 +125,14 @@ export const ActionSettingsTab = ({
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<FormField
control={control}
name="name"
disabled={isReadOnly}
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel htmlFor="actionNameSettingsInput">
{actionClass.type === "noCode"
? t("environments.actions.what_did_your_user_do")
: t("environments.actions.display_name")}
</FormLabel>
<ActionNameDescriptionFields
control={control}
isReadOnly={isReadOnly}
nameInputId="actionNameSettingsInput"
descriptionInputId="actionDescriptionSettingsInput"
/>
<FormControl>
<Input
type="text"
id="actionNameSettingsInput"
{...field}
placeholder={t("environments.actions.eg_clicked_download")}
isInvalid={!!error?.message}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="col-span-1">
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="actionDescriptionSettingsInput">
{t("common.description")}
</FormLabel>
<FormControl>
<Input
type="text"
id="actionDescriptionSettingsInput"
{...field}
placeholder={t("environments.actions.user_clicked_download_button")}
value={field.value ?? ""}
disabled={isReadOnly}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
{actionClass.type === "code" ? (
<>
<CodeActionForm form={form} isReadOnly={true} />
<p className="text-sm text-slate-600">
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
</p>
</>
) : actionClass.type === "noCode" ? (
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
) : (
<p className="text-sm text-slate-600">
{t(
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
)}
</p>
)}
{renderActionForm()}
</div>
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">

View File

@@ -9,8 +9,12 @@ import {
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -49,10 +53,14 @@ vi.mock("@/lib/membership/utils", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
getAccessControlPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
}));
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
getTeamsByOrganizationId: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
@@ -71,7 +79,13 @@ vi.mock("@/lib/constants", () => ({
// Mock components
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => (
<div data-testid="main-navigation">
MainNavigation
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed?.toString() || "false"}</div>
</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
@@ -104,7 +118,7 @@ const mockUser = {
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
} as unknown as TUser;
const mockOrganization = {
@@ -156,6 +170,17 @@ const mockProjectPermission = {
role: "admin",
} as any;
const mockOrganizationTeams = [
{
id: "team-1",
name: "Development Team",
},
{
id: "team-2",
name: "Marketing Team",
},
];
const mockSession: Session = {
user: {
id: "user-1",
@@ -176,6 +201,8 @@ describe("EnvironmentLayout", () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
mockIsDevelopment = false;
mockIsFormbricksCloud = false;
});
@@ -288,6 +315,110 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
});
test("passes isAccessControlAllowed props to MainNavigation", async () => {
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
expect(vi.mocked(getAccessControlPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
});
test("handles empty organizationTeams array", async () => {
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
});
test("handles null organizationTeams", async () => {
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
});
test("handles isAccessControlAllowed false", async () => {
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
});
test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules();

View File

@@ -13,7 +13,10 @@ import {
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
@@ -48,9 +51,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
throw new Error(t("common.environment_not_found"));
}
const [projects, environments] = await Promise.all([
const [projects, environments, isAccessControlAllowed] = await Promise.all([
getUserProjects(user.id, organization.id),
getEnvironments(environment.projectId),
getAccessControlPermission(organization.billing.plan),
]);
if (!projects || !environments || !organizations) {
@@ -117,15 +121,16 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
/>
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
environment={environment}
environments={environments}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>
<div className="mt-14">{children}</div>
<div className="flex-1 overflow-y-auto">{children}</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { usePathname, useRouter } from "next/navigation";
@@ -52,9 +53,19 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
}));
vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
ProjectSwitcher: ({
isCollapsed,
organizationTeams,
isAccessControlAllowed,
}: {
isCollapsed: boolean;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
}) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed.toString()}</div>
</div>
),
}));
@@ -106,7 +117,7 @@ const mockUser = {
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;
@@ -146,6 +157,7 @@ const defaultProps = {
membershipRole: "owner" as const,
organizationProjectsLimit: 5,
isLicenseActive: true,
isAccessControlAllowed: true,
};
describe("MainNavigation", () => {
@@ -334,4 +346,23 @@ describe("MainNavigation", () => {
});
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
});
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
});
test("handles no organizationTeams", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
});
test("handles isAccessControlAllowed false", () => {
render(<MainNavigation {...defaultProps} isAccessControlAllowed={false} />);
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
});
});

View File

@@ -66,6 +66,7 @@ interface NavigationProps {
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
}
export const MainNavigation = ({
@@ -80,6 +81,7 @@ export const MainNavigation = ({
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
isAccessControlAllowed,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -323,6 +325,7 @@ export const MainNavigation = ({
isTextVisible={isTextVisible}
organization={organization}
organizationProjectsLimit={organizationProjectsLimit}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
@@ -336,27 +339,30 @@ export const MainNavigation = ({
<div
tabIndex={0}
className={cn(
"flex cursor-pointer flex-row items-center space-x-3",
isCollapsed ? "pl-2" : "pl-4"
"flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "justify-center px-2" : "px-4"
)}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
{!isCollapsed && !isTextVisible && (
<>
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
<div
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="max-w-28 truncate text-sm text-slate-500">
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
<ChevronRightIcon
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
</>
)}
</div>

View File

@@ -28,7 +28,7 @@ const TestComponent = () => {
return (
<div>
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
<div data-testid="responseStatus">{selectedFilter.responseStatus}</div>
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
@@ -44,7 +44,7 @@ const TestComponent = () => {
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
},
],
onlyComplete: true,
responseStatus: "complete",
})
}>
Update Filter
@@ -81,7 +81,7 @@ describe("ResponseFilterContext", () => {
</ResponseFilterProvider>
);
expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
expect(screen.getByTestId("responseStatus").textContent).toBe("all");
expect(screen.getByTestId("filterLength").textContent).toBe("0");
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
@@ -99,7 +99,7 @@ describe("ResponseFilterContext", () => {
const updateButton = screen.getByText("Update Filter");
await userEvent.click(updateButton);
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
expect(screen.getByTestId("responseStatus").textContent).toBe("complete");
expect(screen.getByTestId("filterLength").textContent).toBe("1");
});

View File

@@ -16,9 +16,11 @@ export interface FilterValue {
};
}
export type TResponseStatus = "all" | "complete" | "partial";
export interface SelectedFilterValue {
filter: FilterValue[];
onlyComplete: boolean;
responseStatus: TResponseStatus;
}
interface SelectedFilterOptions {
@@ -47,7 +49,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
// state holds the filter selected value
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
filter: [],
onlyComplete: false,
responseStatus: "all",
});
// state holds all the options of the responses fetched
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
@@ -67,7 +69,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
});
setSelectedFilter({
filter: [],
onlyComplete: false,
responseStatus: "all",
});
}, []);

View File

@@ -44,10 +44,8 @@ describe("TopControlBar", () => {
);
// Check if the main div is rendered
const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement;
expect(mainDiv).toHaveClass(
"fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"
);
const mainDiv = screen.getByTestId("fb__global-top-control-bar");
expect(mainDiv).toHaveClass("flex h-14 w-full items-center justify-end bg-slate-50 px-6");
// Check if the mocked child component is rendered
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();

View File

@@ -17,7 +17,9 @@ export const TopControlBar = ({
projectPermission,
}: SideBarProps) => {
return (
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
<div
className="flex h-14 w-full items-center justify-end bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<div className="shadow-xs z-10">
<div className="flex w-fit items-center space-x-2 py-2">
<TopControlButtons

View File

@@ -7,6 +7,7 @@ import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
@@ -35,6 +36,7 @@ export const EnvironmentContextWrapper = ({
() => ({
environment,
project,
organizationId: project.organizationId,
}),
[environment, project]
);

View File

@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
locale: TUserLocale;
}
const tableHeaders = [
"common.survey",
"environments.integrations.airtable.table_name",
"common.questions",
"common.updated_at",
];
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslate();
const tableHeaders = [
t("common.survey"),
t("environments.integrations.airtable.table_name"),
t("common.questions"),
t("common.updated_at"),
];
const [isDeleting, setisDeleting] = useState(false);
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{tableHeaders.map((header) => (
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
{t(header)}
{header}
</div>
))}
</div>

View File

@@ -220,7 +220,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
@@ -258,7 +257,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];

View File

@@ -119,7 +119,6 @@ const mockSurveys: TSurvey[] = [
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,

View File

@@ -236,7 +236,6 @@ const surveys: TSurvey[] = [
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
@@ -272,7 +271,6 @@ const surveys: TSurvey[] = [
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];

View File

@@ -128,7 +128,6 @@ const mockSurveys: TSurvey[] = [
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,

View File

@@ -226,7 +226,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
@@ -264,7 +263,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];

View File

@@ -114,7 +114,6 @@ const mockSurveys: TSurvey[] = [
languages: [],
styling: null,
segment: null,
resultShareKey: null,
displayPercentage: null,
closeOnDate: null,
runOnDate: null,

View File

@@ -49,7 +49,6 @@ const mockUser = {
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
role: "project_manager",

View File

@@ -1,166 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { EditWeeklySummary } from "./EditWeeklySummary";
vi.mock("lucide-react", () => ({
UsersIcon: () => <div data-testid="users-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href} data-testid="link">
{children}
</a>
),
}));
const mockNotificationSwitch = vi.fn();
vi.mock("./NotificationSwitch", () => ({
NotificationSwitch: (props: any) => {
mockNotificationSwitch(props);
return (
<div data-testid={`notification-switch-${props.surveyOrProjectOrOrganizationId}`}>
NotificationSwitch
</div>
);
},
}));
const mockT = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: mockT,
}),
}));
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {
proj1: true,
proj3: false,
},
unsubscribedOrganizationIds: [],
},
role: "project_manager",
objective: "other",
emailVerified: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
identityProvider: "email",
twoFactorEnabled: false,
} as unknown as TUser;
const mockMemberships: Membership[] = [
{
organization: {
id: "org1",
name: "Organization 1",
projects: [
{ id: "proj1", name: "Project 1", environments: [] },
{ id: "proj2", name: "Project 2", environments: [] },
],
},
},
{
organization: {
id: "org2",
name: "Organization 2",
projects: [{ id: "proj3", name: "Project 3", environments: [] }],
},
},
];
const environmentId = "test-env-id";
describe("EditWeeklySummary", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with multiple memberships and projects", () => {
render(<EditWeeklySummary memberships={mockMemberships} user={mockUser} environmentId={environmentId} />);
expect(screen.getByText("Organization 1")).toBeInTheDocument();
expect(screen.getByText("Project 1")).toBeInTheDocument();
expect(screen.getByText("Project 2")).toBeInTheDocument();
expect(screen.getByText("Organization 2")).toBeInTheDocument();
expect(screen.getByText("Project 3")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj1",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj1")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj2",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj2")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj3",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj3")).toBeInTheDocument();
const inviteLinks = screen.getAllByTestId("link");
expect(inviteLinks.length).toBe(mockMemberships.length);
inviteLinks.forEach((link) => {
expect(link).toHaveAttribute("href", `/environments/${environmentId}/settings/general`);
expect(link).toHaveTextContent("common.invite_them");
});
expect(screen.getAllByTestId("users-icon").length).toBe(mockMemberships.length);
expect(screen.getAllByText("common.project")[0]).toBeInTheDocument();
expect(screen.getAllByText("common.weekly_summary")[0]).toBeInTheDocument();
expect(
screen.getAllByText("environments.settings.notifications.want_to_loop_in_organization_mates?").length
).toBe(mockMemberships.length);
});
test("renders correctly with no memberships", () => {
render(<EditWeeklySummary memberships={[]} user={mockUser} environmentId={environmentId} />);
expect(screen.queryByText("Organization 1")).not.toBeInTheDocument();
expect(screen.queryByTestId("users-icon")).not.toBeInTheDocument();
});
test("renders correctly when an organization has no projects", () => {
const membershipsWithNoProjects: Membership[] = [
{
organization: {
id: "org3",
name: "Organization No Projects",
projects: [],
},
},
];
render(
<EditWeeklySummary
memberships={membershipsWithNoProjects}
user={mockUser}
environmentId={environmentId}
/>
);
expect(screen.getByText("Organization No Projects")).toBeInTheDocument();
expect(screen.queryByText("Project 1")).not.toBeInTheDocument(); // Check that no projects are listed under it
expect(mockNotificationSwitch).not.toHaveBeenCalled(); // No projects, so no switches for projects
});
});

View File

@@ -1,59 +0,0 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { UsersIcon } from "lucide-react";
import Link from "next/link";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { NotificationSwitch } from "./NotificationSwitch";
interface EditAlertsProps {
memberships: Membership[];
user: TUser;
environmentId: string;
}
export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAlertsProps) => {
const { t } = useTranslate();
return (
<>
{memberships.map((membership) => (
<div key={membership.organization.id}>
<div className="mb-5 flex items-center space-x-3 text-sm font-medium">
<UsersIcon className="h-6 w-7 text-slate-600" />
<p className="text-slate-800">{membership.organization.name}</p>
</div>
<div className="mb-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-3 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">{t("common.project")}</div>
<div className="col-span-1 text-center">{t("common.weekly_summary")}</div>
</div>
<div className="space-y-1 p-2">
{membership.organization.projects.map((project) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center justify-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={project.id}>
<div className="col-span-2">{project?.name}</div>
<div className="col-span-1 flex items-center justify-center">
<NotificationSwitch
surveyOrProjectOrOrganizationId={project.id}
notificationSettings={user.notificationSettings!}
notificationType={"weeklySummary"}
/>
</div>
</div>
))}
</div>
<p className="pb-3 pl-4 text-xs text-slate-400">
{t("environments.settings.notifications.want_to_loop_in_organization_mates")}?{" "}
<Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
{t("common.invite_them")}
</Link>
</p>
</div>
</div>
))}
</>
);
};

View File

@@ -29,7 +29,6 @@ const organizationId = "org1";
const baseNotificationSettings: TUserNotificationSettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
};
@@ -68,19 +67,6 @@ describe("NotificationSwitch", () => {
expect(switchInput.checked).toBe(false);
});
test("renders with initial checked state for 'weeklySummary' (true)", () => {
const settings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: projectId,
notificationSettings: settings,
notificationType: "weeklySummary",
});
const switchInput = screen.getByLabelText(
"toggle notification settings for weeklySummary"
) as HTMLInputElement;
expect(switchInput.checked).toBe(true);
});
test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => {
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
renderSwitch({
@@ -268,31 +254,6 @@ describe("NotificationSwitch", () => {
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'weeklySummary' type", async () => {
const mockErrorResponse = { serverError: "Database connection failed" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: projectId,
notificationSettings: initialSettings,
notificationType: "weeklySummary",
});
const switchInput = screen.getByLabelText("toggle notification settings for weeklySummary");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, weeklySummary: { [projectId]: false } },
});
expect(toast.error).toHaveBeenCalledWith("Database connection failed", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
const mockErrorResponse = { serverError: "Permission denied" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);

View File

@@ -12,7 +12,7 @@ import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps {
surveyOrProjectOrOrganizationId: string;
notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "weeklySummary" | "unsubscribedOrganizationIds";
notificationType: "alert" | "unsubscribedOrganizationIds";
autoDisableNotificationType?: string;
autoDisableNotificationElementId?: string;
}

View File

@@ -34,17 +34,5 @@ describe("Loading Notifications Settings", () => {
.getByText("environments.settings.notifications.email_alerts_surveys")
.closest("div[class*='rounded-xl']"); // Find parent card
expect(alertsCard).toBeInTheDocument();
// Check for Weekly Summary LoadingCard
expect(
screen.getByText("environments.settings.notifications.weekly_summary_projects")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
).toBeInTheDocument();
const weeklySummaryCard = screen
.getByText("environments.settings.notifications.weekly_summary_projects")
.closest("div[class*='rounded-xl']"); // Find parent card
expect(weeklySummaryCard).toBeInTheDocument();
});
});

View File

@@ -14,11 +14,6 @@ const Loading = () => {
description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
},
{
title: t("environments.settings.notifications.weekly_summary_projects"),
description: t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
},
];
return (

View File

@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TUser } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary";
import Page from "./page";
import { Membership } from "./types";
@@ -58,9 +57,7 @@ vi.mock("@formbricks/database", () => ({
vi.mock("./components/EditAlerts", () => ({
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
}));
vi.mock("./components/EditWeeklySummary", () => ({
EditWeeklySummary: vi.fn(() => <div>EditWeeklySummaryComponent</div>),
}));
vi.mock("./components/IntegrationsTip", () => ({
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
}));
@@ -71,7 +68,6 @@ const mockUser: Partial<TUser> = {
email: "test@example.com",
notificationSettings: {
alert: { "survey-old": true },
weeklySummary: { "project-old": true },
unsubscribedOrganizationIds: ["org-unsubscribed"],
},
};
@@ -137,13 +133,6 @@ describe("NotificationsPage", () => {
).toBeInTheDocument();
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.weekly_summary_projects")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
).toBeInTheDocument();
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
// The actual `user.notificationSettings` passed to EditAlerts will be a new object
// after `setCompleteNotificationSettings` processes it.
@@ -157,16 +146,12 @@ describe("NotificationsPage", () => {
// It iterates memberships, then projects, then environments, then surveys.
// `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;`
// This means only survey IDs found in memberships will be in the new `alert` object.
// `newNotificationSettings.weeklySummary[project.id]` also only adds project IDs from memberships.
const finalExpectedSettings = {
alert: {
"survey-1": false,
"survey-2": false,
},
weeklySummary: {
"project-1": false,
},
unsubscribedOrganizationIds: ["org-unsubscribed"],
};
@@ -175,11 +160,6 @@ describe("NotificationsPage", () => {
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId);
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(finalExpectedSettings);
expect(editWeeklySummaryCall.memberships).toEqual(mockMemberships);
expect(editWeeklySummaryCall.environmentId).toBe(mockParams.environmentId);
});
test("throws error if session is not found", async () => {
@@ -207,21 +187,15 @@ describe("NotificationsPage", () => {
render(PageComponent);
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
const expectedEmptySettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
};
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
expect(editAlertsCall.memberships).toEqual([]);
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(expectedEmptySettings);
expect(editWeeklySummaryCall.memberships).toEqual([]);
});
test("handles legacy notification settings correctly", async () => {
@@ -229,7 +203,6 @@ describe("NotificationsPage", () => {
id: "user-legacy",
notificationSettings: {
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
weeklySummary: { "project-1": true },
unsubscribedOrganizationIds: [],
} as any, // To allow legacy structure
};
@@ -246,9 +219,6 @@ describe("NotificationsPage", () => {
"survey-1": true, // Should be true due to legacy setting
"survey-2": false, // Default for other surveys in membership
},
weeklySummary: {
"project-1": true, // From user's weeklySummary
},
unsubscribedOrganizationIds: [],
};

View File

@@ -9,7 +9,6 @@ import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary";
import { IntegrationsTip } from "./components/IntegrationsTip";
import type { Membership } from "./types";
@@ -19,14 +18,10 @@ const setCompleteNotificationSettings = (
): TUserNotificationSettings => {
const newNotificationSettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
for (const project of membership.organization.projects) {
// set default values for weekly summary
newNotificationSettings.weeklySummary[project.id] =
(notificationSettings.weeklySummary && notificationSettings.weeklySummary[project.id]) || false;
// set default values for alerts
for (const environment of project.environments) {
for (const survey of environment.surveys) {
@@ -183,11 +178,6 @@ const Page = async (props) => {
/>
</SettingsCard>
<IntegrationsTip environmentId={params.environmentId} />
<SettingsCard
title={t("environments.settings.notifications.weekly_summary_projects")}
description={t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")}>
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
</SettingsCard>
</PageContentWrapper>
);
};

View File

@@ -10,24 +10,19 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import {
AuthenticationError,
AuthorizationError,
OperationNotAllowedError,
TooManyRequestsError,
} from "@formbricks/types/errors";
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
const limiter = rateLimit({
interval: 60 * 60, // 1 hour
allowedPerInterval: 3, // max 3 calls for email verification per hour
});
TUserPersonalInfoUpdateInput,
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return {
@@ -41,18 +36,15 @@ async function handleEmailUpdate({
parsedInput,
payload,
}: {
ctx: any;
parsedInput: any;
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
payload: TUserUpdateInput;
}) {
const inputEmail = parsedInput.email?.trim().toLowerCase();
if (!inputEmail || ctx.user.email === inputEmail) return payload;
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
@@ -75,41 +67,35 @@ async function handleEmailUpdate({
return payload;
}
export const updateUserAction = authenticatedActionClient
.schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
)
);
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
}
)
);
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),

View File

@@ -20,7 +20,7 @@ const mockUser = {
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
twoFactorEnabled: false,

View File

@@ -15,7 +15,7 @@ const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -13,7 +13,7 @@ const mockUser = {
locale: "en-US",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
twoFactorEnabled: false,

View File

@@ -76,7 +76,7 @@ const mockUser = {
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
@@ -121,8 +121,9 @@ describe("ProfilePage", () => {
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
// Use a regex to match the text content, allowing for variable whitespace
expect(screen.getByText(new RegExp(`common\\.profile\\s*:\\s*${mockUser.id}`))).toBeInTheDocument(); // SettingsId
// Check for IdBadge content
expect(screen.getByText("common.profile_id")).toBeInTheDocument();
expect(screen.getByText(mockUser.id)).toBeInTheDocument();
});
});

View File

@@ -5,9 +5,9 @@ import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/servi
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
@@ -103,7 +103,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isMultiOrgEnabled={isMultiOrgEnabled}
/>
</SettingsCard>
<SettingsId title={t("common.profile")} id={user.id}></SettingsId>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
</div>
)}
</PageContentWrapper>

View File

@@ -129,7 +129,7 @@ const mockUser = {
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;

View File

@@ -5,7 +5,7 @@ import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/lice
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { getTranslate } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -78,8 +78,8 @@ vi.mock("./components/DeleteOrganization", () => ({
DeleteOrganization: vi.fn(() => <div>DeleteOrganization</div>),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div>SettingsId</div>),
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div>IdBadge</div>),
}));
describe("Page", () => {
@@ -156,10 +156,11 @@ describe("Page", () => {
},
undefined
);
expect(SettingsId).toHaveBeenCalledWith(
expect(IdBadge).toHaveBeenCalledWith(
{
title: "common.organization_id",
id: mockEnvironmentAuth.organization.id,
label: "common.organization_id",
variant: "column",
},
undefined
);

View File

@@ -4,9 +4,9 @@ import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
@@ -70,7 +70,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
</PageContentWrapper>
);
};

View File

@@ -58,7 +58,6 @@ vi.mock("@/lib/env", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
vi.mock("@/app/lib/surveys/surveys");
vi.mock("@/app/share/[sharingKey]/actions");
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
}));
@@ -112,7 +111,6 @@ const mockSurvey = {
surveyClosedMessage: null,
welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"],
segment: null,
resultShareKey: null,
closeOnDate: null,
delay: 0,
autoComplete: null,
@@ -171,22 +169,6 @@ describe("SurveyAnalysisNavigation", () => {
);
});
test("renders navigation correctly for sharing page", () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
);
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
mockGetFormattedFilters.mockReturnValue([] as any);
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(MockSecondaryNavigation).toHaveBeenCalled();
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
});
test("displays correct response count string in label for various scenarios", async () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`

View File

@@ -4,7 +4,7 @@ import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { useTranslate } from "@tolgee/react";
import { InboxIcon, PresentationIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { usePathname } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyAnalysisNavigationProps {
@@ -20,11 +20,8 @@ export const SurveyAnalysisNavigation = ({
}: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslate();
const params = useParams();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
const url = `/environments/${environmentId}/surveys/${survey.id}`;
const navigation = [
{

View File

@@ -50,7 +50,6 @@ const mockSurvey = {
isBackButtonHidden: false,
pin: null,
recontactDays: null,
resultShareKey: null,
runOnDate: null,
showLanguageSwitch: false,
singleUse: null,

View File

@@ -60,7 +60,6 @@ const mockResponses = [
userAgent: { browser: "Chrome", os: "Mac OS", device: "Desktop" },
url: "http://localhost:3000",
},
notes: [],
tags: [],
} as unknown as TResponse,
{
@@ -74,7 +73,6 @@ const mockResponses = [
userAgent: { browser: "Firefox", os: "Windows", device: "Desktop" },
url: "http://localhost:3000/page2",
},
notes: [],
tags: [],
} as unknown as TResponse,
{
@@ -88,7 +86,6 @@ const mockResponses = [
userAgent: { browser: "Safari", os: "iOS", device: "Mobile" },
url: "http://localhost:3000/page3",
},
notes: [],
tags: [],
} as unknown as TResponse,
] as unknown as TResponse[];
@@ -113,7 +110,6 @@ const mockSurvey = {
singleUse: null,
triggers: [],
languages: [],
resultShareKey: null,
displayPercentage: null,
welcomeCard: { enabled: false, headline: { default: "Welcome!" } } as unknown as TSurvey["welcomeCard"],
styling: null,
@@ -139,7 +135,7 @@ const mockUser = {
updatedAt: new Date(),
role: "project_manager",
objective: "increase_conversion",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
} as unknown as TUser;
const mockEnvironmentTags: TTag[] = [

View File

@@ -82,7 +82,6 @@ export const ResponseCardModal = ({
survey={survey}
response={responses[currentIndex]}
user={user}
pageType="response"
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}

View File

@@ -88,7 +88,6 @@ const mockSurvey = {
surveyClosedMessage: null,
triggers: [],
languages: [],
resultShareKey: null,
displayPercentage: null,
} as unknown as TSurvey;
@@ -118,17 +117,6 @@ const mockResponses: TResponse[] = [
singleUseId: null,
ttc: {},
tags: [{ id: "tag1", name: "Tag1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }],
notes: [
{
id: "note1",
text: "Note 1",
createdAt: new Date(),
updatedAt: new Date(),
isResolved: false,
isEdited: false,
user: { id: "user1", name: "User 1" },
},
],
variables: { var1: "Response Var Value" },
language: "en",
contact: null,
@@ -145,7 +133,6 @@ const mockResponses: TResponse[] = [
singleUseId: null,
ttc: {},
tags: [],
notes: [],
variables: {},
language: "de",
contact: null,
@@ -235,12 +222,17 @@ describe("ResponseDataView", () => {
status: "Completed",
responseId: "response1",
tags: mockResponses[0].tags,
notes: mockResponses[0].notes,
variables: { var1: "Response Var Value" },
verifiedEmail: "test@example.com",
language: "en",
person: null,
contactAttributes: null,
meta: {
url: "http://localhost",
userAgent: {
browser: "test-agent",
},
},
},
{
responseData: {
@@ -250,12 +242,17 @@ describe("ResponseDataView", () => {
status: "Not Completed",
responseId: "response2",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "de",
person: null,
contactAttributes: null,
meta: {
url: "http://localhost",
userAgent: {
browser: "test-agent-2",
},
},
},
];

View File

@@ -90,7 +90,6 @@ export const mapResponsesToTableData = (
: t("environments.surveys.responses.not_completed"),
responseId: response.id,
tags: response.tags,
notes: response.notes,
variables: survey.variables.reduce(
(acc, curr) => {
return Object.assign(acc, { [curr.id]: response.variables[curr.id] });
@@ -101,6 +100,7 @@ export const mapResponsesToTableData = (
language: response.language,
person: response.contact,
contactAttributes: response.contactAttributes,
meta: response.meta,
}));
};

View File

@@ -28,19 +28,10 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/
CustomFilter: vi.fn(() => <div data-testid="custom-filter">CustomFilter</div>),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
ResultsShareButton: vi.fn(() => <div data-testid="results-share-button">ResultsShareButton</div>),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
getFormattedFilters: vi.fn(),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getResponseCountBySurveySharingKeyAction: vi.fn(),
getResponsesBySurveySharingKeyAction: vi.fn(),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: vi.fn((survey) => survey),
}));
@@ -64,12 +55,6 @@ const mockGetResponseCountAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"))
.getResponseCountAction
);
const mockGetResponsesBySurveySharingKeyAction = vi.mocked(
(await import("@/app/share/[sharingKey]/actions")).getResponsesBySurveySharingKeyAction
);
const mockGetResponseCountBySurveySharingKeyAction = vi.mocked(
(await import("@/app/share/[sharingKey]/actions")).getResponseCountBySurveySharingKeyAction
);
const mockUseParams = vi.mocked((await import("next/navigation")).useParams);
const mockUseSearchParams = vi.mocked((await import("next/navigation")).useSearchParams);
const mockGetFormattedFilters = vi.mocked((await import("@/app/lib/surveys/surveys")).getFormattedFilters);
@@ -122,7 +107,6 @@ const mockResponses: TResponse[] = [
finished: true,
data: {},
meta: { userAgent: {} },
notes: [],
tags: [],
} as unknown as TResponse,
{
@@ -133,7 +117,6 @@ const mockResponses: TResponse[] = [
finished: true,
data: {},
meta: { userAgent: {} },
notes: [],
tags: [],
} as unknown as TResponse,
];
@@ -150,8 +133,6 @@ describe("ResponsePage", () => {
mockUseResponseFilter.mockReturnValue(mockResponseFilterState);
mockGetResponsesAction.mockResolvedValue({ data: mockResponses });
mockGetResponseCountAction.mockResolvedValue({ data: 20 });
mockGetResponsesBySurveySharingKeyAction.mockResolvedValue({ data: mockResponses });
mockGetResponseCountBySurveySharingKeyAction.mockResolvedValue({ data: 20 });
mockGetFormattedFilters.mockReturnValue({});
});
@@ -159,28 +140,11 @@ describe("ResponsePage", () => {
render(<ResponsePage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
});
expect(mockGetResponsesAction).toHaveBeenCalled();
});
test("does not render ResultsShareButton when isReadOnly is true", async () => {
render(<ResponsePage {...defaultProps} isReadOnly={true} />);
await waitFor(() => {
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
});
test("does not render ResultsShareButton when on sharing page", async () => {
mockUseParams.mockReturnValue({ sharingKey: "share123" });
render(<ResponsePage {...defaultProps} />);
await waitFor(() => {
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
});
test("fetches next page of responses", async () => {
const { rerender } = render(<ResponsePage {...defaultProps} />);
await waitFor(() => {

View File

@@ -4,11 +4,9 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/comp
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useParams, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
@@ -20,7 +18,6 @@ interface ResponsePageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
publicDomain: string;
user?: TUser;
environmentTags: TTag[];
responsesPerPage: number;
@@ -32,17 +29,12 @@ export const ResponsePage = ({
environment,
survey,
surveyId,
publicDomain,
user,
environmentTags,
responsesPerPage,
locale,
isReadOnly,
}: ResponsePageProps) => {
const params = useParams();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const [responses, setResponses] = useState<TResponse[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
@@ -63,30 +55,20 @@ export const ResponsePage = ({
let newResponses: TResponse[] = [];
if (isSharingPage) {
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
sharingKey: sharingKey,
limit: responsesPerPage,
offset: (newPage - 1) * responsesPerPage,
filterCriteria: filters,
});
newResponses = getResponsesActionResponse?.data || [];
} else {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: (newPage - 1) * responsesPerPage,
filterCriteria: filters,
});
newResponses = getResponsesActionResponse?.data || [];
}
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: (newPage - 1) * responsesPerPage,
filterCriteria: filters,
});
newResponses = getResponsesActionResponse?.data || [];
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
setHasMore(false);
}
setResponses([...responses, ...newResponses]);
setPage(newPage);
}, [filters, isSharingPage, page, responses, responsesPerPage, sharingKey, surveyId]);
}, [filters, page, responses, responsesPerPage, surveyId]);
const deleteResponses = (responseIds: string[]) => {
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
@@ -114,25 +96,14 @@ export const ResponsePage = ({
setFetchingFirstPage(true);
let responses: TResponse[] = [];
if (isSharingPage) {
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
sharingKey,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
responses = getResponsesActionResponse?.data || [];
} else {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
responses = getResponsesActionResponse?.data || [];
}
responses = getResponsesActionResponse?.data || [];
if (responses.length < responsesPerPage) {
setHasMore(false);
@@ -143,7 +114,7 @@ export const ResponsePage = ({
}
};
fetchInitialResponses();
}, [surveyId, filters, responsesPerPage, sharingKey, isSharingPage]);
}, [surveyId, filters, responsesPerPage]);
useEffect(() => {
setPage(1);
@@ -155,7 +126,6 @@ export const ResponsePage = ({
<>
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
</div>
<ResponseDataView
survey={survey}

View File

@@ -1,12 +1,11 @@
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { cleanup } from "@testing-library/react";
import { AnyActionArg } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import { TResponseTableData } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyQuestion,
@@ -60,6 +59,7 @@ vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionIconMap: vi.fn(() => ({
[TSurveyQuestionTypeEnum.OpenText]: <span>OT</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: <span>MCS</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: <span>MCM</span>,
[TSurveyQuestionTypeEnum.Matrix]: <span>MX</span>,
[TSurveyQuestionTypeEnum.Address]: <span>AD</span>,
[TSurveyQuestionTypeEnum.ContactInfo]: <span>CI</span>,
@@ -102,6 +102,33 @@ vi.mock("lucide-react", () => ({
EyeOffIcon: () => <span>EyeOff</span>,
MailIcon: () => <span>Mail</span>,
TagIcon: () => <span>Tag</span>,
MousePointerClickIcon: () => <span>MousePointerClick</span>,
AirplayIcon: () => <span>Airplay</span>,
ArrowUpFromDotIcon: () => <span>ArrowUpFromDot</span>,
FlagIcon: () => <span>Flag</span>,
GlobeIcon: () => <span>Globe</span>,
SmartphoneIcon: () => <span>Smartphone</span>,
}));
// Mock new dependencies
vi.mock("@/lib/response/utils", () => ({
extractChoiceIdsFromResponse: vi.fn((responseValue) => {
// Mock implementation that returns choice IDs based on response value
if (Array.isArray(responseValue)) {
return responseValue.map((_, index) => `choice-${index + 1}`);
} else if (typeof responseValue === "string") {
return [`choice-single`];
}
return [];
}),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(({ id }) => <div data-testid="id-badge">{id}</div>),
}));
vi.mock("@/modules/ui/lib/utils", () => ({
cn: vi.fn((...classes) => classes.filter(Boolean).join(" ")),
}));
const mockSurvey = {
@@ -136,6 +163,28 @@ const mockSurvey = {
headline: { default: "Contact Info Question" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q5single",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Single Choice Question" },
required: false,
choices: [
{ id: "choice-1", label: { default: "Option 1" } },
{ id: "choice-2", label: { default: "Option 2" } },
{ id: "choice-3", label: { default: "Option 3" } },
],
} as unknown as TSurveyQuestion,
{
id: "q6multi",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multi Choice Question" },
required: false,
choices: [
{ id: "choice-a", label: { default: "Choice A" } },
{ id: "choice-b", label: { default: "Choice B" } },
{ id: "choice-c", label: { default: "Choice C" } },
],
} as unknown as TSurveyQuestion,
],
variables: [
{ id: "var1", name: "User Segment", type: "text" } as TSurveyVariable,
@@ -156,7 +205,6 @@ const mockSurvey = {
projectOverwrites: null,
singleUse: null,
pin: null,
resultShareKey: null,
surveyClosedMessage: null,
welcomeCard: {
enabled: false,
@@ -174,19 +222,13 @@ const mockResponseData = {
firstName: "John",
email: "john.doe@example.com",
hf1: "Hidden Field 1 Value",
q5single: "Option 1", // Single choice response
q6multi: ["Choice A", "Choice C"], // Multi choice response
},
variables: {
var1: "Segment A",
var2: 100,
},
notes: [
{
id: "note1",
text: "This is a note",
updatedAt: new Date(),
user: { name: "User" } as unknown as TResponseNoteUser,
} as TResponseNote,
],
status: "completed",
tags: [{ id: "tag1", name: "Important" } as unknown as TTag],
language: "default",
@@ -252,14 +294,6 @@ describe("generateResponseTableColumns", () => {
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hf1Col).toBeUndefined();
});
test("should generate Notes column", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesCol = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesCol).toBeDefined();
(notesCol?.cell as any)?.({ row: { original: mockResponseData } } as any);
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
});
});
describe("ResponseTableColumns", () => {
@@ -399,36 +433,6 @@ describe("ResponseTableColumns - Column Implementations", () => {
expect(cellResult).toBeUndefined();
});
test("notesColumn renders when notes is an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with notes
const mockRow = {
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
} as any;
// Call the cell function
notesColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
});
test("notesColumn returns undefined when notes is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with no notes
const mockRow = {
original: { notes: null },
} as any;
// Call the cell function
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeUndefined();
});
test("variableColumns render variable values correctly", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
@@ -496,3 +500,281 @@ describe("ResponseTableColumns - Column Implementations", () => {
expect(hfColumn).toBeUndefined();
});
});
describe("ResponseTableColumns - Multiple Choice Questions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("generates two columns for multipleChoiceSingle questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q5single");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("generates two columns for multipleChoiceMulti questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q6multi");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("multipleChoiceSingle main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
test("multipleChoiceMulti main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
});
describe("ResponseTableColumns - Choice ID Columns", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("option IDs column calls extractChoiceIdsFromResponse for string response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single", type: "multipleChoiceSingle" }),
"default"
);
});
test("option IDs column calls extractChoiceIdsFromResponse for array response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
["Choice A", "Choice C"],
expect.objectContaining({ id: "q6multi", type: "multipleChoiceMulti" }),
"default"
);
});
test("option IDs column renders IdBadge components for choice IDs", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return specific choice IDs
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce(["choice-1", "choice-3"]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
// Should render something for choice IDs
expect(cellResult).toBeDefined();
// Verify that extractChoiceIdsFromResponse was called
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalled();
});
test("option IDs column returns null for non-string/array response values", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: 123 }, // Invalid type
language: "default",
},
};
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
expect(vi.mocked(extractChoiceIdsFromResponse)).not.toHaveBeenCalled();
});
test("option IDs column returns null when no choice IDs found", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Non-existent option" },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return empty array
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce([]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
});
test("option IDs column handles missing language gracefully", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: null, // No language
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single" }),
undefined
);
});
});
describe("ResponseTableColumns - Helper Functions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("question headers are properly created for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
// Test main column header
const mainHeader = mainColumn?.header?.();
expect(mainHeader).toBeDefined();
expect(mainHeader?.props?.className).toContain("flex items-center justify-between");
// Test option IDs column header
const optionHeader = optionIdsColumn?.header?.();
expect(optionHeader).toBeDefined();
expect(optionHeader?.props?.className).toContain("flex items-center justify-between");
});
test("question headers include proper icons for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
// Headers should be functions that return JSX
expect(typeof singleChoiceColumn?.header).toBe("function");
expect(typeof multiChoiceColumn?.header).toBe("function");
// Call headers to ensure they don't throw
expect(() => singleChoiceColumn?.header?.()).not.toThrow();
expect(() => multiChoiceColumn?.header?.()).not.toThrow();
});
});
describe("ResponseTableColumns - Integration Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("multiple choice questions work end-to-end with real data", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find all multiple choice related columns
const singleMainCol = columns.find((col) => (col as any).accessorKey === "q5single");
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const multiMainCol = columns.find((col) => (col as any).accessorKey === "q6multi");
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(singleMainCol).toBeDefined();
expect(singleIdsCol).toBeDefined();
expect(multiMainCol).toBeDefined();
expect(multiIdsCol).toBeDefined();
// Test with actual mock response data
const mockRow = { original: mockResponseData };
// Test single choice main column
const singleMainResult = (singleMainCol?.cell as any)?.({ row: mockRow });
expect(singleMainResult).toBeDefined();
// Test multi choice main column
const multiMainResult = (multiMainCol?.cell as any)?.({ row: mockRow });
expect(multiMainResult).toBeDefined();
// Test that choice ID columns exist and can be called
const singleIdsResult = (singleIdsCol?.cell as any)?.({ row: mockRow });
const multiIdsResult = (multiIdsCol?.cell as any)?.({ row: mockRow });
// Should not error when calling the cell functions
expect(() => singleIdsResult).not.toThrow();
expect(() => multiIdsResult).not.toThrow();
});
});

View File

@@ -1,58 +1,31 @@
"use client";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { processResponseData } from "@/lib/responses";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { ColumnDef } from "@tanstack/react-table";
import { TFnType } from "@tolgee/react";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
const getAddressFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "addressLine1":
return t("environments.surveys.responses.address_line_1");
case "addressLine2":
return t("environments.surveys.responses.address_line_2");
case "city":
return t("environments.surveys.responses.city");
case "state":
return t("environments.surveys.responses.state_region");
case "zip":
return t("environments.surveys.responses.zip_post_code");
case "country":
return t("environments.surveys.responses.country");
default:
break;
}
};
const getContactInfoFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "firstName":
return t("environments.surveys.responses.first_name");
case "lastName":
return t("environments.surveys.responses.last_name");
case "email":
return t("environments.surveys.responses.email");
case "phone":
return t("environments.surveys.responses.phone");
case "company":
return t("environments.surveys.responses.company");
default:
break;
}
};
import {
COLUMNS_ICON_MAP,
METADATA_FIELDS,
getAddressFieldLabel,
getContactInfoFieldLabel,
getMetadataFieldLabel,
getMetadataValue,
} from "../lib/utils";
const getQuestionColumnsData = (
question: TSurveyQuestion,
@@ -61,6 +34,42 @@ const getQuestionColumnsData = (
t: TFnType
): ColumnDef<TResponseTableData>[] => {
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
// Helper function to create consistent column headers
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline;
const QuestionHeader = () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
<span className="truncate">{title}</span>
</div>
</div>
);
QuestionHeader.displayName = "QuestionHeader";
return QuestionHeader;
};
// Helper function to get localized question headline
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
};
// Helper function to render choice ID badges
const renderChoiceIdBadges = (choiceIds: string[], isExpanded: boolean) => {
if (choiceIds.length === 0) return null;
const containerClasses = cn("flex gap-x-1 w-full", isExpanded && "flex-wrap gap-y-1");
return (
<div className={containerClasses}>
{choiceIds.map((choiceId, index) => (
<IdBadge key={`${choiceId}-${index}`} id={choiceId} />
))}
</div>
);
};
switch (question.type) {
case "matrix":
return question.rows.map((matrixRow) => {
@@ -137,6 +146,50 @@ const getQuestionColumnsData = (
};
});
case "multipleChoiceMulti":
case "multipleChoiceSingle":
case "ranking":
case "pictureSelection": {
const questionHeadline = getQuestionHeadline(question, survey);
return [
{
accessorKey: question.id,
header: createQuestionHeader(question.type, questionHeadline),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
const language = row.original.language;
return (
<RenderResponse
question={question}
survey={survey}
responseData={responseValue}
language={language}
isExpanded={isExpanded}
showId={false}
/>
);
},
},
{
accessorKey: question.id + "optionIds",
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
// Type guard to ensure responseValue is the correct type
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
const choiceIds = extractChoiceIdsFromResponse(
responseValue,
question,
row.original.language || undefined
);
return renderChoiceIdBadges(choiceIds, isExpanded);
}
return null;
},
},
];
}
default:
return [
{
@@ -164,6 +217,7 @@ const getQuestionColumnsData = (
responseData={responseValue}
language={language}
isExpanded={isExpanded}
showId={false}
/>
);
},
@@ -172,6 +226,33 @@ const getQuestionColumnsData = (
}
};
const getMetadataColumnsData = (t: TFnType): ColumnDef<TResponseTableData>[] => {
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label];
metadataColumns.push({
accessorKey: label,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
<span className="truncate">{getMetadataFieldLabel(label, t)}</span>
</div>
),
cell: ({ row }) => {
const value = getMetadataValue(row.original.meta, label);
if (value) {
return <div className="truncate text-slate-900">{value}</div>;
}
return null;
},
});
});
return metadataColumns;
};
export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
@@ -230,7 +311,7 @@ export const generateResponseTableColumns = (
header: t("common.status"),
cell: ({ row }) => {
const status = row.original.status;
return <ResponseBadges items={[status]} />;
return <ResponseBadges items={[{ value: status }]} showId={false} />;
},
};
@@ -243,27 +324,16 @@ export const generateResponseTableColumns = (
const tagsArray = tags.map((tag) => tag.name);
return (
<ResponseBadges
items={tagsArray}
items={tagsArray.map((tag) => ({ value: tag }))}
isExpanded={isExpanded}
icon={<TagIcon className="h-4 w-4 text-slate-500" />}
showId={false}
/>
);
}
},
};
const notesColumn: ColumnDef<TResponseTableData> = {
accessorKey: "notes",
header: t("common.notes"),
cell: ({ row }) => {
const notes = row.original.notes;
if (Array.isArray(notes)) {
const notesArray = notes.map((note) => note.text);
return processResponseData(notesArray);
}
},
};
const variableColumns: ColumnDef<TResponseTableData>[] = survey.variables.map((variable) => {
return {
accessorKey: variable.id,
@@ -304,6 +374,8 @@ export const generateResponseTableColumns = (
})
: [];
const metadataColumns = getMetadataColumnsData(t);
const verifiedEmailColumn: ColumnDef<TResponseTableData> = {
accessorKey: "verifiedEmail",
header: () => (
@@ -317,7 +389,6 @@ export const generateResponseTableColumns = (
};
// Combine the selection column with the dynamic question columns
const baseColumns = [
personColumn,
dateColumn,
@@ -326,8 +397,8 @@ export const generateResponseTableColumns = (
...questionColumns,
...variableColumns,
...hiddenFieldColumns,
...metadataColumns,
tagsColumn,
notesColumn,
];
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];

View File

@@ -0,0 +1,204 @@
import "@testing-library/jest-dom/vitest";
import {
AirplayIcon,
ArrowUpFromDotIcon,
FlagIcon,
GlobeIcon,
MousePointerClickIcon,
SmartphoneIcon,
} from "lucide-react";
import { describe, expect, test, vi } from "vitest";
import {
COLUMNS_ICON_MAP,
getAddressFieldLabel,
getContactInfoFieldLabel,
getMetadataFieldLabel,
getMetadataValue,
} from "./utils";
describe("utils", () => {
const mockT = vi.fn((key: string) => {
const translations: Record<string, string> = {
"environments.surveys.responses.address_line_1": "Address Line 1",
"environments.surveys.responses.address_line_2": "Address Line 2",
"environments.surveys.responses.city": "City",
"environments.surveys.responses.state_region": "State/Region",
"environments.surveys.responses.zip_post_code": "ZIP/Post Code",
"environments.surveys.responses.country": "Country",
"environments.surveys.responses.first_name": "First Name",
"environments.surveys.responses.last_name": "Last Name",
"environments.surveys.responses.email": "Email",
"environments.surveys.responses.phone": "Phone",
"environments.surveys.responses.company": "Company",
"common.action": "Action",
"environments.surveys.responses.os": "OS",
"environments.surveys.responses.device": "Device",
"environments.surveys.responses.browser": "Browser",
"common.url": "URL",
"environments.surveys.responses.source": "Source",
};
return translations[key] || key;
});
describe("getAddressFieldLabel", () => {
test("returns correct label for addressLine1", () => {
const result = getAddressFieldLabel("addressLine1", mockT);
expect(result).toBe("Address Line 1");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_1");
});
test("returns correct label for addressLine2", () => {
const result = getAddressFieldLabel("addressLine2", mockT);
expect(result).toBe("Address Line 2");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_2");
});
test("returns correct label for city", () => {
const result = getAddressFieldLabel("city", mockT);
expect(result).toBe("City");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.city");
});
test("returns correct label for state", () => {
const result = getAddressFieldLabel("state", mockT);
expect(result).toBe("State/Region");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.state_region");
});
test("returns correct label for zip", () => {
const result = getAddressFieldLabel("zip", mockT);
expect(result).toBe("ZIP/Post Code");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.zip_post_code");
});
test("returns correct label for country", () => {
const result = getAddressFieldLabel("country", mockT);
expect(result).toBe("Country");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
});
test("returns undefined for unknown field", () => {
const result = getAddressFieldLabel("unknown", mockT);
expect(result).toBeUndefined();
expect(mockT).not.toHaveBeenCalled();
});
});
describe("getContactInfoFieldLabel", () => {
test("returns correct label for firstName", () => {
const result = getContactInfoFieldLabel("firstName", mockT);
expect(result).toBe("First Name");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.first_name");
});
test("returns correct label for lastName", () => {
const result = getContactInfoFieldLabel("lastName", mockT);
expect(result).toBe("Last Name");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.last_name");
});
test("returns correct label for email", () => {
const result = getContactInfoFieldLabel("email", mockT);
expect(result).toBe("Email");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.email");
});
test("returns correct label for phone", () => {
const result = getContactInfoFieldLabel("phone", mockT);
expect(result).toBe("Phone");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.phone");
});
test("returns correct label for company", () => {
const result = getContactInfoFieldLabel("company", mockT);
expect(result).toBe("Company");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.company");
});
test("returns undefined for unknown field", () => {
const result = getContactInfoFieldLabel("unknown", mockT);
expect(result).toBeUndefined();
expect(mockT).not.toHaveBeenCalled();
});
});
describe("getMetadataFieldLabel", () => {
test("returns correct label for action", () => {
const result = getMetadataFieldLabel("action", mockT);
expect(result).toBe("Action");
expect(mockT).toHaveBeenCalledWith("common.action");
});
test("returns correct label for country", () => {
const result = getMetadataFieldLabel("country", mockT);
expect(result).toBe("Country");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
});
test("returns correct label for os", () => {
const result = getMetadataFieldLabel("os", mockT);
expect(result).toBe("OS");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.os");
});
test("returns correct label for device", () => {
const result = getMetadataFieldLabel("device", mockT);
expect(result).toBe("Device");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.device");
});
test("returns correct label for browser", () => {
const result = getMetadataFieldLabel("browser", mockT);
expect(result).toBe("Browser");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.browser");
});
test("returns correct label for url", () => {
const result = getMetadataFieldLabel("url", mockT);
expect(result).toBe("URL");
expect(mockT).toHaveBeenCalledWith("common.url");
});
test("returns correct label for source", () => {
const result = getMetadataFieldLabel("source", mockT);
expect(result).toBe("Source");
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.source");
});
test("returns capitalized label for unknown field", () => {
const result = getMetadataFieldLabel("customField", mockT);
expect(result).toBe("Customfield");
expect(mockT).not.toHaveBeenCalled();
});
test("returns capitalized label for field with underscores", () => {
const result = getMetadataFieldLabel("custom_field", mockT);
expect(result).toBe("Custom_field");
expect(mockT).not.toHaveBeenCalled();
});
});
describe("COLUMNS_ICON_MAP", () => {
test("contains correct icon mappings", () => {
expect(COLUMNS_ICON_MAP.action).toBe(MousePointerClickIcon);
expect(COLUMNS_ICON_MAP.country).toBe(FlagIcon);
expect(COLUMNS_ICON_MAP.browser).toBe(GlobeIcon);
expect(COLUMNS_ICON_MAP.os).toBe(AirplayIcon);
expect(COLUMNS_ICON_MAP.device).toBe(SmartphoneIcon);
expect(COLUMNS_ICON_MAP.source).toBe(ArrowUpFromDotIcon);
expect(COLUMNS_ICON_MAP.url).toBe(GlobeIcon);
});
});
describe("getMetadataValue", () => {
test("returns correct value for action", () => {
const result = getMetadataValue({ action: "action_column" }, "action");
expect(result).toBe("action_column");
});
test("returns correct value for userAgent", () => {
const result = getMetadataValue({ userAgent: { browser: "browser_column" } }, "browser");
expect(result).toBe("browser_column");
});
});
});

View File

@@ -0,0 +1,88 @@
import { TFnType } from "@tolgee/react";
import { capitalize } from "lodash";
import {
AirplayIcon,
ArrowUpFromDotIcon,
FlagIcon,
GlobeIcon,
MousePointerClickIcon,
SmartphoneIcon,
} from "lucide-react";
import { TResponseMeta } from "@formbricks/types/responses";
export const getAddressFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "addressLine1":
return t("environments.surveys.responses.address_line_1");
case "addressLine2":
return t("environments.surveys.responses.address_line_2");
case "city":
return t("environments.surveys.responses.city");
case "state":
return t("environments.surveys.responses.state_region");
case "zip":
return t("environments.surveys.responses.zip_post_code");
case "country":
return t("environments.surveys.responses.country");
default:
break;
}
};
export const getContactInfoFieldLabel = (field: string, t: TFnType) => {
switch (field) {
case "firstName":
return t("environments.surveys.responses.first_name");
case "lastName":
return t("environments.surveys.responses.last_name");
case "email":
return t("environments.surveys.responses.email");
case "phone":
return t("environments.surveys.responses.phone");
case "company":
return t("environments.surveys.responses.company");
default:
break;
}
};
export const getMetadataFieldLabel = (label: string, t: TFnType) => {
switch (label) {
case "action":
return t("common.action");
case "country":
return t("environments.surveys.responses.country");
case "os":
return t("environments.surveys.responses.os");
case "device":
return t("environments.surveys.responses.device");
case "browser":
return t("environments.surveys.responses.browser");
case "url":
return t("common.url");
case "source":
return t("environments.surveys.responses.source");
default:
return capitalize(label);
}
};
export const COLUMNS_ICON_MAP = {
action: MousePointerClickIcon,
country: FlagIcon,
browser: GlobeIcon,
os: AirplayIcon,
device: SmartphoneIcon,
source: ArrowUpFromDotIcon,
url: GlobeIcon,
};
const userAgentFields = ["browser", "os", "device"];
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
if (userAgentFields.includes(label)) {
return meta.userAgent?.[label];
}
return meta[label];
};

View File

@@ -3,6 +3,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -73,6 +74,10 @@ vi.mock("@/lib/response/service", () => ({
getResponseCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/display/service", () => ({
getDisplayCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
@@ -115,7 +120,6 @@ vi.mock("next/navigation", () => ({
useParams: () => ({
environmentId: "test-env-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
@@ -178,6 +182,7 @@ describe("ResponsesPage", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
});
@@ -206,6 +211,8 @@ describe("ResponsesPage", () => {
isReadOnly: false,
user: mockUser,
publicDomain: mockPublicDomain,
responseCount: 10,
displayCount: 5,
}),
undefined
);
@@ -224,12 +231,10 @@ describe("ResponsesPage", () => {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
publicDomain: mockPublicDomain,
environmentTags: mockTags,
user: mockUser,
responsesPerPage: 10,
locale: mockLocale,
isReadOnly: false,
}),
undefined
);

View File

@@ -2,6 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -40,6 +41,7 @@ const Page = async (props) => {
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
@@ -56,6 +58,7 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
displayCount={displayCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
@@ -67,7 +70,6 @@ const Page = async (props) => {
environment={environment}
survey={survey}
surveyId={params.surveyId}
publicDomain={publicDomain}
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}

View File

@@ -14,10 +14,10 @@ import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
@@ -64,143 +64,60 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
);
});
const ZGenerateResultShareUrlAction = z.object({
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const generateResultShareUrlAction = authenticatedActionClient
.schema(ZGenerateResultShareUrlAction)
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
ctx.auditLoggingCtx.newObject = newSurvey;
return resultShareKey;
}
)
);
const ZGetResultShareUrlAction = z.object({
surveyId: ZId,
});
export const getResultShareUrlAction = authenticatedActionClient
.schema(ZGetResultShareUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
}
return survey.resultShareKey;
});
const ZDeleteResultShareUrlAction = z.object({
surveyId: ZId,
});
export const deleteResultShareUrlAction = authenticatedActionClient
.schema(ZDeleteResultShareUrlAction)
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
const newSurvey = await updateSurvey({ ...survey, resultShareKey: null });
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
)
);
)
);
const ZGetEmailHtmlAction = z.object({
surveyId: ZId,

View File

@@ -7,6 +7,13 @@ vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
}));
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
describe("MultipleChoiceSummary", () => {
afterEach(() => {
@@ -160,8 +167,8 @@ describe("MultipleChoiceSummary", () => {
/>
);
const btns = screen.getAllByRole("button");
expect(btns[0]).toHaveTextContent("2 - Y50%2 common.selections");
expect(btns[1]).toHaveTextContent("1 - X50%1 common.selection");
expect(btns[0]).toHaveTextContent("2 - YID: other2 common.selections50%");
expect(btns[1]).toHaveTextContent("1 - XID: other1 common.selection50%");
});
test("places choice with others after one without when reversed inputs", () => {
@@ -272,4 +279,127 @@ describe("MultipleChoiceSummary", () => {
["O5"]
);
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q6",
headline: "H6",
type: "multipleChoiceSingle",
choices: [
{ id: "choice1", label: { default: "Option A" } },
{ id: "choice2", label: { default: "Option B" } },
],
},
choices: {
"Option A": { value: "Option A", count: 5, percentage: 50, others: [] },
"Option B": { value: "Option B", count: 5, percentage: 50, others: [] },
},
type: "multipleChoiceSingle",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "choice1");
expect(idBadges[1]).toHaveAttribute("data-id", "choice2");
expect(idBadges[0]).toHaveTextContent("ID: choice1");
expect(idBadges[1]).toHaveTextContent("ID: choice2");
});
test("getChoiceIdByValue function correctly maps values to IDs", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q8",
headline: "H8",
type: "multipleChoiceMulti",
choices: [
{ id: "id-apple", label: { default: "Apple" } },
{ id: "id-banana", label: { default: "Banana" } },
{ id: "id-cherry", label: { default: "Cherry" } },
],
},
choices: {
Apple: { value: "Apple", count: 3, percentage: 30, others: [] },
Banana: { value: "Banana", count: 4, percentage: 40, others: [] },
Cherry: { value: "Cherry", count: 3, percentage: 30, others: [] },
},
type: "multipleChoiceMulti",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
// Check that each badge has the correct ID
const expectedMappings = [
{ text: "Banana", id: "id-banana" }, // Highest count appears first
{ text: "Apple", id: "id-apple" },
{ text: "Cherry", id: "id-cherry" },
];
expectedMappings.forEach(({ text, id }, index) => {
expect(screen.getByText(`${3 - index} - ${text}`)).toBeInTheDocument();
expect(idBadges[index]).toHaveAttribute("data-id", id);
});
});
test("handles choices with special characters in labels", () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q9",
headline: "H9",
type: "multipleChoiceSingle",
choices: [
{ id: "special-1", label: { default: "Option & Choice" } },
{ id: "special-2", label: { default: "Choice with 'quotes'" } },
],
},
choices: {
"Option & Choice": { value: "Option & Choice", count: 2, percentage: 50, others: [] },
"Choice with 'quotes'": { value: "Choice with 'quotes'", count: 2, percentage: 50, others: [] },
},
type: "multipleChoiceSingle",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId="env"
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "special-1");
expect(idBadges[1]).toHaveAttribute("data-id", "special-2");
});
});

View File

@@ -1,8 +1,10 @@
"use client";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
@@ -84,90 +86,95 @@ export const MultipleChoiceSummary = ({
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<Fragment key={result.value}>
<button
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
<div>
{results.map((result, resultsIdx) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<Fragment key={result.value}>
<button
type="button"
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
{choiceId && <IdBadge id={choiceId} />}
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
)}
</Fragment>
))}
)}
</div>
)}
</Fragment>
);
})}
</div>
</div>
);

View File

@@ -1,7 +1,11 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
TSurvey,
TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { PictureChoiceSummary } from "./PictureChoiceSummary";
vi.mock("@/modules/ui/components/progress-bar", () => ({
@@ -12,6 +16,19 @@ vi.mock("@/modules/ui/components/progress-bar", () => ({
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
vi.mock("@/lib/response/utils", () => ({
getChoiceIdByValue: (value: string, question: TSurveyPictureSelectionQuestion) => {
return question.choices?.find((choice) => choice.imageUrl === value)?.id ?? "other";
},
}));
// mock next image
vi.mock("next/image", () => ({
@@ -88,4 +105,73 @@ describe("PictureChoiceSummary", () => {
expect(screen.getByTestId("header")).toBeEmptyDOMElement();
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found via imageUrl", () => {
const choices = [
{ id: "choice1", imageUrl: "https://example.com/img1.png", percentage: 50, count: 5 },
{ id: "choice2", imageUrl: "https://example.com/img2.png", percentage: 50, count: 5 },
];
const questionSummary = {
choices,
question: {
id: "q2",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "Picture Question",
allowMulti: true,
choices: [
{ id: "pic-choice-1", imageUrl: "https://example.com/img1.png" },
{ id: "pic-choice-2", imageUrl: "https://example.com/img2.png" },
],
},
selectionCount: 10,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "pic-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "pic-choice-2");
expect(idBadges[0]).toHaveTextContent("ID: pic-choice-1");
expect(idBadges[1]).toHaveTextContent("ID: pic-choice-2");
});
test("getChoiceIdByValue function correctly maps imageUrl to choice ID", () => {
const choices = [
{ id: "choice1", imageUrl: "https://cdn.example.com/photo1.jpg", percentage: 33.33, count: 2 },
{ id: "choice2", imageUrl: "https://cdn.example.com/photo2.jpg", percentage: 33.33, count: 2 },
{ id: "choice3", imageUrl: "https://cdn.example.com/photo3.jpg", percentage: 33.33, count: 2 },
];
const questionSummary = {
choices,
question: {
id: "q4",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "Photo Selection",
allowMulti: true,
choices: [
{ id: "photo-a", imageUrl: "https://cdn.example.com/photo1.jpg" },
{ id: "photo-b", imageUrl: "https://cdn.example.com/photo2.jpg" },
{ id: "photo-c", imageUrl: "https://cdn.example.com/photo3.jpg" },
],
},
selectionCount: 6,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
expect(idBadges[0]).toHaveAttribute("data-id", "photo-a");
expect(idBadges[1]).toHaveAttribute("data-id", "photo-b");
expect(idBadges[2]).toHaveAttribute("data-id", "photo-c");
// Verify the images are also rendered correctly
const images = screen.getAllByRole("img");
expect(images).toHaveLength(3);
expect(images[0]).toHaveAttribute("src", "https://cdn.example.com/photo1.jpg");
expect(images[1]).toHaveAttribute("src", "https://cdn.example.com/photo2.jpg");
expect(images[2]).toHaveAttribute("src", "https://cdn.example.com/photo3.jpg");
});
});

View File

@@ -1,5 +1,7 @@
"use client";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
@@ -29,6 +31,7 @@ interface PictureChoiceSummaryProps {
export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: PictureChoiceSummaryProps) => {
const results = questionSummary.choices;
const { t } = useTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
@@ -44,43 +47,48 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.includes_all"),
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
alt="choice-image"
layout="fill"
objectFit="cover"
className="rounded-md"
/>
{results.map((result, index) => {
const choiceId = getChoiceIdByValue(result.imageUrl, questionSummary.question);
return (
<button
type="button"
className="w-full cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.includes_all"),
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
alt="choice-image"
layout="fill"
objectFit="cover"
className="rounded-md"
/>
</div>
<div className="self-end">{choiceId && <IdBadge id={choiceId} />}</div>
</div>
<div className="self-end">
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
))}
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
);
})}
</div>
</div>
);

View File

@@ -27,10 +27,10 @@ vi.mock("@/modules/survey/lib/questions", () => ({
],
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: { title: string; id: string }) => (
<div data-testid="settings-id">
{title}: {id}
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ label, id }: { label: string; id: string }) => (
<div data-testid="id-badge">
{label}: {id}
</div>
),
}));
@@ -76,7 +76,7 @@ describe("QuestionSummaryHeader", () => {
).toBeInTheDocument();
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
expect(screen.getByTestId("id-badge")).toHaveTextContent("common.question_id: q1");
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
});

View File

@@ -3,7 +3,7 @@
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
@@ -55,7 +55,7 @@ export const QuestionSummaryHeader = ({
</div>
)}
</div>
<SettingsId title={t("common.question_id")} id={questionSummary.question.id}></SettingsId>
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
import { RankingSummary } from "./RankingSummary";
// Mock dependencies
@@ -12,17 +12,32 @@ vi.mock("../lib/utils", () => ({
convertFloatToNDecimal: (value: number) => value.toFixed(2),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id }: { id: string }) => (
<div data-testid="id-badge" data-id={id}>
ID: {id}
</div>
),
}));
describe("RankingSummary", () => {
afterEach(() => {
cleanup();
});
const survey = {} as TSurvey;
const surveyType: TSurveyType = "app";
test("renders ranking results in correct order", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
question: {
id: "q1",
headline: "Rank the following",
choices: [
{ id: "choice1", label: { default: "Option A" } },
{ id: "choice2", label: { default: "Option B" } },
{ id: "choice3", label: { default: "Option C" } },
],
},
choices: {
option1: { value: "Option A", avgRanking: 1.5, others: [] },
option2: { value: "Option B", avgRanking: 2.3, others: [] },
@@ -30,7 +45,7 @@ describe("RankingSummary", () => {
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
@@ -51,43 +66,13 @@ describe("RankingSummary", () => {
expect(screen.getByText("#2.30")).toBeInTheDocument();
});
test("renders 'other values found' section when others exist", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
avgRanking: 1.0,
others: [{ value: "Other value", count: 2 }],
},
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeInTheDocument();
});
test("shows 'User' column in other values section for app survey type", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
avgRanking: 1.0,
others: [{ value: "Other value", count: 1 }],
},
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="app" />);
expect(screen.getByText("common.user")).toBeInTheDocument();
});
test("doesn't show 'User' column for link survey type", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
question: {
id: "q1",
headline: "Rank the following",
choices: [{ id: "choice1", label: { default: "Option A" } }],
},
choices: {
option1: {
value: "Option A",
@@ -97,8 +82,132 @@ describe("RankingSummary", () => {
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="link" />);
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
expect(screen.queryByText("common.user")).not.toBeInTheDocument();
});
// New tests for IdBadge functionality
test("renders IdBadge when choice ID is found via label", () => {
const questionSummary = {
question: {
id: "q2",
headline: "Rank these options",
choices: [
{ id: "rank-choice-1", label: { default: "First Option" } },
{ id: "rank-choice-2", label: { default: "Second Option" } },
{ id: "rank-choice-3", label: { default: "Third Option" } },
],
},
choices: {
option1: { value: "First Option", avgRanking: 1.5, others: [] },
option2: { value: "Second Option", avgRanking: 2.1, others: [] },
option3: { value: "Third Option", avgRanking: 2.8, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
expect(idBadges[0]).toHaveAttribute("data-id", "rank-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "rank-choice-2");
expect(idBadges[2]).toHaveAttribute("data-id", "rank-choice-3");
expect(idBadges[0]).toHaveTextContent("ID: rank-choice-1");
expect(idBadges[1]).toHaveTextContent("ID: rank-choice-2");
expect(idBadges[2]).toHaveTextContent("ID: rank-choice-3");
});
test("getChoiceIdByValue function correctly maps ranking values to choice IDs", () => {
const questionSummary = {
question: {
id: "q4",
headline: "Rate importance",
choices: [
{ id: "importance-high", label: { default: "Very Important" } },
{ id: "importance-medium", label: { default: "Somewhat Important" } },
{ id: "importance-low", label: { default: "Not Important" } },
],
},
choices: {
option1: { value: "Very Important", avgRanking: 1.2, others: [] },
option2: { value: "Somewhat Important", avgRanking: 2.0, others: [] },
option3: { value: "Not Important", avgRanking: 2.8, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3);
// Should be ordered by avgRanking (ascending)
expect(screen.getByText("Very Important")).toBeInTheDocument(); // avgRanking: 1.2
expect(screen.getByText("Somewhat Important")).toBeInTheDocument(); // avgRanking: 2.0
expect(screen.getByText("Not Important")).toBeInTheDocument(); // avgRanking: 2.8
expect(idBadges[0]).toHaveAttribute("data-id", "importance-high");
expect(idBadges[1]).toHaveAttribute("data-id", "importance-medium");
expect(idBadges[2]).toHaveAttribute("data-id", "importance-low");
});
test("handles mixed choices with and without matching IDs", () => {
const questionSummary = {
question: {
id: "q5",
headline: "Mixed options",
choices: [
{ id: "valid-choice-1", label: { default: "Valid Option" } },
{ id: "valid-choice-2", label: { default: "Another Valid Option" } },
],
},
choices: {
option1: { value: "Valid Option", avgRanking: 1.5, others: [] },
option2: { value: "Unknown Option", avgRanking: 2.0, others: [] },
option3: { value: "Another Valid Option", avgRanking: 2.5, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(3); // Only 2 out of 3 should have badges
// Check that all options are still displayed
expect(screen.getByText("Valid Option")).toBeInTheDocument();
expect(screen.getByText("Unknown Option")).toBeInTheDocument();
expect(screen.getByText("Another Valid Option")).toBeInTheDocument();
// Check that only the valid choices have badges
expect(idBadges[0]).toHaveAttribute("data-id", "valid-choice-1");
expect(idBadges[1]).toHaveAttribute("data-id", "other");
expect(idBadges[2]).toHaveAttribute("data-id", "valid-choice-2");
});
test("handles special characters in choice labels", () => {
const questionSummary = {
question: {
id: "q6",
headline: "Special characters test",
choices: [
{ id: "special-1", label: { default: "Option with 'quotes'" } },
{ id: "special-2", label: { default: "Option & Ampersand" } },
],
},
choices: {
option1: { value: "Option with 'quotes'", avgRanking: 1.0, others: [] },
option2: { value: "Option & Ampersand", avgRanking: 2.0, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} />);
const idBadges = screen.getAllByTestId("id-badge");
expect(idBadges).toHaveLength(2);
expect(idBadges[0]).toHaveAttribute("data-id", "special-1");
expect(idBadges[1]).toHaveAttribute("data-id", "special-2");
expect(screen.getByText("Option with 'quotes'")).toBeInTheDocument();
expect(screen.getByText("Option & Ampersand")).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More