Compare commits

...

57 Commits

Author SHA1 Message Date
Piyush Gupta 1a8e4f562c Merge branch 'main' of https://github.com/formbricks/formbricks into fix/Changing-project-name-doesn't-update-in-the-sidebar-and-project-selector 2025-07-07 10:07:49 +05:30
Dhruwang Jariwala 4fdea3221b feat: Personal links (#6138)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-04 14:17:40 +00:00
Jakob Schott fef30c54b2 feat: replace deprecated modals with new one (5824) (#5903)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
2025-07-04 11:44:36 +00:00
Johannes 75362eac7a chore: updating contribution docs (#6157) 2025-07-04 04:56:14 -07:00
Dhruwang Jariwala 6e3b224944 chore: sunset card shadow color (#6152) 2025-07-04 10:44:32 +00:00
Aditya ef1be219b4 fix: Show Specific Error for Duplicate Tag Names (#6057)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-04 08:47:49 +00:00
Piyush Gupta ba9b01a969 fix: survey list refresh (#6104)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-04 08:16:27 +00:00
Harsh Bhat e810e38333 chore: change pricing (#5850)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-03 13:40:19 +00:00
victorvhs017 dab8ad00d5 feat: Add Sentry source maps (#6047) 2025-07-03 13:03:59 +00:00
Anshuman Pandey 2c34f43c83 fix: adds build step to the database package for optimizing docker build (#5970)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-02 03:42:01 +00:00
Kunal Garg 979fd71a11 feat: reset password in accounts page (#5219)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-01 15:41:14 +00:00
Harsh Bhat 1be23eebbb docs: Add audit logs, domain split in the license details (#6139) 2025-07-01 04:57:42 -07:00
Dhruwang Jariwala d10cff917d fix: recall parsing for headlines with empty strings (#6131) 2025-07-01 08:16:14 +00:00
Dhruwang Jariwala da72101320 fix: active tab scaling issue (#6127) 2025-06-30 11:10:33 +00:00
suraj 82f7029958 fix:Changing project name doesn't update in the sidebar and project selector 2025-06-30 15:06:31 +05:30
Aditya 5f02ad49c1 fix: allow dynamic height for action cards to show full text (#6106)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-30 02:29:06 -07:00
Dhruwang Jariwala 6644bba6ea fix: formatted databse error message for response endpoint (#6111) 2025-06-30 06:15:50 +00:00
Piyush Gupta 0b7734f725 fix: optional fields in update response API (#6113) 2025-06-30 06:13:42 +00:00
Dhruwang Jariwala 1536bf6907 fix: question change issue (#6091) 2025-06-29 11:10:30 -07:00
Varun Singh e81190214f feat: Enable recall for welcome cards. (#5963)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-06-29 10:24:54 -07:00
Romit 48c8906a89 fix: Preview in Email embed is broken (#6120) 2025-06-29 09:31:26 -07:00
Johannes 717b30115b fix: align settings card height plus border radius (#6119) 2025-06-27 07:20:52 -07:00
victorvhs017 1f3962d2d5 fix: updated url validation (#6096)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-06-27 13:01:36 +00:00
Piyush Gupta 619f6e408f fix: /api/v2/management/contact-attribute-keys returns 500 instead of 409 on duplicate record (#6100) 2025-06-27 12:50:35 +00:00
Dhruwang Jariwala 4a8719abaa fix: auto subscribe (#6114) 2025-06-27 12:33:08 +00:00
Dhruwang Jariwala 7b59eb3b26 fix: name and description updation in contact attribute key via api (#6089) 2025-06-27 12:09:41 +00:00
Piyush Gupta 8ac280268d fix: update preview URL construction in survey dropdown menu (#6117) 2025-06-27 11:42:14 +00:00
Dhruwang Jariwala 34e8f4931d chore: simplified sharing modal access (#6103) 2025-06-27 11:39:15 +00:00
Piyush Gupta ac46850a24 fix: unformatted db errors in contact attribute keys management v1 API (#6102) 2025-06-27 05:48:08 +00:00
victorvhs017 6328be220a fix: updated api docs to use - instead of > (#6107) 2025-06-26 09:54:34 -07:00
Dhruwang Jariwala 882ad99ed7 fix: templates page back button (#6088)
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-26 10:38:45 +00:00
Piyush Gupta ce47b4c2d8 fix: improper zod validation in action classes management API (#6084) 2025-06-26 10:21:01 +00:00
Matti Nannt ce8f9de8ec fix: confetti animation display issue (#6085)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-26 06:35:19 +00:00
Anshuman Pandey ed3c2d2b58 fix: fixes shrinking checkbox (#6092) 2025-06-26 05:14:54 +00:00
Suraj a56b42e7ce Merge branch 'formbricks:main' into main 2025-06-25 17:43:23 +05:30
Anshuman Pandey 9ae226329b fix: decreases environment ttl to 5 minutes (#6087) 2025-06-25 10:30:36 +00:00
Piyush Gupta 12c3899b85 fix: input validation in management v2 webhooks API (#6078) 2025-06-25 09:49:56 +00:00
Piyush Gupta ccb1353eb5 fix: split domain docs (#6086) 2025-06-25 00:50:23 -07:00
Johannes 22eb0b79ee chore: update issue templates (#6081) 2025-06-24 13:42:10 -07:00
Abhishek Sharma 5eb7a496da fix: "Add ending" button ui distortion in safari browser (#6048) 2025-06-24 11:50:17 -07:00
Matti Nannt 7ea55e199f chore(infra): always pull new images on staging (#6079) 2025-06-24 19:45:00 +02:00
Varun Singh 83eb472acd fix: Empty survey list state after deleting the last survey. (#6044)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-24 07:52:18 -07:00
Jakob Schott d9fe6ee4f4 fix: styling update and loading animation for survey media (#6020) 2025-06-24 09:53:27 +00:00
Anshuman Pandey 51b58be079 docs: fixes the bulk contact upload api docs and adds the email property (#6066)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-06-24 01:44:34 -07:00
Harsh Bhat 397643330a docs: Update docs for Private file upload and general client API (#6045) 2025-06-23 08:26:10 -07:00
Piyush Gupta e5fa4328e1 fix: tls handshake failure in self-hosting license generation (#6050) 2025-06-23 08:42:08 +00:00
Jakob Schott 4b777f1907 feat: unify modal component in storybook (#5901) 2025-06-22 13:54:04 +00:00
Piyush Gupta c3547ccb36 fix: default environment redirect (#6033) 2025-06-20 16:46:43 +00:00
Johannes a0f334b300 chore: add rules (#6036) 2025-06-19 09:02:25 -07:00
Jakob Schott a9f635b768 chore: Satisfy SonarQube ReadOnly props for all question types (#6021) 2025-06-19 06:10:11 +00:00
Jakob Schott d385b4a0d6 fix: Set non-required as default value on questions (#6018) 2025-06-19 06:09:36 +00:00
Matti Nannt 5e825413d2 chore(infra): switch staging to internal lb (#6012) 2025-06-18 12:04:53 +00:00
Suraj b6223d1b0a Merge branch 'formbricks:main' into main 2025-01-24 19:59:35 +05:30
Dhruwang Jariwala 9a4dd4a60d Merge branch 'main' into main 2024-12-17 09:23:32 +05:30
Suraj 8eabe4aa88 Merge branch 'formbricks:main' into main 2024-12-13 20:16:50 +05:30
Dhruwang Jariwala 43e07b846f Merge branch 'main' into main 2024-12-13 14:55:01 +05:30
suraj ac324e5e9a resolve BUG #4447 2024-12-12 23:20:22 +05:30
395 changed files with 11590 additions and 6384 deletions
+216
View File
@@ -0,0 +1,216 @@
---
description:
globs:
alwaysApply: false
---
# Component Migration Automation Rule
## Overview
This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases.
## Trigger
When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration").
## Process
### Step 1: Discovery and Planning
1. **Identify migration parameters:**
- Ask user for deprecated component name (e.g., "Modal")
- Ask user for new component name(s) (e.g., "Dialog")
- Ask for any components to exclude (e.g., "ModalWithTabs")
- Ask for specific import paths if needed
2. **Scan codebase** for deprecated components:
- Search for `import.*[DeprecatedComponent]` patterns
- Exclude specified components that should not be migrated
- List all found components with file paths
- Present numbered list to user for confirmation
### Step 2: Component-by-Component Migration
For each component, follow this exact sequence:
#### 2.1 Component Migration
- **Import changes:**
- Ask user to provide the new import structure
- Example transformation pattern:
```typescript
// FROM:
import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]"
// TO:
import {
[NewComponent],
[NewComponentPart1],
[NewComponentPart2],
// ... other parts
} from "@/components/ui/[NewComponent]"
```
- **Props transformation:**
- Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`)
- Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`)
- Apply transformations based on user specifications
- **Structure transformation:**
- Ask user for the new component structure pattern
- Apply the transformation maintaining all functionality
- Preserve all existing logic, state management, and event handlers
#### 2.2 Wait for User Approval
- Present the migration changes
- Wait for explicit user approval before proceeding
- If rejected, ask for specific feedback and iterate
#### 2.3 Re-read and Apply Additional Changes
- Re-read the component file to capture any user modifications
- Apply any additional improvements the user made
- Ensure all changes are incorporated
#### 2.4 Test File Updates
- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`)
- **Update test mocks:**
- Ask user for new component mock structure
- Replace old component mocks with new ones
- Example pattern:
```typescript
// Add to test setup:
jest.mock("@/components/ui/[NewComponent]", () => ({
[NewComponent]: ({ children, [props] }: any) => ([mock implementation]),
[NewComponentPart1]: ({ children }: any) => <div data-testid="[new-component-part1]">{children}</div>,
[NewComponentPart2]: ({ children }: any) => <div data-testid="[new-component-part2]">{children}</div>,
// ... other parts
}));
```
- **Update test expectations:**
- Change test IDs from old component to new component
- Update any component-specific assertions
- Ensure all new component parts used in the component are mocked
#### 2.5 Run Tests and Optimize
- Execute `Node package manager test -- ComponentName.test.tsx`
- Fix any failing tests
- Optimize code quality (imports, formatting, etc.)
- Re-run tests until all pass
- **Maximum 3 iterations** - if still failing, ask user for guidance
#### 2.6 Wait for Final Approval
- Present test results and any optimizations made
- Wait for user approval of the complete migration
- If rejected, iterate based on feedback
#### 2.7 Git Commit
- Run: `git add .`
- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"`
- Confirm commit was successful
### Step 3: Final Report Generation
After all components are migrated, generate a comprehensive GitHub PR report:
#### PR Title
```
feat: migrate [DeprecatedComponent] components to [NewComponent] system
```
#### PR Description Template
```markdown
## 🔄 [DeprecatedComponent] to [NewComponent] Migration
### Overview
Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency.
### Components Migrated
[List each component with file path]
### Technical Changes
- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]`
- **Props:** [List prop transformations]
- **Structure:** Implemented proper [NewComponent] component hierarchy
- **Styling:** [Describe styling changes]
- **Tests:** Updated all test mocks and expectations
### Migration Pattern
```typescript
// Before
<[DeprecatedComponent] [oldProps]>
[oldStructure]
</[DeprecatedComponent]>
// After
<[NewComponent] [newProps]>
[newStructure]
</[NewComponent]>
```
### Testing
- ✅ All existing tests updated and passing
- ✅ Component functionality preserved
- ✅ UI/UX behavior maintained
### How to Test This PR
1. **Functional Testing:**
- Navigate to each migrated component's usage
- Verify [component] opens and closes correctly
- Test all interactive elements within [components]
- Confirm styling and layout are preserved
2. **Automated Testing:**
```bash
Node package manager test
```
3. **Visual Testing:**
- Check that all [components] maintain proper styling
- Verify responsive behavior
- Test keyboard navigation and accessibility
### Breaking Changes
[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."]
### Notes
- [Any excluded components] were preserved as they already use [NewComponent] internally
- All form validation and complex state management preserved
- Enhanced code quality with better imports and formatting
```
## Special Considerations
### Excluded Components
- **DO NOT MIGRATE** components specified by user as exclusions
- They may already use the new component internally or have other reasons
- Inform user these are skipped and why
### Complex Components
- Preserve all existing functionality (forms, validation, state management)
- Maintain prop interfaces
- Keep all event handlers and callbacks
- Preserve accessibility features
### Test Coverage
- Ensure all new component parts are mocked when used
- Mock all new component parts that appear in the component
- Update test IDs from old component to new component
- Maintain all existing test scenarios
### Error Handling
- If tests fail after 3 iterations, stop and ask user for guidance
- If component is too complex, ask user for specific guidance
- If unsure about functionality preservation, ask for clarification
### Migration Patterns
- Always ask user for specific migration patterns before starting
- Confirm import structures, prop mappings, and component hierarchies
- Adapt to different component architectures (simple replacements, complex restructuring, etc.)
## Success Criteria
- All deprecated components successfully migrated to new components
- All tests passing
- No functionality lost
- Code quality maintained or improved
- User approval on each component
- Successful git commits for each migration
- Comprehensive PR report generated
## Usage Examples
- "migrate Modal to Dialog"
- "migrate Button to NewButton"
- "migrate Card to ModernCard"
- "component migration" (will prompt for details)
+23
View File
@@ -0,0 +1,23 @@
---
description: Guideline for writing end-user facing documentation in the apps/docs folder
globs:
alwaysApply: false
---
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
---
title: "FEATURE NAME"
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
icon: "link"
---
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
- If a feature is part of the Enterprise Edition, use this note:
<Note>
FEATURE NAME is part of the @Enterprise Edition.
</Note>
+2
View File
@@ -210,6 +210,8 @@ UNKEY_ROOT_KEY=
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin. # The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
# It's used automatically by Sentry during the build for authentication when uploading source maps. # It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN= # SENTRY_AUTH_TOKEN=
# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard
# SENTRY_ENVIRONMENT=
# Configure the minimum role for user management from UI(owner, manager, disabled) # Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager" # USER_MANAGEMENT_MINIMUM_ROLE="manager"
@@ -1,6 +1,7 @@
name: Feature request name: Feature request
description: "Suggest an idea for this project \U0001F680" description: "Suggest an idea for this project \U0001F680"
type: feature type: feature
projects: "formbricks/21"
body: body:
- type: textarea - type: textarea
id: problem-description id: problem-description
-11
View File
@@ -1,11 +0,0 @@
name: Task (internal)
description: "Template for creating a task. Used by the Formbricks Team only \U0001f4e5"
type: task
body:
- type: textarea
id: task-summary
attributes:
label: Task description
description: A clear detailed-rich description of the task.
validations:
required: true
@@ -0,0 +1,121 @@
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
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: production
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"
+22
View File
@@ -32,3 +32,25 @@ jobs:
with: with:
VERSION: v${{ needs.docker-build.outputs.VERSION }} VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod" ENVIRONMENT: "prod"
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- docker-build
- deploy-formbricks-cloud
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
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 }}
@@ -0,0 +1,46 @@
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 }}
+1 -11
View File
@@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
## 🛠 Crafting Pull Requests ## 🛠 Crafting Pull Requests
Ready to dive into the code and make a real impact? Here's your path: For the time being, we don't have the capacity to properly facilitate community contributions. It's a lot of engineering attention often spent on issues which don't follow our prioritization, so we've decided to only facilitate community code contributions in rare exceptions in the coming months.
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
1. **Tweak and Transform:** Work your coding magic and apply your changes.
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
## 🚀 Aspiring Features ## 🚀 Aspiring Features
+1 -1
View File
@@ -192,7 +192,7 @@ Here are a few options:
- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap. - Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap.
Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information. - Note: For the time being, we can only facilitate code contributions as an exception.
## All Thanks To Our Contributors ## All Thanks To Our Contributors
+14 -41
View File
@@ -25,21 +25,9 @@ RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers # Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3 RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
# BuildKit secret handling without hardcoded fallback values # Copy the secrets handling script
# This approach relies entirely on secrets passed from GitHub Actions COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \ RUN chmod +x /tmp/read-secrets.sh
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
# Increase Node.js memory limit as a regular build argument # Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096" ARG NODE_OPTIONS="--max_old_space_size=4096"
@@ -62,6 +50,9 @@ RUN touch apps/web/.env
# Install the dependencies # Install the dependencies
RUN pnpm install --ignore-scripts RUN pnpm install --ignore-scripts
# Build the database package first
RUN pnpm build --filter=@formbricks/database
# Build the project using our secret reader script # Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers # This mounts the secrets only during this build step without storing them in layers
RUN --mount=type=secret,id=database_url \ RUN --mount=type=secret,id=database_url \
@@ -106,20 +97,8 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
@@ -142,12 +121,14 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod RUN chmod -R 755 ./node_modules/zod
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
RUN npm install -g prisma RUN npm install -g prisma
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh
EXPOSE 3000 EXPOSE 3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENV NODE_ENV="production"
USER nextjs USER nextjs
# Prepare volume for uploads # Prepare volume for uploads
@@ -158,12 +139,4 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \ CMD ["/home/nextjs/start.sh"]
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js
@@ -94,6 +94,7 @@ describe("LandingSidebar component", () => {
organizationId: "o1", organizationId: "o1",
redirect: true, redirect: true,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}); });
}); });
@@ -130,6 +130,7 @@ export const LandingSidebar = ({
organizationId: organization.id, organizationId: organization.id,
redirect: true, redirect: true,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}} }}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}> icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
@@ -1,5 +1,5 @@
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs"; import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render } from "@testing-library/react"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes"; import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
@@ -8,23 +8,40 @@ import { ActionDetailModal } from "./ActionDetailModal";
// Import mocked components // Import mocked components
import { ActionSettingsTab } from "./ActionSettingsTab"; import { ActionSettingsTab } from "./ActionSettingsTab";
// Mock child components // Mock the Dialog components
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => ( Dialog: ({
<div data-testid="modal-with-tabs"> open,
<span data-testid="modal-label">{label}</span> onOpenChange,
<span data-testid="modal-description">{description}</span> children,
<span data-testid="modal-open">{open.toString()}</span> }: {
<button onClick={() => setOpen(false)}>Close</button> open: boolean;
{icon} onOpenChange: (open: boolean) => void;
{tabs.map((tab) => ( children: React.ReactNode;
<div key={tab.title}> }) =>
<h2>{tab.title}</h2> open ? (
{tab.children} <div data-testid="dialog">
</div> {children}
))} <button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
</div> Close
)), </button>
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
})); }));
vi.mock("./ActionActivityTab", () => ({ vi.mock("./ActionActivityTab", () => ({
@@ -44,6 +61,22 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
}, },
})); }));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.activity": "Activity",
"common.settings": "Settings",
"common.no_code": "No Code",
"common.action": "Action",
"common.code": "Code",
};
return translations[key] || key;
},
}),
}));
const mockEnvironmentId = "test-env-id"; const mockEnvironmentId = "test-env-id";
const mockSetOpen = vi.fn(); const mockSetOpen = vi.fn();
@@ -89,58 +122,68 @@ describe("ActionDetailModal", () => {
vi.clearAllMocks(); // Clear mocks after each test vi.clearAllMocks(); // Clear mocks after each test
}); });
test("renders ModalWithTabs with correct props", () => { test("renders correctly when open", () => {
render(<ActionDetailModal {...defaultProps} />); render(<ActionDetailModal {...defaultProps} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action");
expect(screen.getByTestId("code-icon")).toBeInTheDocument();
expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
// Only the first tab (Activity) should be active initially
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
expect(mockedModalWithTabs).toHaveBeenCalled(); test("does not render when open is false", () => {
const props = mockedModalWithTabs.mock.calls[0][0]; render(<ActionDetailModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
// Check basic props test("switches tabs correctly", async () => {
expect(props.open).toBe(true); const user = userEvent.setup();
expect(props.setOpen).toBe(mockSetOpen); render(<ActionDetailModal {...defaultProps} />);
expect(props.label).toBe(mockActionClass.name);
expect(props.description).toBe(mockActionClass.description);
// Check icon data-testid based on the mock for the default 'code' type // Initially shows activity tab (first tab is active)
expect(props.icon).toBeDefined(); expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
if (!props.icon) { expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
throw new Error("Icon prop is not defined");
}
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
// Check tabs structure // Click settings tab
expect(props.tabs).toHaveLength(2); const settingsTab = screen.getByText("Settings");
expect(props.tabs[0].title).toBe("common.activity"); await user.click(settingsTab);
expect(props.tabs[1].title).toBe("common.settings");
// Check if the correct mocked components are used as children // Now shows settings tab content
// Access the mocked functions directly expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument();
const mockedActionActivityTab = vi.mocked(ActionActivityTab); expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
if (!props.tabs[0].children || !props.tabs[1].children) { // Click activity tab again
throw new Error("Tabs children are not defined"); const activityTab = screen.getByText("Activity");
} await user.click(activityTab);
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab); // Back to activity tab content
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab); expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
// Check props passed to child components test("resets to first tab when modal is reopened", async () => {
const activityTabProps = (props.tabs[0].children as any).props; const user = userEvent.setup();
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses); const { rerender } = render(<ActionDetailModal {...defaultProps} />);
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
expect(activityTabProps.isReadOnly).toBe(false);
expect(activityTabProps.environment).toBe(mockEnvironment);
expect(activityTabProps.actionClass).toBe(mockActionClass);
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
const settingsTabProps = (props.tabs[1].children as any).props; // Switch to settings tab
expect(settingsTabProps.actionClass).toBe(mockActionClass); const settingsTab = screen.getByText("Settings");
expect(settingsTabProps.actionClasses).toBe(mockActionClasses); await user.click(settingsTab);
expect(settingsTabProps.setOpen).toBe(mockSetOpen); expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
expect(settingsTabProps.isReadOnly).toBe(false);
// Close modal
rerender(<ActionDetailModal {...defaultProps} open={false} />);
// Reopen modal
rerender(<ActionDetailModal {...defaultProps} open={true} />);
// Should be back to activity tab (first tab)
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
}); });
test("renders correct icon based on action type", () => { test("renders correct icon based on action type", () => {
@@ -148,33 +191,68 @@ describe("ActionDetailModal", () => {
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass; const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />); render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs); expect(screen.getByTestId("nocode-icon")).toBeInTheDocument();
const props = mockedModalWithTabs.mock.calls[0][0]; expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument();
});
// Expect the 'nocode-icon' based on the updated mock and action type test("handles action without description", () => {
expect(props.icon).toBeDefined(); const actionWithoutDescription = { ...mockActionClass, description: "" };
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
if (!props.icon) { expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
throw new Error("Icon prop is not defined"); expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action");
} });
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon"); test("passes correct props to ActionActivityTab", () => {
render(<ActionDetailModal {...defaultProps} />);
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
expect(mockedActionActivityTab).toHaveBeenCalledWith(
{
otherEnvActionClasses: mockOtherEnvActionClasses,
otherEnvironment: mockOtherEnvironment,
isReadOnly: false,
environment: mockEnvironment,
actionClass: mockActionClass,
environmentId: mockEnvironmentId,
},
undefined
);
});
test("passes correct props to ActionSettingsTab when tab is active", async () => {
const user = userEvent.setup();
render(<ActionDetailModal {...defaultProps} />);
// ActionSettingsTab should not be called initially since first tab is active
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
expect(mockedActionSettingsTab).not.toHaveBeenCalled();
// Click the settings tab to activate ActionSettingsTab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Now ActionSettingsTab should be called with correct props
expect(mockedActionSettingsTab).toHaveBeenCalledWith(
{
actionClass: mockActionClass,
actionClasses: mockActionClasses,
setOpen: mockSetOpen,
isReadOnly: false,
},
undefined
);
}); });
test("passes isReadOnly prop correctly", () => { test("passes isReadOnly prop correctly", () => {
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />); render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
// Access the mocked component directly
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
if (!props.tabs[0].children || !props.tabs[1].children) { const mockedActionActivityTab = vi.mocked(ActionActivityTab);
throw new Error("Tabs children are not defined"); expect(mockedActionActivityTab).toHaveBeenCalledWith(
} expect.objectContaining({
isReadOnly: true,
const activityTabProps = (props.tabs[0].children as any).props; }),
expect(activityTabProps.isReadOnly).toBe(true); undefined
);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.isReadOnly).toBe(true);
}); });
}); });
@@ -59,6 +59,16 @@ export const ActionDetailModal = ({
}, },
]; ];
const typeDescription = () => {
if (actionClass.description) return actionClass.description;
else
return (
(actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) +
" " +
t("common.action").toLowerCase()
);
};
return ( return (
<> <>
<ModalWithTabs <ModalWithTabs
@@ -67,7 +77,7 @@ export const ActionDetailModal = ({
tabs={tabs} tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]} icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name} label={actionClass.name}
description={actionClass.description || ""} description={typeDescription()}
/> />
</> </>
); );
@@ -11,22 +11,21 @@ export const ActionClassDataRow = ({
locale: TUserLocale; locale: TUserLocale;
}) => { }) => {
return ( return (
<div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100"> <div className="m-2 grid grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm"> <div className="col-span-4 flex items-start py-3 pl-6 text-sm">
<div className="flex items-center"> <div className="flex w-full items-center gap-4">
<div className="h-5 w-5 flex-shrink-0 text-slate-500"> <div className="mt-1 h-5 w-5 flex-shrink-0 text-slate-500">
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]} {ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
</div> </div>
<div className="ml-4 text-left"> <div className="text-left">
<div className="font-medium text-slate-900">{actionClass.name}</div> <div className="break-words font-medium text-slate-900">{actionClass.name}</div>
<div className="text-xs text-slate-400">{actionClass.description}</div> <div className="break-words text-xs text-slate-400">{actionClass.description}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500"> <div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)} {timeSince(actionClass.createdAt.toString(), locale)}
</div> </div>
<div className="text-center"></div>
</div> </div>
); );
}; };
@@ -210,14 +210,13 @@ export const ActionSettingsTab = ({
)} )}
</div> </div>
<div className="flex justify-between border-t border-slate-200 py-6"> <div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div> <div className="flex items-center gap-x-2">
{!isReadOnly ? ( {!isReadOnly ? (
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
onClick={() => setOpenDeleteDialog(true)} onClick={() => setOpenDeleteDialog(true)}
className="mr-3"
id="deleteActionModalTrigger"> id="deleteActionModalTrigger">
<TrashIcon /> <TrashIcon />
{t("common.delete")} {t("common.delete")}
@@ -22,14 +22,29 @@ vi.mock("@/modules/ui/components/button", () => ({
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ children, open, setOpen, ...props }: any) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? ( open ? (
<div data-testid="modal" {...props}> <div data-testid="dialog" role="dialog">
{children} {children}
<button onClick={() => setOpen(false)}>Close Modal</button> <button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div> </div>
) : null, ) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children, className }: any) => (
<h2 data-testid="dialog-title" className={className}>
{children}
</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
})); }));
vi.mock("@tolgee/react", () => ({ vi.mock("@tolgee/react", () => ({
@@ -70,17 +85,21 @@ describe("AddActionModal", () => {
); );
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument(); expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("opens the modal when the 'Add Action' button is clicked", async () => { test("opens the dialog when the 'Add Action' button is clicked", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument(); expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument(); expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
expect( expect(
@@ -108,35 +127,35 @@ describe("AddActionModal", () => {
expect(props.setActionClasses).toBeInstanceOf(Function); expect(props.setActionClasses).toBeInstanceOf(Function);
}); });
test("closes the modal when the close button (simulated) is clicked", async () => { test("closes the dialog when the close button (simulated) is clicked", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked Modal's close button // Simulate closing via the mocked Dialog's close button
const closeModalButton = screen.getByText("Close Modal"); const closeDialogButton = screen.getByText("Close Dialog");
await userEvent.click(closeModalButton); await userEvent.click(closeDialogButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("closes the modal when setOpen is called from CreateNewActionTab", async () => { test("closes the dialog when setOpen is called from CreateNewActionTab", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked CreateNewActionTab's button // Simulate closing via the mocked CreateNewActionTab's button
const closeFromTabButton = screen.getByText("Close from Tab"); const closeFromTabButton = screen.getByText("Close from Tab");
await userEvent.click(closeFromTabButton); await userEvent.click(closeFromTabButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
}); });
@@ -2,7 +2,14 @@
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab"; import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal"; import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { MousePointerClickIcon, PlusIcon } from "lucide-react"; import { MousePointerClickIcon, PlusIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -26,36 +33,26 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
{t("common.add_action")} {t("common.add_action")}
<PlusIcon /> <PlusIcon />
</Button> </Button>
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow> <Dialog open={open} onOpenChange={setOpen}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent disableCloseOnOutsideClick>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <MousePointerClickIcon />
<div className="flex items-center space-x-2"> <DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
<div className="mr-1.5 h-6 w-6 text-slate-500"> <DialogDescription>
<MousePointerClickIcon className="h-5 w-5" /> {t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
</div> </DialogDescription>
<div> </DialogHeader>
<div className="text-xl font-medium text-slate-700"> <DialogBody>
{t("environments.actions.track_new_user_action")} <CreateNewActionTab
</div> actionClasses={newActionClasses}
<div className="text-sm text-slate-500"> environmentId={environmentId}
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")} isReadOnly={isReadOnly}
</div> setActionClasses={setNewActionClasses}
</div> setOpen={setOpen}
</div> />
</div> </DialogBody>
</div> </DialogContent>
</div> </Dialog>
<div className="px-6 py-4">
<CreateNewActionTab
actionClasses={newActionClasses}
environmentId={environmentId}
isReadOnly={isReadOnly}
setActionClasses={setNewActionClasses}
setOpen={setOpen}
/>
</div>
</Modal>
</> </>
); );
}; };
@@ -221,7 +221,6 @@ describe("MainNavigation", () => {
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut }); vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
// Set up localStorage spy on the mocked localStorage // Set up localStorage spy on the mocked localStorage
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
render(<MainNavigation {...defaultProps} />); render(<MainNavigation {...defaultProps} />);
@@ -243,23 +242,18 @@ describe("MainNavigation", () => {
const logoutButton = screen.getByText("common.logout"); const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton); await userEvent.click(logoutButton);
// Verify localStorage.removeItem is called with the correct key
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
expect(mockSignOut).toHaveBeenCalledWith({ expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated", reason: "user_initiated",
redirectUrl: "/auth/login", redirectUrl: "/auth/login",
organizationId: "org1", organizationId: "org1",
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
await waitFor(() => { await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
}); });
// Clean up spy
removeItemSpy.mockRestore();
}); });
test("handles organization switching", async () => { test("handles organization switching", async () => {
@@ -4,7 +4,6 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import FBLogo from "@/images/formbricks-wordmark.svg"; import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings"; import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
@@ -391,14 +390,13 @@ export const MainNavigation = ({
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
const route = await signOutWithAudit({ const route = await signOutWithAudit({
reason: "user_initiated", reason: "user_initiated",
redirectUrl: "/auth/login", redirectUrl: "/auth/login",
organizationId: organization.id, organizationId: organization.id,
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
}} }}
@@ -92,14 +92,24 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ children, open, setOpen }) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? ( open ? (
<div data-testid="modal"> <div data-testid="dialog" role="dialog">
{children} {children}
<button onClick={() => setOpen(false)}>Close Modal</button> <button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div> </div>
) : null, ) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
vi.mock("@/modules/ui/components/alert", () => ({ vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }) => <div data-testid="alert">{children}</div>, Alert: ({ children }) => <div data-testid="alert">{children}</div>,
@@ -10,8 +10,16 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -19,11 +27,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/modules/ui/components/select"; } from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react"; import { TFnType, useTranslate } from "@tolgee/react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Control, Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { import {
@@ -68,6 +76,80 @@ const NoBaseFoundError = () => {
); );
}; };
const renderQuestionSelection = ({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: {
t: TFnType;
selectedSurvey: TSurvey;
control: Control<IntegrationModalInputs>;
includeVariables: boolean;
setIncludeVariables: (value: boolean) => void;
includeHiddenFields: boolean;
includeMetadata: boolean;
setIncludeHiddenFields: (value: boolean) => void;
setIncludeMetadata: (value: boolean) => void;
includeCreatedAt: boolean;
setIncludeCreatedAt: (value: boolean) => void;
}) => {
return (
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)}
/>
))}
</div>
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
);
};
export const AddIntegrationModal = ({ export const AddIntegrationModal = ({
open, open,
setOpenWithStates, setOpenWithStates,
@@ -210,182 +292,148 @@ export const AddIntegrationModal = ({
}; };
return ( return (
<Modal open={open} setOpen={handleClose} noPadding> <Dialog open={open} onOpenChange={setOpenWithStates}>
<div className="rounded-t-lg bg-slate-100"> <DialogContent className="overflow-visible md:overflow-visible">
<div className="flex w-full items-center justify-between p-6"> <DialogHeader>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <div className="relative size-8">
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" /> <Image
fill
className="object-contain object-center"
src={AirtableLogo}
alt={t("environments.integrations.airtable.airtable_logo")}
/>
</div> </div>
<div> <div className="space-y-0.5">
<div className="text-xl font-medium text-slate-700"> <DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
{t("environments.integrations.airtable.link_airtable_table")} <DialogDescription>
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.airtable.sync_responses_with_airtable")} {t("environments.integrations.airtable.sync_responses_with_airtable")}
</div> </DialogDescription>
</div> </div>
</div> </div>
</div> </DialogHeader>
</div> <form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
<form onSubmit={handleSubmit(submitHandler)}> <DialogBody className="overflow-visible">
<div className="flex rounded-lg p-6"> <div className="flex w-full flex-col gap-y-4">
<div className="flex w-full flex-col gap-y-4 pt-5"> {airtableArray.length ? (
{airtableArray.length ? ( <BaseSelectDropdown
<BaseSelectDropdown
control={control}
isLoading={isLoading}
fetchTable={fetchTable}
airtableArray={airtableArray}
setValue={setValue}
defaultValue={defaultData?.base}
/>
) : (
<NoBaseFoundError />
)}
<div className="flex w-full flex-col">
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex">
<Controller
control={control} control={control}
name="table" isLoading={isLoading}
render={({ field }) => ( fetchTable={fetchTable}
<Select airtableArray={airtableArray}
required setValue={setValue}
disabled={!tables.length} defaultValue={defaultData?.base}
onValueChange={(val) => {
field.onChange(val);
}}
defaultValue={defaultData?.table}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
)}
/> />
</div> ) : (
</div> <NoBaseFoundError />
)}
{surveys.length ? (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<Label htmlFor="survey">{t("common.select_survey")}</Label> <Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex"> <div className="mt-1 flex">
<Controller <Controller
control={control} control={control}
name="survey" name="table"
render={({ field }) => ( render={({ field }) => (
<Select <Select
required required
disabled={!tables.length}
onValueChange={(val) => { onValueChange={(val) => {
field.onChange(val); field.onChange(val);
setValue("questions", []);
}} }}
defaultValue={defaultData?.survey}> defaultValue={defaultData?.table}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> {tables.length ? (
{surveys.map((item) => ( <SelectContent>
<SelectItem key={item.id} value={item.id}> {tables.map((item) => (
{item.name} <SelectItem key={item.id} value={item.id}>
</SelectItem> {item.name}
))} </SelectItem>
</SelectContent> ))}
</SelectContent>
) : null}
</Select> </Select>
)} )}
/> />
</div> </div>
</div> </div>
) : null}
{!surveys.length ? ( {surveys.length ? (
<p className="m-1 text-xs text-slate-500"> <div className="flex w-full flex-col">
{t("environments.integrations.create_survey_warning")} <Label htmlFor="survey">{t("common.select_survey")}</Label>
</p> <div className="mt-1 flex">
) : null} <Controller
control={control}
{survey && selectedSurvey && ( name="survey"
<div className="space-y-4"> render={({ field }) => (
<div> <Select
<Label htmlFor="Surveys">{t("common.questions")}</Label> required
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200"> onValueChange={(val) => {
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900"> field.onChange(val);
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => ( setValue("questions", []);
<Controller }}
key={question.id} defaultValue={defaultData?.survey}>
control={control} <SelectTrigger>
name={"questions"} <SelectValue />
render={({ field }) => ( </SelectTrigger>
<div className="my-1 flex items-center space-x-2"> <SelectContent>
<label htmlFor={question.id} className="flex cursor-pointer items-center"> {surveys.map((item) => (
<Checkbox <SelectItem key={item.id} value={item.id}>
type="button" {item.name}
id={question.id} </SelectItem>
value={question.id} ))}
className="bg-white" </SelectContent>
checked={field.value?.includes(question.id)} </Select>
onCheckedChange={(checked) => { )}
return checked />
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)}
/>
))}
</div>
</div> </div>
</div> </div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
)}
<div className="flex justify-end gap-x-2">
{isEditMode ? (
<Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : ( ) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}> <p className="m-1 text-xs text-slate-500">
{t("common.cancel")} {t("environments.integrations.create_survey_warning")}
</Button> </p>
)} )}
<Button type="submit">{t("common.save")}</Button> {survey &&
selectedSurvey &&
renderQuestionSelection({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
})}
</div> </div>
</div> </DialogBody>
</div> <DialogFooter>
</form> {isEditMode ? (
</Modal> <Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
)}
<Button type="submit">{t("common.save")}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
); );
}; };
@@ -88,9 +88,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? <div data-testid="modal">{children}</div> : null, open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -304,10 +319,9 @@ describe("AddIntegrationModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
).toBeInTheDocument();
// Use getByPlaceholderText for the input // Use getByPlaceholderText for the input
expect( expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>") screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -332,10 +346,9 @@ describe("AddIntegrationModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
).toBeInTheDocument();
// Use getByPlaceholderText for the input // Use getByPlaceholderText for the input
expect( expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>") screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -14,10 +14,18 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -202,31 +210,28 @@ export const AddIntegrationModal = ({
}; };
return ( return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}> <Dialog open={open} onOpenChange={setOpenWithStates}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2"> <div className="relative size-8">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <Image
<Image fill
className="w-12" className="object-contain object-center"
src={GoogleSheetLogo} src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")} alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/> />
</div> </div>
<div> <div className="space-y-0.5">
<div className="text-xl font-medium text-slate-700"> <DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
{t("environments.integrations.google_sheets.link_google_sheet")} <DialogDescription>
</div> {t("environments.integrations.google_sheets.google_sheets_integration_description")}
<div className="text-sm text-slate-500"> </DialogDescription>
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</div>
</div>
</div> </div>
</div> </div>
</div> </DialogHeader>
<form onSubmit={handleSubmit(linkSheet)}> <form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
<div className="flex justify-between rounded-lg p-6"> <DialogBody>
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
</div> </div>
)} )}
</div> </div>
</div> </DialogBody>
<div className="flex justify-end border-t border-slate-200 p-6"> <DialogFooter>
<div className="flex space-x-2"> {selectedIntegration ? (
{selectedIntegration ? ( <Button
<Button type="button"
type="button" variant="destructive"
variant="destructive" loading={isDeleting}
loading={isDeleting} onClick={() => {
onClick={() => { deleteLink();
deleteLink(); }}>
}}> {t("common.delete")}
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button> </Button>
</div> ) : (
</div> <Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button>
</DialogFooter>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -74,13 +74,41 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
vi.mock("@/modules/ui/components/label", () => ({ vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>, Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null, open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p data-testid="dialog-description" className={className}>
{children}
</p>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
})); }));
vi.mock("lucide-react", () => ({ vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>, PlusIcon: () => <span data-testid="plus-icon">+</span>,
XIcon: () => <span data-testid="x-icon">x</span>, TrashIcon: () => <span data-testid="trash-icon">🗑</span>,
})); }));
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -334,7 +362,7 @@ describe("AddIntegrationModal (Notion)", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument(); expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument(); expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument(); expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
@@ -359,7 +387,7 @@ describe("AddIntegrationModal (Notion)", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id); expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id); expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument(); expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
@@ -381,7 +409,7 @@ describe("AddIntegrationModal (Notion)", () => {
expect(columnDropdowns[1]).toHaveValue("p2"); expect(columnDropdowns[1]).toHaveValue("p2");
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0); expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0); expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0);
}); });
expect(screen.getByText("Delete")).toBeInTheDocument(); expect(screen.getByText("Delete")).toBeInTheDocument();
@@ -445,8 +473,8 @@ describe("AddIntegrationModal (Notion)", () => {
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2); expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button
await userEvent.click(xButton); await userEvent.click(trashButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1); expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
}); });
@@ -12,11 +12,19 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions"; import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { PlusIcon, XIcon } from "lucide-react"; import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -336,9 +344,9 @@ export const AddIntegrationModal = ({
col={mapping[idx].column} col={mapping[idx].column}
ques={mapping[idx].question} ques={mapping[idx].question}
/> />
<div className="flex w-full items-center"> <div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<div className="w-[340px] max-w-full"> <div className="max-w-full flex-1">
<DropdownSelector <DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")} placeholder={t("environments.integrations.notion.select_a_survey_question")}
items={filteredQuestionItems} items={filteredQuestionItems}
@@ -384,7 +392,7 @@ export const AddIntegrationModal = ({
/> />
</div> </div>
<div className="h-px w-4 border-t border-t-slate-300" /> <div className="h-px w-4 border-t border-t-slate-300" />
<div className="w-[340px] max-w-full"> <div className="max-w-full flex-1">
<DropdownSelector <DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")} placeholder={t("environments.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()} items={getFilteredDbItems()}
@@ -430,53 +438,45 @@ export const AddIntegrationModal = ({
/> />
</div> </div>
</div> </div>
<button <div className="flex space-x-2">
type="button" {mapping.length > 1 && (
className={`rounded-md p-1 hover:bg-slate-300 ${ <Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
idx === mapping.length - 1 ? "visible" : "invisible" <TrashIcon />
}`} </Button>
onClick={addRow}> )}
<PlusIcon className="h-5 w-5 font-bold text-slate-500" /> <Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
</button> <PlusIcon />
<button </Button>
type="button" </div>
className={`flex-1 rounded-md p-1 hover:bg-red-100 ${
mapping.length > 1 ? "visible" : "invisible"
}`}
onClick={deleteRow}>
<XIcon className="h-5 w-5 text-red-500" />
</button>
</div> </div>
</div> </div>
); );
}; };
return ( return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg"> <Dialog open={open} onOpenChange={setOpen}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <div className="mb-4 flex items-start space-x-2">
<div className="flex items-center space-x-2"> <div className="relative size-8">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <Image
<Image fill
className="w-12" className="object-contain object-center"
src={NotionLogo} src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")} alt={t("environments.integrations.notion.notion_logo")}
/> />
</div> </div>
<div> <div className="space-y-0.5">
<div className="text-xl font-medium text-slate-700"> <DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
{t("environments.integrations.notion.link_notion_database")} <DialogDescription>
</div> {t("environments.integrations.notion.notion_integration_description")}
<div className="text-sm text-slate-500"> </DialogDescription>
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
</div>
</div>
</div> </div>
</div> </div>
</div> </DialogHeader>
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
<div className="flex justify-between rounded-lg p-6"> <form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4">
<DialogBody>
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
<Label> <Label>
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")} {t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label> </Label>
<div className="mt-4 max-h-[20vh] w-full overflow-y-auto"> <div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((_, idx) => ( {mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} /> <MappingRow idx={idx} key={idx} />
))} ))}
@@ -530,43 +530,40 @@ export const AddIntegrationModal = ({
)} )}
</div> </div>
</div> </div>
</div> </DialogBody>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2"> <DialogFooter>
{selectedIntegration ? ( {selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button <Button
type="submit" type="button"
loading={isLinkingDatabase} variant="destructive"
disabled={mapping.filter((m) => m.error).length > 0}> loading={isDeleting}
{selectedIntegration onClick={() => {
? t("common.update") deleteLink();
: t("environments.integrations.notion.link_database")} }}>
{t("common.delete")}
</Button> </Button>
</div> ) : (
</div> <Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
</Button>
</DialogFooter>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -83,9 +83,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? <div data-testid="modal">{children}</div> : null, open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -121,6 +136,8 @@ vi.mock("@tolgee/react", async () => {
if (key === "common.all_questions") return "All questions"; if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions"; if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel"; if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel";
if (key === "environments.integrations.slack.slack_integration_description")
return "Send responses directly to Slack.";
if (key === "common.update") return "Update"; if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete"; if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel"; if (key === "common.cancel") return "Cancel";
@@ -312,10 +329,9 @@ describe("AddChannelMappingModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument(); expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument(); expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument(); expect(screen.getByText("Cancel")).toBeInTheDocument();
@@ -339,10 +355,9 @@ describe("AddChannelMappingModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id); expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id); expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument(); expect(screen.getByText("Questions")).toBeInTheDocument();
@@ -7,9 +7,17 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { CircleHelpIcon } from "lucide-react"; import { CircleHelpIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
@@ -189,24 +197,28 @@ export const AddChannelMappingModal = ({
); );
return ( return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}> <Dialog open={open} onOpenChange={setOpenWithStates}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2"> <div className="relative size-8">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <Image
<Image className="w-12" src={SlackLogo} alt="Slack logo" /> fill
</div> className="object-contain object-center"
<div> src={SlackLogo}
<div className="text-xl font-medium text-slate-700"> alt={t("environments.integrations.slack.slack_logo")}
{t("environments.integrations.slack.link_slack_channel")} />
</div> </div>
</div> <div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.slack.link_slack_channel")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.slack.slack_integration_description")}
</DialogDescription>
</div> </div>
</div> </div>
</div> </DialogHeader>
<form onSubmit={handleSubmit(linkChannel)}> <form className="space-y-4" onSubmit={handleSubmit(linkChannel)}>
<div className="flex justify-between rounded-lg p-6"> <DialogBody>
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -289,31 +301,29 @@ export const AddChannelMappingModal = ({
</div> </div>
)} )}
</div> </div>
</div> </DialogBody>
<div className="flex justify-end border-t border-slate-200 p-6"> <DialogFooter>
<div className="flex space-x-2"> {selectedIntegration ? (
{selectedIntegration ? ( <Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}> {t("common.delete")}
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</Button> </Button>
</div> ) : (
</div> <Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</Button>
</DialogFooter>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -20,7 +20,7 @@ vi.mock("@/modules/ui/components/switch", () => ({
})); }));
vi.mock("../actions", () => ({ vi.mock("../actions", () => ({
updateNotificationSettingsAction: vi.fn(() => Promise.resolve()), updateNotificationSettingsAction: vi.fn(() => Promise.resolve({ data: true })),
})); }));
const surveyId = "survey1"; const surveyId = "survey1";
@@ -246,4 +246,204 @@ describe("NotificationSwitch", () => {
}); });
expect(updateNotificationSettingsAction).not.toHaveBeenCalled(); expect(updateNotificationSettingsAction).not.toHaveBeenCalled();
}); });
test("shows error toast when updateNotificationSettingsAction fails for 'alert' type", async () => {
const mockErrorResponse = { serverError: "Failed to update notification settings" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Failed to update notification settings", {
id: "notification-switch",
});
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);
const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
renderSwitch({
surveyOrProjectOrOrganizationId: organizationId,
notificationSettings: initialSettings,
notificationType: "unsubscribedOrganizationIds",
});
const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [organizationId] },
});
expect(toast.error).toHaveBeenCalledWith("Permission denied", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns null", async () => {
const mockErrorResponse = { serverError: "An error occurred" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns undefined", async () => {
const mockErrorResponse = { serverError: "An error occurred" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns response without data property", async () => {
const mockErrorResponse = { validationErrors: { _errors: ["Invalid input"] } };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Invalid input", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction throws an exception", async () => {
const mockErrorResponse = { serverError: "Network error" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Network error", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("switch remains enabled after error occurs", async () => {
const mockErrorResponse = { serverError: "Failed to update" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(toast.error).toHaveBeenCalledWith("Failed to update", {
id: "notification-switch",
});
expect(switchInput).toBeEnabled(); // Switch should be re-enabled after error
});
test("shows error toast with validation errors for specific fields", async () => {
const mockErrorResponse = {
validationErrors: {
notificationSettings: {
_errors: ["Invalid notification settings"],
},
},
};
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("notificationSettingsInvalid notification settings", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
}); });
@@ -1,7 +1,9 @@
"use client"; "use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Switch } from "@/modules/ui/components/switch"; import { Switch } from "@/modules/ui/components/switch";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { TUserNotificationSettings } from "@formbricks/types/user"; import { TUserNotificationSettings } from "@formbricks/types/user";
@@ -24,6 +26,7 @@ export const NotificationSwitch = ({
}: NotificationSwitchProps) => { }: NotificationSwitchProps) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslate(); const { t } = useTranslate();
const router = useRouter();
const isChecked = const isChecked =
notificationType === "unsubscribedOrganizationIds" notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId) ? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
@@ -50,7 +53,20 @@ export const NotificationSwitch = ({
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId]; !updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
} }
await updateNotificationSettingsAction({ notificationSettings: updatedNotificationSettings }); const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
notificationSettings: updatedNotificationSettings,
});
if (updatedNotificationSettingsActionResponse?.data) {
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
id: "notification-switch",
});
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedNotificationSettingsActionResponse);
toast.error(errorMessage, {
id: "notification-switch",
});
}
setIsLoading(false); setIsLoading(false);
}; };
@@ -104,9 +120,6 @@ export const NotificationSwitch = ({
disabled={isLoading} disabled={isLoading}
onCheckedChange={async () => { onCheckedChange={async () => {
await handleSwitchChange(); await handleSwitchChange();
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
id: "notification-switch",
});
}} }}
/> />
); );
@@ -13,7 +13,7 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co
import { rateLimit } from "@/lib/utils/rate-limit"; import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationNewEmail } from "@/modules/email"; import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { import {
@@ -162,3 +162,21 @@ export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatar
} }
) )
); );
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging(
"passwordReset",
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
}
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
}
)
);
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { updateUserAction } from "../actions"; import { resetPasswordAction, updateUserAction } from "../actions";
import { EditProfileDetailsForm } from "./EditProfileDetailsForm"; import { EditProfileDetailsForm } from "./EditProfileDetailsForm";
const mockUser = { const mockUser = {
@@ -24,6 +24,8 @@ const mockUser = {
objective: "other", objective: "other",
} as unknown as TUser; } as unknown as TUser;
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
// Mock window.location.reload // Mock window.location.reload
const originalLocation = window.location; const originalLocation = window.location;
beforeEach(() => { beforeEach(() => {
@@ -35,6 +37,11 @@ beforeEach(() => {
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({ vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
updateUserAction: vi.fn(), updateUserAction: vi.fn(),
resetPasswordAction: vi.fn(),
}));
vi.mock("@/modules/auth/forgot-password/actions", () => ({
forgotPasswordAction: vi.fn(),
})); }));
afterEach(() => { afterEach(() => {
@@ -50,7 +57,13 @@ describe("EditProfileDetailsForm", () => {
test("renders with initial user data and updates successfully", async () => { test("renders with initial user data and updates successfully", async () => {
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any); vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />); render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={true}
isPasswordResetEnabled={false}
/>
);
const nameInput = screen.getByPlaceholderText("common.full_name"); const nameInput = screen.getByPlaceholderText("common.full_name");
expect(nameInput).toHaveValue(mockUser.name); expect(nameInput).toHaveValue(mockUser.name);
@@ -91,7 +104,13 @@ describe("EditProfileDetailsForm", () => {
const errorMessage = "Update failed"; const errorMessage = "Update failed";
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage)); vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />); render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={false}
/>
);
const nameInput = screen.getByPlaceholderText("common.full_name"); const nameInput = screen.getByPlaceholderText("common.full_name");
await userEvent.clear(nameInput); await userEvent.clear(nameInput);
@@ -109,7 +128,13 @@ describe("EditProfileDetailsForm", () => {
}); });
test("update button is disabled initially and enables on change", async () => { test("update button is disabled initially and enables on change", async () => {
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />); render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={false}
/>
);
const updateButton = screen.getByText("common.update"); const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled(); expect(updateButton).toBeDisabled();
@@ -117,4 +142,68 @@ describe("EditProfileDetailsForm", () => {
await userEvent.type(nameInput, " updated"); await userEvent.type(nameInput, " updated");
expect(updateButton).toBeEnabled(); expect(updateButton).toBeEnabled();
}); });
test("reset password button works", async () => {
vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } });
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
await waitFor(() => {
expect(resetPasswordAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading");
});
});
test("reset password button handles error correctly", async () => {
const errorMessage = "Reset failed";
vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage });
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
await waitFor(() => {
expect(resetPasswordAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(errorMessage);
});
});
test("reset password button shows loading state", async () => {
vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
expect(resetButton).toBeDisabled();
});
}); });
@@ -14,6 +14,7 @@ import {
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
@@ -22,7 +23,7 @@ import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { z } from "zod"; import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { updateUserAction } from "../actions"; import { resetPasswordAction, updateUserAction } from "../actions";
// Schema & types // Schema & types
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
@@ -30,13 +31,17 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email:
}); });
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>; type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
interface IEditProfileDetailsFormProps {
user: TUser;
isPasswordResetEnabled?: boolean;
emailVerificationDisabled: boolean;
}
export const EditProfileDetailsForm = ({ export const EditProfileDetailsForm = ({
user, user,
isPasswordResetEnabled,
emailVerificationDisabled, emailVerificationDisabled,
}: { }: IEditProfileDetailsFormProps) => {
user: TUser;
emailVerificationDisabled: boolean;
}) => {
const { t } = useTranslate(); const { t } = useTranslate();
const form = useForm<TEditProfileNameForm>({ const form = useForm<TEditProfileNameForm>({
@@ -50,6 +55,8 @@ export const EditProfileDetailsForm = ({
}); });
const { isSubmitting, isDirty } = form.formState; const { isSubmitting, isDirty } = form.formState;
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
@@ -90,6 +97,7 @@ export const EditProfileDetailsForm = ({
redirectUrl: "/email-change-without-verification-success", redirectUrl: "/email-change-without-verification-success",
redirect: true, redirect: true,
callbackUrl: "/email-change-without-verification-success", callbackUrl: "/email-change-without-verification-success",
clearEnvironmentId: true,
}); });
return; return;
} }
@@ -121,6 +129,28 @@ export const EditProfileDetailsForm = ({
} }
}; };
const handleResetPassword = async () => {
setIsResettingPassword(true);
const result = await resetPasswordAction();
if (result?.data) {
toast.success(t("auth.forgot-password.email-sent.heading"));
await signOutWithAudit({
reason: "password_reset",
redirectUrl: "/auth/login",
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(t(errorMessage));
}
setIsResettingPassword(false);
};
return ( return (
<> <>
<FormProvider {...form}> <FormProvider {...form}>
@@ -205,6 +235,26 @@ export const EditProfileDetailsForm = ({
)} )}
/> />
{isPasswordResetEnabled && (
<div className="mt-4 space-y-2">
<Label htmlFor="reset-password">{t("auth.forgot-password.reset_password")}</Label>
<p className="mt-1 text-sm text-slate-500">
{t("auth.forgot-password.reset_password_description")}
</p>
<div className="flex items-center justify-between gap-2">
<Input type="email" id="reset-password" defaultValue={user.email} disabled />
<Button
onClick={handleResetPassword}
loading={isResettingPassword}
disabled={isResettingPassword}
size="default"
variant="secondary">
{t("auth.forgot-password.reset_password")}
</Button>
</div>
</div>
)}
<Button <Button
type="submit" type="submit"
className="mt-4" className="mt-4"
@@ -4,18 +4,27 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { PasswordConfirmationModal } from "./password-confirmation-modal"; import { PasswordConfirmationModal } from "./password-confirmation-modal";
// Mock the Modal component // Mock the Dialog component
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ children, open, setOpen, title }: any) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? ( open ? (
<div data-testid="modal"> <div data-testid="dialog" role="dialog">
<div data-testid="modal-title">{title}</div>
{children} {children}
<button data-testid="modal-close" onClick={() => setOpen(false)}> <button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close Close
</button> </button>
</div> </div>
) : null, ) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
// Mock the PasswordInput component // Mock the PasswordInput component
@@ -54,13 +63,13 @@ describe("PasswordConfirmationModal", () => {
test("renders nothing when open is false", () => { test("renders nothing when open is false", () => {
render(<PasswordConfirmationModal {...defaultProps} open={false} />); render(<PasswordConfirmationModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("renders modal content when open is true", () => { test("renders dialog content when open is true", () => {
render(<PasswordConfirmationModal {...defaultProps} />); render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("modal-title")).toBeInTheDocument(); expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
}); });
test("displays old and new email addresses", () => { test("displays old and new email addresses", () => {
@@ -1,8 +1,16 @@
"use client"; "use client";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Modal } from "@/modules/ui/components/modal";
import { PasswordInput } from "@/modules/ui/components/password-input"; import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
@@ -54,64 +62,69 @@ export const PasswordConfirmationModal = ({
}; };
return ( return (
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}> <Dialog open={open} onOpenChange={setOpen}>
<FormProvider {...form}> <DialogContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <DialogHeader>
<p className="text-muted-foreground text-sm"> <DialogTitle>{t("auth.forgot-password.reset.confirm_password")}</DialogTitle>
{t("auth.email-change.confirm_password_description")} <DialogDescription>{t("auth.email-change.confirm_password_description")}</DialogDescription>
</p> </DialogHeader>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<DialogBody>
<div className="space-y-4">
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4"> <FormField
<p> control={form.control}
<strong>{t("auth.email-change.old_email")}:</strong> name="password"
<br /> {oldEmail.toLowerCase()} render={({ field, fieldState: { error } }) => (
</p> <FormItem className="w-full">
<p> <FormControl>
<strong>{t("auth.email-change.new_email")}:</strong> <div>
<br /> {newEmail.toLowerCase()} <PasswordInput
</p> id="password"
</div> autoComplete="current-password"
placeholder="*******"
<FormField aria-placeholder="password"
control={form.control} aria-label="password"
name="password" aria-required="true"
render={({ field, fieldState: { error } }) => ( required
<FormItem className="w-full"> className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
<FormControl> value={field.value}
<div> onChange={(password) => field.onChange(password)}
<PasswordInput />
id="password" {error?.message && <FormError className="text-left">{error.message}</FormError>}
autoComplete="current-password" </div>
placeholder="*******" </FormControl>
aria-placeholder="password" </FormItem>
aria-label="password" )}
aria-required="true" />
required </div>
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm" </DialogBody>
value={field.value} <DialogFooter>
onChange={(password) => field.onChange(password)} <Button type="button" variant="secondary" onClick={handleCancel}>
/> {t("common.cancel")}
{error?.message && <FormError className="text-left">{error.message}</FormError>} </Button>
</div> <Button
</FormControl> type="submit"
</FormItem> variant="default"
)} loading={isSubmitting}
/> disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
<div className="mt-4 space-x-2 text-right"> </Button>
<Button type="button" variant="secondary" onClick={handleCancel}> </DialogFooter>
{t("common.cancel")} </form>
</Button> </FormProvider>
<Button </DialogContent>
type="submit" </Dialog>
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</div>
</form>
</FormProvider>
</Modal>
); );
}; };
@@ -12,7 +12,8 @@ import Page from "./page";
// Mock services and utils // Mock services and utils
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true, IS_FORMBRICKS_CLOUD: 1,
PASSWORD_RESET_DISABLED: 1,
EMAIL_VERIFICATION_DISABLED: true, EMAIL_VERIFICATION_DISABLED: true,
})); }));
vi.mock("@/lib/organization/service", () => ({ vi.mock("@/lib/organization/service", () => ({
@@ -1,6 +1,6 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -32,6 +32,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
return ( return (
<PageContentWrapper> <PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")}> <PageHeader pageTitle={t("common.account_settings")}>
@@ -42,7 +44,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
<SettingsCard <SettingsCard
title={t("environments.settings.profile.personal_information")} title={t("environments.settings.profile.personal_information")}
description={t("environments.settings.profile.update_personal_info")}> description={t("environments.settings.profile.update_personal_info")}>
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} /> <EditProfileDetailsForm
user={user}
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
isPasswordResetEnabled={isPasswordResetEnabled}
/>
</SettingsCard> </SettingsCard>
<SettingsCard <SettingsCard
title={t("common.avatar")} title={t("common.avatar")}
@@ -26,8 +26,26 @@ vi.mock("@/modules/ui/components/button", () => ({
)), )),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)), Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, hideCloseButton, width, className }) => (
<div
data-testid="dialog-content"
data-hide-close-button={hideCloseButton}
data-width={width}
className={className}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
DialogFooter: vi.fn(({ children }) => <div data-testid="dialog-footer">{children}</div>),
})); }));
const mockResponses = [ const mockResponses = [
@@ -163,12 +181,12 @@ describe("ResponseCardModal", () => {
test("should not render if selectedResponseId is null", () => { test("should not render if selectedResponseId is null", () => {
const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />); const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />);
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("should render the modal when a response is selected", () => { test("should render the dialog when a response is selected", () => {
render(<ResponseCardModal {...defaultProps} />); render(<ResponseCardModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("single-response-card")).toBeInTheDocument(); expect(screen.getByTestId("single-response-card")).toBeInTheDocument();
}); });
@@ -204,14 +222,6 @@ describe("ResponseCardModal", () => {
expect(nextButton).toBeDisabled(); expect(nextButton).toBeDisabled();
}); });
test("should call setSelectedResponseId with null when close button is clicked", async () => {
render(<ResponseCardModal {...defaultProps} />);
const buttons = screen.getAllByTestId("mock-button");
const closeButton = buttons.find((button) => button.querySelector("svg.lucide-x"));
if (closeButton) await userEvent.click(closeButton);
expect(mockSetSelectedResponseId).toHaveBeenCalledWith(null);
});
test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => { test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => {
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />); render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />);
expect(mockSetOpen).toHaveBeenCalledWith(true); expect(mockSetOpen).toHaveBeenCalledWith(true);
@@ -229,11 +239,10 @@ describe("ResponseCardModal", () => {
expect(mockSetOpen).toHaveBeenCalledWith(false); expect(mockSetOpen).toHaveBeenCalledWith(false);
}); });
test("should render ChevronLeft, ChevronRight, and XIcon", () => { test("should render ChevronLeft and ChevronRight icons", () => {
render(<ResponseCardModal {...defaultProps} />); render(<ResponseCardModal {...defaultProps} />);
expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument(); expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument();
expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument(); expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument();
expect(document.querySelector(".lucide-x")).toBeInTheDocument();
}); });
}); });
@@ -1,7 +1,7 @@
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard"; import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal"; import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog";
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses"; import { TResponse } from "@formbricks/types/responses";
@@ -64,42 +64,20 @@ export const ResponseCardModal = ({
} }
}; };
const handleClose = () => { const handleClose = (open: boolean) => {
setSelectedResponseId(null); setOpen(open);
if (!open) {
setSelectedResponseId(null);
}
}; };
// If no response is selected or currentIndex is null, do not render the modal // If no response is selected or currentIndex is null, do not render the modal
if (selectedResponseId === null || currentIndex === null) return null; if (selectedResponseId === null || currentIndex === null) return null;
return ( return (
<Modal <Dialog open={open} onOpenChange={handleClose}>
hideCloseButton <DialogContent width="wide">
open={open} <DialogBody>
setOpen={setOpen}
size="xxl"
className="max-h-[80vh] overflow-auto"
noPadding>
<div className="h-full rounded-lg">
<div className="relative h-full w-full overflow-auto p-4">
<div className="mb-4 flex items-center justify-end space-x-2">
<Button
onClick={handleBack}
disabled={currentIndex === 0}
variant="ghost"
className="border bg-white p-2">
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="ghost"
className="border bg-white p-2">
<ChevronRight className="h-5 w-5" />
</Button>
<Button className="border bg-white p-2" onClick={handleClose} variant="ghost">
<XIcon className="h-5 w-5" />
</Button>
</div>
<SingleResponseCard <SingleResponseCard
survey={survey} survey={survey}
response={responses[currentIndex]} response={responses[currentIndex]}
@@ -113,8 +91,20 @@ export const ResponseCardModal = ({
setSelectedResponseId={setSelectedResponseId} setSelectedResponseId={setSelectedResponseId}
locale={locale} locale={locale}
/> />
</div> </DialogBody>
</div> <DialogFooter>
</Modal> <Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
<ChevronLeft />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="outline"
size="icon">
<ChevronRight />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}; };
@@ -1,13 +1,15 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage"; 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 { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { RESPONSES_PER_PAGE } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
@@ -33,6 +35,9 @@ const Page = async (props) => {
const tags = await getTagsByEnvironmentId(params.environmentId); const tags = await getTagsByEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
// Get response count for the CTA component // Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId); const responseCount = await getResponseCountBySurveyId(params.surveyId);
@@ -51,6 +56,9 @@ const Page = async (props) => {
user={user} user={user}
publicDomain={publicDomain} publicDomain={publicDomain}
responseCount={responseCount} responseCount={responseCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/> />
}> }>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" /> <SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
@@ -1,18 +1,23 @@
"use server"; "use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate"; import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { WEBAPP_URL } from "@/lib/constants";
import { putFile } from "@/lib/storage/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
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 { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email"; import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
const ZSendEmbedSurveyPreviewEmailAction = z.object({ const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId, surveyId: ZId,
@@ -222,3 +227,89 @@ export const getEmailHtmlAction = authenticatedActionClient
return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale); return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
}); });
const ZGeneratePersonalLinksAction = z.object({
surveyId: ZId,
segmentId: ZId,
environmentId: ZId,
expirationDays: z.number().optional(),
});
export const generatePersonalLinksAction = authenticatedActionClient
.schema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
}
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",
},
],
});
// Get contacts and generate personal links
const contactsResult = await generatePersonalLinks(
parsedInput.surveyId,
parsedInput.segmentId,
parsedInput.expirationDays
);
if (!contactsResult || contactsResult.length === 0) {
throw new UnknownError("No contacts found for the selected segment");
}
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
"User ID",
"First Name",
"Last Name",
"Email",
"Personal Link",
];
const csvData = contactsResult
.map((contact) => {
if (!contact) {
return null;
}
const attributes = contact.attributes ?? {};
return {
"Formbricks Contact ID": contact.contactId,
"User ID": attributes.userId ?? "",
"First Name": attributes.firstName ?? "",
"Last Name": attributes.lastName ?? "",
Email: attributes.email ?? "",
"Personal Link": contact.surveyUrl,
};
})
.filter((contact) => contact !== null);
// Convert to CSV using the file conversion utility
const csvContent = await convertToCsv(csvHeaders, csvData);
const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`;
// Store file temporarily and return download URL
const fileBuffer = Buffer.from(csvContent);
await putFile(fileName, fileBuffer, "private", parsedInput.environmentId);
const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`;
return {
downloadUrl,
fileName,
count: csvData.length,
};
});
@@ -117,9 +117,9 @@ vi.mock("./shareEmbedModal/EmbedView", () => ({
EmbedView: (props: any) => mockEmbedViewComponent(props), EmbedView: (props: any) => mockEmbedViewComponent(props),
})); }));
const mockPanelInfoViewComponent = vi.fn(); // Mock getSurveyUrl to return a predictable URL
vi.mock("./shareEmbedModal/PanelInfoView", () => ({ vi.mock("@/modules/analysis/utils", () => ({
PanelInfoView: (props: any) => mockPanelInfoViewComponent(props), getSurveyUrl: vi.fn().mockResolvedValue("https://public-domain.com/s/survey1"),
})); }));
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined; let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
@@ -133,8 +133,6 @@ vi.mock("@/modules/ui/components/dialog", async () => {
capturedDialogOnOpenChange = props.onOpenChange; capturedDialogOnOpenChange = props.onOpenChange;
return <actual.Dialog {...props} />; return <actual.Dialog {...props} />;
}, },
// DialogTitle, DialogContent, DialogDescription will be the actual components
// due to ...actual spread and no specific mock for them here.
}; };
}); });
@@ -154,13 +152,15 @@ describe("ShareEmbedSurvey", () => {
modalView: "start" as "start" | "embed" | "panel", modalView: "start" as "start" | "embed" | "panel",
setOpen: mockSetOpen, setOpen: mockSetOpen,
user: mockUser, user: mockUser,
segments: [],
isContactsEnabled: true,
isFormbricksCloud: true,
}; };
beforeEach(() => { beforeEach(() => {
mockEmbedViewComponent.mockImplementation( mockEmbedViewComponent.mockImplementation(
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => ( ({ tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
<div> <div>
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div> <div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
<div data-testid="embedview-activeid">{activeId}</div> <div data-testid="embedview-activeid">{activeId}</div>
<div data-testid="embedview-survey-id">{survey.id}</div> <div data-testid="embedview-survey-id">{survey.id}</div>
@@ -171,13 +171,10 @@ describe("ShareEmbedSurvey", () => {
</div> </div>
) )
); );
mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => (
<button onClick={() => handleInitialPageButton()}>PanelInfoViewMockContent</button>
));
}); });
test("renders initial 'start' view correctly when open and modalView is 'start'", () => { test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
render(<ShareEmbedSurvey {...defaultProps} />); render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} />);
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument(); expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
@@ -188,48 +185,32 @@ describe("ShareEmbedSurvey", () => {
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new"); expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
}); });
test("renders initial 'start' view correctly when open and modalView is 'start' for app survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} />);
// For app surveys, ShareSurveyLink should not be rendered
expect(screen.queryByText("ShareSurveyLinkMock")).not.toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => { test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />); render(<ShareEmbedSurvey {...defaultProps} />);
const embedButton = screen.getByText("environments.surveys.summary.embed_survey"); const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
await userEvent.click(embedButton); await userEvent.click(embedButton);
expect(mockEmbedViewComponent).toHaveBeenCalled(); expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
}); });
test("switches to 'panel' view when 'Send to panel' button is clicked", async () => { test("switches to 'panel' view when 'Send to panel' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />); render(<ShareEmbedSurvey {...defaultProps} />);
const panelButton = screen.getByText("environments.surveys.summary.send_to_panel"); const panelButton = screen.getByText("environments.surveys.summary.send_to_panel");
await userEvent.click(panelButton); await userEvent.click(panelButton);
expect(mockPanelInfoViewComponent).toHaveBeenCalled(); // Panel view currently just shows a title, no component is rendered
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
});
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
}); });
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => { test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
@@ -255,10 +236,10 @@ describe("ShareEmbedSurvey", () => {
tabs: { id: string; label: string; icon: LucideIcon }[]; tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string; activeId: string;
}; };
expect(embedViewProps.tabs.length).toBe(3); expect(embedViewProps.tabs.length).toBe(4);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined(); expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs[0].id).toBe("email"); expect(embedViewProps.tabs[0].id).toBe("link");
expect(embedViewProps.activeId).toBe("email"); expect(embedViewProps.activeId).toBe("link");
}); });
test("correctly configures for 'web' survey type in embed view", () => { test("correctly configures for 'web' survey type in embed view", () => {
@@ -285,24 +266,21 @@ describe("ShareEmbedSurvey", () => {
test("initial showView is set by modalView prop when open is true", () => { test("initial showView is set by modalView prop when open is true", () => {
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />); render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled(); expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
cleanup(); cleanup();
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />); render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled(); // Panel view currently just shows a title
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
}); });
test("useEffect sets showView to 'start' when open becomes false", () => { test("useEffect sets showView to 'start' when open becomes false", () => {
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />); const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); // Starts in embed
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />); rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found // Dialog mock returns null when open is false, so EmbedViewMockContent is not found
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument(); expect(screen.queryByTestId("embedview-tabs")).not.toBeInTheDocument();
// To verify showView is 'start', we'd need to inspect internal state or render start view elements
// For now, we trust the useEffect sets showView, and if it were to re-open in 'start' mode, it would show.
// The main check is that the previous view ('embed') is gone.
}); });
test("renders correct label for link tab based on singleUse survey property", () => { test("renders correct label for link tab based on singleUse survey property", () => {
@@ -12,15 +12,16 @@ import {
LinkIcon, LinkIcon,
MailIcon, MailIcon,
SmartphoneIcon, SmartphoneIcon,
UserIcon,
UsersRound, UsersRound,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { EmbedView } from "./shareEmbedModal/EmbedView"; import { EmbedView } from "./shareEmbedModal/EmbedView";
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps { interface ShareEmbedSurveyProps {
survey: TSurvey; survey: TSurvey;
@@ -29,6 +30,9 @@ interface ShareEmbedSurveyProps {
modalView: "start" | "embed" | "panel"; modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
user: TUser; user: TUser;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
} }
export const ShareEmbedSurvey = ({ export const ShareEmbedSurvey = ({
@@ -38,6 +42,9 @@ export const ShareEmbedSurvey = ({
modalView, modalView,
setOpen, setOpen,
user, user,
segments,
isContactsEnabled,
isFormbricksCloud,
}: ShareEmbedSurveyProps) => { }: ShareEmbedSurveyProps) => {
const router = useRouter(); const router = useRouter();
const environmentId = survey.environmentId; const environmentId = survey.environmentId;
@@ -47,20 +54,22 @@ export const ShareEmbedSurvey = ({
const tabs = useMemo( const tabs = useMemo(
() => () =>
[ [
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
{ {
id: "link", id: "link",
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`, label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
icon: LinkIcon, icon: LinkIcon,
}, },
{ id: "personal-links", label: t("environments.surveys.summary.personal_links"), icon: UserIcon },
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
{ id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon }, { id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon },
].filter((tab) => !(survey.type === "link" && tab.id === "app")), ].filter((tab) => !(survey.type === "link" && tab.id === "app")),
[t, isSingleUseLinkSurvey, survey.type] [t, isSingleUseLinkSurvey, survey.type]
); );
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id); const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[4].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start"); const [showView, setShowView] = useState<"start" | "embed" | "panel" | "personal-links">("start");
const [surveyUrl, setSurveyUrl] = useState(""); const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => { useEffect(() => {
@@ -79,7 +88,7 @@ export const ShareEmbedSurvey = ({
useEffect(() => { useEffect(() => {
if (survey.type !== "link") { if (survey.type !== "link") {
setActiveId(tabs[3].id); setActiveId(tabs[4].id);
} }
}, [survey.type, tabs]); }, [survey.type, tabs]);
@@ -92,7 +101,7 @@ export const ShareEmbedSurvey = ({
}, [open, modalView]); }, [open, modalView]);
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id); setActiveId(survey.type === "link" ? tabs[0].id : tabs[4].id);
setOpen(open); setOpen(open);
if (!open) { if (!open) {
setShowView("start"); setShowView("start");
@@ -100,33 +109,30 @@ export const ShareEmbedSurvey = ({
router.refresh(); router.refresh();
}; };
const handleInitialPageButton = () => {
setShowView("start");
};
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTitle className="sr-only" /> <DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
<DialogContent className="w-full max-w-xl bg-white p-0 md:max-w-3xl lg:h-[700px] lg:max-w-5xl">
{showView === "start" ? ( {showView === "start" ? (
<div className="h-full max-w-full overflow-hidden"> <div className="flex h-full max-w-full flex-col overflow-hidden">
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5"> {survey.type === "link" && (
<DialogTitle> <div className="flex h-2/5 w-full flex-col items-center justify-center space-y-6 p-8 text-center">
<p className="pt-2 text-xl font-semibold text-slate-800"> <DialogTitle>
{t("environments.surveys.summary.your_survey_is_public")} 🎉 <p className="pt-2 text-xl font-semibold text-slate-800">
</p> {t("environments.surveys.summary.your_survey_is_public")} 🎉
</DialogTitle> </p>
<DialogDescription className="hidden" /> </DialogTitle>
<ShareSurveyLink <DialogDescription className="hidden" />
survey={survey} <ShareSurveyLink
surveyUrl={surveyUrl} survey={survey}
publicDomain={publicDomain} surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl} publicDomain={publicDomain}
locale={user.locale} setSurveyUrl={setSurveyUrl}
/> locale={user.locale}
</div> />
<div className="flex h-[300px] flex-col items-center justify-center gap-8 rounded-b-lg bg-slate-50 px-8 lg:h-3/5"> </div>
<p className="-mt-8 text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p> )}
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-b-lg bg-slate-50 px-8">
<p className="text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
<button <button
type="button" type="button"
@@ -164,22 +170,28 @@ export const ShareEmbedSurvey = ({
</div> </div>
</div> </div>
) : showView === "embed" ? ( ) : showView === "embed" ? (
<EmbedView <>
handleInitialPageButton={handleInitialPageButton} <DialogTitle className="sr-only">{t("environments.surveys.summary.embed_survey")}</DialogTitle>
tabs={survey.type === "link" ? tabs : [tabs[3]]} <EmbedView
disableBack={false} tabs={survey.type === "link" ? tabs : [tabs[4]]}
activeId={activeId} activeId={activeId}
environmentId={environmentId} environmentId={environmentId}
setActiveId={setActiveId} setActiveId={setActiveId}
survey={survey} survey={survey}
email={email} email={email}
surveyUrl={surveyUrl} surveyUrl={surveyUrl}
publicDomain={publicDomain} publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl} setSurveyUrl={setSurveyUrl}
locale={user.locale} locale={user.locale}
/> segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
</>
) : showView === "panel" ? ( ) : showView === "panel" ? (
<PanelInfoView handleInitialPageButton={handleInitialPageButton} disableBack={false} /> <>
<DialogTitle className="sr-only">{t("environments.surveys.summary.send_to_panel")}</DialogTitle>
</>
) : null} ) : null}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -20,9 +20,22 @@ vi.mock("@/modules/ui/components/button", () => ({
}), }),
})); }));
// Mock Modal // Mock Dialog
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)), Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, ...props }) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
})); }));
// Mock useTranslate // Mock useTranslate
@@ -120,7 +133,7 @@ describe("ShareSurveyResults", () => {
test("does not render content when modal is closed (open is false)", () => { test("does not render content when modal is closed (open is false)", () => {
render(<ShareSurveyResults {...defaultProps} open={false} />); render(<ShareSurveyResults {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument(); expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
expect( expect(
screen.queryByText("environments.surveys.summary.survey_results_are_public") screen.queryByText("environments.surveys.summary.survey_results_are_public")
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal"; import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react"; import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
import { Clipboard } from "lucide-react"; import { Clipboard } from "lucide-react";
@@ -26,70 +26,72 @@ export const ShareSurveyResults = ({
}: ShareEmbedSurveyProps) => { }: ShareEmbedSurveyProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
return ( return (
<Modal open={open} setOpen={setOpen} size="lg"> <Dialog open={open} onOpenChange={setOpen}>
{showPublishModal && surveyUrl ? ( <DialogContent>
<div className="flex flex-col rounded-2xl bg-white px-12 py-6"> <DialogBody>
<div className="flex flex-col items-center gap-y-6 text-center"> {showPublishModal && surveyUrl ? (
<CheckCircle2Icon className="h-20 w-20 text-slate-300" /> <div className="flex flex-col items-center gap-y-6 text-center">
<div> <CheckCircle2Icon className="text-primary h-20 w-20" />
<p className="text-lg font-medium text-slate-600"> <div>
{t("environments.surveys.summary.survey_results_are_public")} <p className="text-primary text-lg font-medium">
</p> {t("environments.surveys.summary.survey_results_are_public")}
<p className="text-balanced mt-2 text-sm text-slate-500"> </p>
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")} <p className="text-balanced mt-2 text-sm text-slate-500">
</p> {t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
</div> </p>
<div className="flex gap-2"> </div>
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800"> <div className="flex gap-2">
<span>{surveyUrl}</span> <div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span>{surveyUrl}</span>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="secondary"
className="text-center"
onClick={() => handleUnpublish()}>
{t("environments.surveys.summary.unpublish_from_web")}
</Button>
<Button className="text-center" asChild>
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
{t("environments.surveys.summary.view_site")}
</Link>
</Button>
</div> </div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div> </div>
<div className="flex gap-2"> ) : (
<Button <div className="flex flex-col rounded-2xl bg-white p-8">
type="submit" <div className="flex flex-col items-center gap-y-6 text-center">
variant="secondary" <AlertCircleIcon className="h-20 w-20 text-slate-300" />
className="text-center" <div>
onClick={() => handleUnpublish()}> <p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.unpublish_from_web")} {t("environments.surveys.summary.publish_to_web_warning")}
</Button> </p>
<Button className="text-center" asChild> <p className="text-balanced mt-2 text-sm text-slate-500">
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer"> {t("environments.surveys.summary.publish_to_web_warning_description")}
{t("environments.surveys.summary.view_site")} </p>
</Link> </div>
</Button> <Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div> </div>
</div> )}
</div> </DialogBody>
) : ( </DialogContent>
<div className="flex flex-col rounded-2xl bg-white p-8"> </Dialog>
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.publish_to_web_warning")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.publish_to_web_warning_description")}
</p>
</div>
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div>
)}
</Modal>
); );
}; };
@@ -1,5 +1,6 @@
"use client"; "use client";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
@@ -117,13 +118,13 @@ export const SummaryMetadata = ({
)} )}
</span> </span>
{!isLoading && ( {!isLoading && (
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"> <Button variant="secondary" className="h-6 w-6">
{showDropOffs ? ( {showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" /> <ChevronUpIcon className="h-4 w-4" />
) : ( ) : (
<ChevronDownIcon className="h-4 w-4" /> <ChevronDownIcon className="h-4 w-4" />
)} )}
</span> </Button>
)} )}
</div> </div>
</div> </div>
@@ -69,13 +69,13 @@ vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
const mockSearchParams = new URLSearchParams(); const mockSearchParams = new URLSearchParams();
const mockPush = vi.fn(); const mockPush = vi.fn();
const mockReplace = vi.fn();
// Mock next/navigation // Mock next/navigation
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }), useRouter: () => ({ push: mockPush, replace: mockReplace }),
useSearchParams: () => mockSearchParams, useSearchParams: () => mockSearchParams,
usePathname: () => "/current", usePathname: () => "/current-path",
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
})); }));
// Mock copySurveyLink to return a predictable string // Mock copySurveyLink to return a predictable string
@@ -131,280 +131,281 @@ const dummySurvey = {
id: "survey123", id: "survey123",
type: "link", type: "link",
environmentId: "env123", environmentId: "env123",
status: "active", status: "inProgress",
resultShareKey: null,
} as unknown as TSurvey; } as unknown as TSurvey;
const dummyAppSurvey = {
id: "survey123",
type: "app",
environmentId: "env123",
status: "inProgress",
} as unknown as TSurvey;
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment; const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
const dummyUser = { id: "user123", name: "Test User" } as TUser; const dummyUser = { id: "user123", name: "Test User" } as TUser;
describe("SurveyAnalysisCTA - handleCopyLink", () => { describe("SurveyAnalysisCTA", () => {
afterEach(() => {
cleanup();
});
test("calls copySurveyLink and clipboard.writeText on success", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
fireEvent.click(copyButton);
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).toHaveBeenCalledWith("https://public-domain.com/s/survey123?suId=newSingleUseId");
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
test("shows error toast on failure", async () => {
refreshSingleUseIdSpy.mockImplementationOnce(() => Promise.reject(new Error("fail")));
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
fireEvent.click(copyButton);
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_copy_link");
});
});
});
// New tests for squarePenIcon and edit functionality
describe("SurveyAnalysisCTA - Edit functionality", () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
mockSearchParams.delete("share"); // reset params
}); });
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => { describe("Edit functionality", () => {
render( test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
<SurveyAnalysisCTA render(
survey={dummySurvey} <SurveyAnalysisCTA
environment={dummyEnvironment} survey={dummySurvey}
isReadOnly={false} environment={dummyEnvironment}
publicDomain={mockPublicDomain} isReadOnly={false}
user={dummyUser} publicDomain={mockPublicDomain}
responseCount={5} user={dummyUser}
/> responseCount={5}
); />
);
// Find the edit button // Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" }); const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton); await fireEvent.click(editButton);
// Check if dialog is shown // Check if dialog is shown
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey"); const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
expect(dialogTitle).toBeInTheDocument(); expect(dialogTitle).toBeInTheDocument();
});
test("navigates directly to edit page when response count = 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={0}
/>
);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Should navigate directly to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
});
test("doesn't show edit button when isReadOnly is true", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const editButton = screen.queryByRole("button", { name: "common.edit" });
expect(editButton).not.toBeInTheDocument();
});
}); });
test("navigates directly to edit page when response count = 0", async () => { describe("Duplicate functionality", () => {
render( test("duplicates survey and redirects on primary button click", async () => {
<SurveyAnalysisCTA mockCopySurveyToOtherEnvironmentAction.mockResolvedValue({
survey={dummySurvey} data: { id: "newSurvey456" },
environment={dummyEnvironment} });
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={0}
/>
);
// Find the edit button render(
const editButton = screen.getByRole("button", { name: "common.edit" }); <SurveyAnalysisCTA
await fireEvent.click(editButton); survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Should navigate directly to edit page const editButton = screen.getByRole("button", { name: "common.edit" });
expect(mockPush).toHaveBeenCalledWith( fireEvent.click(editButton);
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
); const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
environmentId: "env123",
surveyId: "survey123",
targetEnvironmentId: "env123",
});
expect(mockPush).toHaveBeenCalledWith("/environments/env123/surveys/newSurvey456/edit");
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
});
});
test("shows error toast on duplication failure", async () => {
const error = { error: "Duplication failed" };
mockCopySurveyToOtherEnvironmentAction.mockResolvedValue(error);
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const editButton = screen.getByRole("button", { name: "common.edit" });
fireEvent.click(editButton);
const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Duplication failed");
});
});
}); });
test("doesn't show edit button when isReadOnly is true", () => { describe("Share button and modal", () => {
render( test("opens share modal when 'Share survey' button is clicked", async () => {
<SurveyAnalysisCTA render(
survey={dummySurvey} <SurveyAnalysisCTA
environment={dummyEnvironment} survey={dummySurvey}
isReadOnly={true} environment={dummyEnvironment}
publicDomain={mockPublicDomain} isReadOnly={false}
user={dummyUser} publicDomain={mockPublicDomain}
responseCount={5} user={dummyUser}
/> responseCount={5}
); />
);
// Try to find the edit button (it shouldn't exist) const shareButton = screen.getByText("environments.surveys.summary.share_survey");
const editButton = screen.queryByRole("button", { name: "common.edit" }); fireEvent.click(shareButton);
expect(editButton).not.toBeInTheDocument();
});
});
// Updated test description to mention EditPublicSurveyAlertDialog // The share button opens the embed modal, not a URL
describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertDialog", () => { // We can verify this by checking that the ShareEmbedSurvey component is rendered
afterEach(() => { // with the embed modal open
cleanup(); expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument();
});
test("renders ShareEmbedSurvey component when share modal is open", async () => {
mockSearchParams.set("share", "true");
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Assuming ShareEmbedSurvey renders a dialog with a specific title when open
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeInTheDocument();
});
}); });
test("duplicates survey successfully and navigates to edit page", async () => { describe("General UI and visibility", () => {
// Mock the API response test("shows public results badge when resultShareKey is present", () => {
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({ const surveyWithShareKey = { ...dummySurvey, resultShareKey: "someKey" } as TSurvey;
data: { id: "duplicated-survey-456" }, render(
<SurveyAnalysisCTA
survey={surveyWithShareKey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.getByText("environments.surveys.summary.results_are_public")).toBeInTheDocument();
}); });
render( test("shows SurveyStatusDropdown for non-draft surveys", () => {
<SurveyAnalysisCTA render(
survey={dummySurvey} <SurveyAnalysisCTA
environment={dummyEnvironment} survey={dummySurvey}
isReadOnly={false} environment={dummyEnvironment}
publicDomain={mockPublicDomain} isReadOnly={false}
user={dummyUser} publicDomain={mockPublicDomain}
responseCount={5} user={dummyUser}
/> responseCount={5}
); />
);
// Find and click the edit button to show dialog expect(screen.getByRole("combobox")).toBeInTheDocument();
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Find and click the duplicate button in dialog
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Verify the API was called with correct parameters
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
environmentId: dummyEnvironment.id,
surveyId: dummySurvey.id,
targetEnvironmentId: dummyEnvironment.id,
}); });
// Verify success toast was shown test("does not show SurveyStatusDropdown for draft surveys", () => {
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully"); const draftSurvey = { ...dummySurvey, status: "draft" } as TSurvey;
render(
// Verify navigation to edit page <SurveyAnalysisCTA
expect(mockPush).toHaveBeenCalledWith( survey={draftSurvey}
`/environments/${dummyEnvironment.id}/surveys/duplicated-survey-456/edit` environment={dummyEnvironment}
); isReadOnly={false}
}); publicDomain={mockPublicDomain}
user={dummyUser}
test("shows error toast when duplication fails with error object", async () => { responseCount={5}
// Mock API failure with error object />
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({ );
error: "Test error message", expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
}); });
render( test("hides status dropdown and edit actions when isReadOnly is true", () => {
<SurveyAnalysisCTA render(
survey={dummySurvey} <SurveyAnalysisCTA
environment={dummyEnvironment} survey={dummySurvey}
isReadOnly={false} environment={dummyEnvironment}
publicDomain={mockPublicDomain} isReadOnly={true}
user={dummyUser} publicDomain={mockPublicDomain}
responseCount={5} user={dummyUser}
/> responseCount={5}
); />
);
// Open dialog expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
const editButton = screen.getByRole("button", { name: "common.edit" }); expect(screen.queryByRole("button", { name: "common.edit" })).not.toBeInTheDocument();
await fireEvent.click(editButton);
// Click duplicate
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Verify error toast
expect(toast.error).toHaveBeenCalledWith("Test error message");
});
test("navigates to edit page when cancel button is clicked in dialog", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click edit (cancel) button
const editButtonInDialog = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButtonInDialog);
// Verify navigation
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
});
test("shows loading state when duplicating survey", async () => {
// Create a promise that we can resolve manually
let resolvePromise: (value: any) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
}); });
mockCopySurveyToOtherEnvironmentAction.mockImplementation(() => promise); test("shows preview button for link surveys", () => {
render(
render( <SurveyAnalysisCTA
<SurveyAnalysisCTA survey={dummySurvey}
survey={dummySurvey} environment={dummyEnvironment}
environment={dummyEnvironment} isReadOnly={false}
isReadOnly={false} publicDomain={mockPublicDomain}
publicDomain={mockPublicDomain} user={dummyUser}
user={dummyUser} responseCount={5}
responseCount={5} />
/> );
); expect(screen.getByRole("button", { name: "common.preview" })).toBeInTheDocument();
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click duplicate
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Button should now be in loading state
// expect(duplicateButton).toHaveAttribute("data-state", "loading");
// Resolve the promise
resolvePromise!({
data: { id: "duplicated-survey-456" },
}); });
// Wait for the promise to resolve test("hides preview button for app surveys", () => {
await waitFor(() => { render(
expect(mockPush).toHaveBeenCalled(); <SurveyAnalysisCTA
survey={dummyAppSurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.queryByRole("button", { name: "common.preview" })).not.toBeInTheDocument();
}); });
}); });
}); });
@@ -5,17 +5,17 @@ import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown"; import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog"; import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions"; import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { IconBar } from "@/modules/ui/components/iconbar"; import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { BellRing, Code2Icon, Eye, LinkIcon, SquarePenIcon, UsersRound } from "lucide-react"; import { BellRing, Eye, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
@@ -26,6 +26,9 @@ interface SurveyAnalysisCTAProps {
user: TUser; user: TUser;
publicDomain: string; publicDomain: string;
responseCount: number; responseCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
} }
interface ModalState { interface ModalState {
@@ -42,6 +45,9 @@ export const SurveyAnalysisCTA = ({
user, user,
publicDomain, publicDomain,
responseCount, responseCount,
segments,
isContactsEnabled,
isFormbricksCloud,
}: SurveyAnalysisCTAProps) => { }: SurveyAnalysisCTAProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -57,7 +63,6 @@ export const SurveyAnalysisCTA = ({
}); });
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]); const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted; const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -79,22 +84,6 @@ export const SurveyAnalysisCTA = ({
setModalState((prev) => ({ ...prev, share: open })); setModalState((prev) => ({ ...prev, share: open }));
}; };
const handleCopyLink = () => {
refreshSingleUseId()
.then((newId) => {
const linkToCopy = copySurveyLink(surveyUrl, newId);
return navigator.clipboard.writeText(linkToCopy);
})
.then(() => {
toast.success(t("common.copied_to_clipboard"));
})
.catch((err) => {
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
console.error(err);
});
setModalState((prev) => ({ ...prev, dropdown: false }));
};
const duplicateSurveyAndRoute = async (surveyId: string) => { const duplicateSurveyAndRoute = async (surveyId: string) => {
setLoading(true); setLoading(true);
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({ const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
@@ -134,24 +123,6 @@ export const SurveyAnalysisCTA = ({
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false); const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const iconActions = [ const iconActions = [
{
icon: Eye,
tooltip: t("common.preview"),
onClick: () => window.open(getPreviewUrl(), "_blank"),
isVisible: survey.type === "link",
},
{
icon: LinkIcon,
tooltip: t("common.copy_link"),
onClick: handleCopyLink,
isVisible: survey.type === "link",
},
{
icon: Code2Icon,
tooltip: t("common.embed"),
onClick: () => handleModalState("embed")(true),
isVisible: !isReadOnly,
},
{ {
icon: BellRing, icon: BellRing,
tooltip: t("environments.surveys.summary.configure_alerts"), tooltip: t("environments.surveys.summary.configure_alerts"),
@@ -159,13 +130,10 @@ export const SurveyAnalysisCTA = ({
isVisible: !isReadOnly, isVisible: !isReadOnly,
}, },
{ {
icon: UsersRound, icon: Eye,
tooltip: t("environments.surveys.summary.send_to_panel"), tooltip: t("common.preview"),
onClick: () => { onClick: () => window.open(getPreviewUrl(), "_blank"),
handleModalState("panel")(true); isVisible: survey.type === "link",
setModalState((prev) => ({ ...prev, dropdown: false }));
},
isVisible: !isReadOnly,
}, },
{ {
icon: SquarePenIcon, icon: SquarePenIcon,
@@ -195,6 +163,13 @@ export const SurveyAnalysisCTA = ({
)} )}
<IconBar actions={iconActions} /> <IconBar actions={iconActions} />
<Button
className="h-10"
onClick={() => {
setModalState((prev) => ({ ...prev, embed: true }));
}}>
{t("environments.surveys.summary.share_survey")}
</Button>
{user && ( {user && (
<> <>
@@ -207,6 +182,9 @@ export const SurveyAnalysisCTA = ({
setOpen={setOpen} setOpen={setOpen}
user={user} user={user}
modalView={modalView} modalView={modalView}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/> />
))} ))}
<SuccessMessage environment={environment} survey={survey} /> <SuccessMessage environment={environment} survey={survey} />
@@ -29,6 +29,22 @@ vi.mock("./WebsiteTab", () => ({
), ),
})); }));
vi.mock("./personal-links-tab", () => ({
PersonalLinksTab: (props: { segments: any[]; surveyId: string; environmentId: string }) => (
<div data-testid="personal-links-tab">
PersonalLinksTab Content for {props.surveyId} in {props.environmentId}
</div>
),
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: (props: { title: string; description: string; buttons: any[] }) => (
<div data-testid="upgrade-prompt">
{props.title} - {props.description}
</div>
),
}));
// Mock @tolgee/react // Mock @tolgee/react
vi.mock("@tolgee/react", () => ({ vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ useTranslate: () => ({
@@ -43,6 +59,21 @@ vi.mock("lucide-react", () => ({
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>, LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>, GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>, SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
AlertCircle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-circle">
AlertCircle
</div>
),
AlertTriangle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-triangle">
AlertTriangle
</div>
),
Info: ({ className }: { className?: string }) => (
<div className={className} data-testid="info">
Info
</div>
),
})); }));
const mockTabs = [ const mockTabs = [
@@ -56,7 +87,6 @@ const mockSurveyLink = { id: "survey1", type: "link" };
const mockSurveyWeb = { id: "survey2", type: "web" }; const mockSurveyWeb = { id: "survey2", type: "web" };
const defaultProps = { const defaultProps = {
handleInitialPageButton: vi.fn(),
tabs: mockTabs, tabs: mockTabs,
activeId: "email", activeId: "email",
setActiveId: vi.fn(), setActiveId: vi.fn(),
@@ -67,7 +97,9 @@ const defaultProps = {
publicDomain: "http://example.com", publicDomain: "http://example.com",
setSurveyUrl: vi.fn(), setSurveyUrl: vi.fn(),
locale: "en" as any, locale: "en" as any,
disableBack: false, segments: [],
isContactsEnabled: true,
isFormbricksCloud: false,
}; };
describe("EmbedView", () => { describe("EmbedView", () => {
@@ -76,11 +108,6 @@ describe("EmbedView", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
test("does not render back button when disableBack is true", () => {
render(<EmbedView {...defaultProps} disableBack={true} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
});
test("does not render desktop tabs for non-link survey type", () => { test("does not render desktop tabs for non-link survey type", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />); render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />);
// Desktop tabs container should not be present or not have lg:flex if it's a common parent // Desktop tabs container should not be present or not have lg:flex if it's a common parent
@@ -2,33 +2,32 @@
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react"; import { TSegment } from "@formbricks/types/segment";
import { ArrowLeftIcon } from "lucide-react";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { AppTab } from "./AppTab"; import { AppTab } from "./AppTab";
import { EmailTab } from "./EmailTab"; import { EmailTab } from "./EmailTab";
import { LinkTab } from "./LinkTab"; import { LinkTab } from "./LinkTab";
import { WebsiteTab } from "./WebsiteTab"; import { WebsiteTab } from "./WebsiteTab";
import { PersonalLinksTab } from "./personal-links-tab";
interface EmbedViewProps { interface EmbedViewProps {
handleInitialPageButton: () => void;
tabs: Array<{ id: string; label: string; icon: any }>; tabs: Array<{ id: string; label: string; icon: any }>;
activeId: string; activeId: string;
setActiveId: React.Dispatch<React.SetStateAction<string>>; setActiveId: React.Dispatch<React.SetStateAction<string>>;
environmentId: string; environmentId: string;
disableBack: boolean;
survey: any; survey: any;
email: string; email: string;
surveyUrl: string; surveyUrl: string;
publicDomain: string; publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>; setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
locale: TUserLocale; locale: TUserLocale;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
} }
export const EmbedView = ({ export const EmbedView = ({
handleInitialPageButton,
tabs, tabs,
disableBack,
activeId, activeId,
setActiveId, setActiveId,
environmentId, environmentId,
@@ -38,18 +37,45 @@ export const EmbedView = ({
publicDomain, publicDomain,
setSurveyUrl, setSurveyUrl,
locale, locale,
segments,
isContactsEnabled,
isFormbricksCloud,
}: EmbedViewProps) => { }: EmbedViewProps) => {
const { t } = useTranslate(); const renderActiveTab = () => {
switch (activeId) {
case "email":
return <EmailTab surveyId={survey.id} email={email} />;
case "webpage":
return <WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />;
case "link":
return (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
case "app":
return <AppTab />;
case "personal-links":
return (
<PersonalLinksTab
segments={segments}
surveyId={survey.id}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
);
default:
return null;
}
};
return ( return (
<div className="h-full overflow-hidden"> <div className="h-full overflow-hidden">
{!disableBack && (
<div className="border-b border-slate-200 py-2 pl-2">
<Button variant="ghost" className="focus:ring-0" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-4"> <div className="grid h-full grid-cols-4">
{survey.type === "link" && ( {survey.type === "link" && (
<div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}> <div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}>
@@ -75,21 +101,7 @@ export const EmbedView = ({
)} )}
<div <div
className={`col-span-4 h-full overflow-y-auto bg-slate-50 px-4 py-6 ${survey.type === "link" ? "lg:col-span-3" : ""} lg:p-6`}> className={`col-span-4 h-full overflow-y-auto bg-slate-50 px-4 py-6 ${survey.type === "link" ? "lg:col-span-3" : ""} lg:p-6`}>
{activeId === "email" ? ( {renderActiveTab()}
<EmailTab surveyId={survey.id} email={email} />
) : activeId === "webpage" ? (
<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />
) : activeId === "link" ? (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
) : activeId === "app" ? (
<AppTab />
) : null}
<div className="mt-2 rounded-md p-3 text-center lg:hidden"> <div className="mt-2 rounded-md p-3 text-center lg:hidden">
{tabs.slice(0, 2).map((tab) => ( {tabs.slice(0, 2).map((tab) => (
<Button <Button
@@ -1,108 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PanelInfoView } from "./PanelInfoView";
// Mock next/image
vi.mock("next/image", () => ({
default: ({ src, alt, className }: { src: any; alt: string; className?: string }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src.src} alt={alt} className={className} />
),
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target}>
{children}
</a>
),
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, asChild }: any) => {
if (asChild) {
return <div onClick={onClick}>{children}</div>; // NOSONAR
}
return (
<button onClick={onClick} data-variant={variant}>
{children}
</button>
);
},
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: vi.fn(() => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>),
}));
const mockHandleInitialPageButton = vi.fn();
describe("PanelInfoView", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with back button and all sections", async () => {
render(<PanelInfoView disableBack={false} handleInitialPageButton={mockHandleInitialPageButton} />);
// Check for back button
const backButton = screen.getByText("common.back");
expect(backButton).toBeInTheDocument();
expect(screen.getByTestId("arrow-left-icon")).toBeInTheDocument();
// Check images
expect(screen.getAllByAltText("Prolific panel selection UI")[0]).toBeInTheDocument();
expect(screen.getAllByAltText("Prolific panel selection UI")[1]).toBeInTheDocument();
// Check text content (Tolgee keys)
expect(screen.getByText("environments.surveys.summary.what_is_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_a_panel_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4_description")
).toBeInTheDocument();
// Check "Learn more" link
const learnMoreLink = screen.getByRole("link", { name: "common.learn_more" });
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
// Click back button
await userEvent.click(backButton);
expect(mockHandleInitialPageButton).toHaveBeenCalledTimes(1);
});
test("renders correctly without back button when disableBack is true", () => {
render(<PanelInfoView disableBack={true} handleInitialPageButton={mockHandleInitialPageButton} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
expect(screen.queryByTestId("arrow-left-icon")).not.toBeInTheDocument();
});
});
@@ -1,98 +0,0 @@
"use client";
import ProlificLogo from "@/images/prolific-logo.webp";
import ProlificUI from "@/images/prolific-screenshot.webp";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
interface PanelInfoViewProps {
disableBack: boolean;
handleInitialPageButton: () => void;
}
export const PanelInfoView = ({ disableBack, handleInitialPageButton }: PanelInfoViewProps) => {
const { t } = useTranslate();
return (
<div className="h-full overflow-hidden text-slate-900">
{!disableBack && (
<div className="border-b border-slate-200 py-2">
<Button variant="ghost" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-2">
<div className="flex flex-col gap-y-6 border-r border-slate-200 p-8">
<Image src={ProlificUI} alt="Prolific panel selection UI" className="rounded-lg shadow-lg" />
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_a_panel")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_a_panel_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.when_do_i_need_it")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.when_do_i_need_it_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_prolific")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_prolific_answer")}</p>
</div>
</div>
<div className="relative flex flex-col gap-y-6 bg-slate-50 p-8">
<Image
src={ProlificLogo}
alt="Prolific panel selection UI"
className="absolute right-8 top-8 w-32"
/>
<div>
<h3 className="text-xl font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel")}
</h3>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_1")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_1_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_2")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_2_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_3")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_3_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_4")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_4_description")}
</p>
</div>
<Button className="justify-center" asChild>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
target="_blank">
{t("common.learn_more")}
</Link>
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,519 @@
import { generatePersonalLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PersonalLinksTab } from "./personal-links-tab";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions", () => ({
generatePersonalLinksAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: {
loading: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock UI components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant }: any) => (
<div data-testid="alert" data-variant={variant}>
{children}
</div>
),
AlertButton: ({ children }: any) => <div data-testid="alert-button">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, disabled, loading, className, ...props }: any) => (
<button
data-testid="button"
onClick={onClick}
disabled={disabled}
data-loading={loading}
className={className}
{...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/date-picker", () => ({
DatePicker: ({ date, updateSurveyDate, minDate, onClearDate }: any) => (
<div data-testid="date-picker">
<input
data-testid="date-input"
type="date"
value={date ? date.toISOString().split("T")[0] : ""}
onChange={(e) => {
const newDate = e.target.value ? new Date(e.target.value) : null;
updateSurveyDate(newDate);
}}
/>
<button data-testid="clear-date" onClick={() => onClearDate()}>
Clear
</button>
<div data-testid="min-date">{minDate ? minDate.toISOString() : ""}</div>
</div>
),
}));
vi.mock("@/modules/ui/components/select", () => {
let globalOnValueChange: ((value: string) => void) | null = null;
return {
Select: ({ children, value, onValueChange, disabled }: any) => {
globalOnValueChange = onValueChange;
return (
<div data-testid="select" data-disabled={disabled} data-value={value}>
<div data-testid="select-current-value">{value || "Select option"}</div>
{children}
</div>
);
},
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div
data-testid="select-item"
data-value={value}
onClick={() => {
if (globalOnValueChange) {
globalOnValueChange(value);
}
}}>
{children}
</div>
),
SelectTrigger: ({ children, className }: any) => (
<div data-testid="select-trigger" className={className}>
{children}
</div>
),
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
};
});
// Mock icons
vi.mock("lucide-react", () => ({
DownloadIcon: () => <div data-testid="download-icon" />,
KeyIcon: () => <div data-testid="key-icon" />,
}));
// Mock Next.js Link
vi.mock("next/link", () => ({
default: ({ children, href, target, rel }: any) => (
<a data-testid="link" href={href} target={target} rel={rel}>
{children}
</a>
),
}));
const mockGeneratePersonalLinksAction = vi.mocked(generatePersonalLinksAction);
const mockToast = vi.mocked(toast);
const mockGetFormattedErrorMessage = vi.mocked(getFormattedErrorMessage);
// Mock segments data
const mockSegments = [
{
id: "segment1",
title: "Public Segment 1",
isPrivate: false,
description: "Test segment 1",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
{
id: "segment2",
title: "Public Segment 2",
isPrivate: false,
description: "Test segment 2",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
{
id: "segment3",
title: "Private Segment",
isPrivate: true,
description: "Test private segment",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
];
const defaultProps = {
environmentId: "test-env-id",
surveyId: "test-survey-id",
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
};
// Helper function to trigger select change
const selectOption = (value: string) => {
const selectItems = screen.getAllByTestId("select-item");
const targetItem = selectItems.find((item) => item.getAttribute("data-value") === value);
if (targetItem) {
fireEvent.click(targetItem);
}
};
describe("PersonalLinksTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders the component with correct title and description", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_title")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_description")
).toBeInTheDocument();
});
test("renders recipients section with segment selection", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("common.recipients")).toBeInTheDocument();
expect(screen.getByTestId("select")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.create_and_manage_segments")).toBeInTheDocument();
});
test("renders expiry date section with date picker", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("environments.surveys.summary.expiry_date_optional")).toBeInTheDocument();
expect(screen.getByTestId("date-picker")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.expiry_date_description")).toBeInTheDocument();
});
test("renders generate button with correct initial state", () => {
render(<PersonalLinksTab {...defaultProps} />);
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
expect(screen.getByText("environments.surveys.summary.generate_and_download_links")).toBeInTheDocument();
expect(screen.getByTestId("download-icon")).toBeInTheDocument();
});
test("renders info alert with correct content", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.personal_links_work_with_segments")
).toBeInTheDocument();
expect(screen.getByTestId("link")).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
);
});
test("filters out private segments and shows only public segments", () => {
render(<PersonalLinksTab {...defaultProps} />);
const selectItems = screen.getAllByTestId("select-item");
expect(selectItems).toHaveLength(2); // Only public segments
expect(selectItems[0]).toHaveTextContent("Public Segment 1");
expect(selectItems[1]).toHaveTextContent("Public Segment 2");
});
test("shows no segments message when no public segments available", () => {
const propsWithPrivateSegments = {
...defaultProps,
segments: [mockSegments[2]], // Only private segment
};
render(<PersonalLinksTab {...propsWithPrivateSegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(screen.getByTestId("select")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("button")).toBeDisabled();
});
test("enables button when segment is selected", () => {
render(<PersonalLinksTab {...defaultProps} />);
// Initially disabled
expect(screen.getByTestId("button")).toBeDisabled();
// Select a segment
selectOption("segment1");
// Should now be enabled
expect(screen.getByTestId("button")).not.toBeDisabled();
});
test("handles date selection correctly", () => {
render(<PersonalLinksTab {...defaultProps} />);
const dateInput = screen.getByTestId("date-input");
const testDate = "2024-12-31";
fireEvent.change(dateInput, { target: { value: testDate } });
expect(dateInput).toHaveValue(testDate);
});
test("clears date when clear button is clicked", () => {
render(<PersonalLinksTab {...defaultProps} />);
const dateInput = screen.getByTestId("date-input");
const clearButton = screen.getByTestId("clear-date");
// Set a date first
fireEvent.change(dateInput, { target: { value: "2024-12-31" } });
// Clear the date
fireEvent.click(clearButton);
expect(dateInput).toHaveValue("");
});
test("sets minimum date to tomorrow", () => {
render(<PersonalLinksTab {...defaultProps} />);
const minDateElement = screen.getByTestId("min-date");
// Should have some ISO date string for a future date
expect(minDateElement.textContent).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
test("successfully generates and downloads links", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "personal-links.csv",
count: 5,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Verify action was called
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: undefined,
});
});
// Verify loading toast
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
});
test("generates links with expiry date when date is selected", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "personal-links.csv",
count: 3,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Set expiry date (10 days from now)
const dateInput = screen.getByTestId("date-input");
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
const expiryDate = futureDate.toISOString().split("T")[0];
fireEvent.change(dateInput, { target: { value: expiryDate } });
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: expect.any(Number),
});
});
// Verify that expirationDays is a reasonable value (between 9-10 days)
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(9);
expect(callArgs.expirationDays).toBeLessThanOrEqual(10);
});
test("handles error response from generatePersonalLinksAction", async () => {
const mockErrorResult = {
serverError: "Test error message",
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockErrorResult);
mockGetFormattedErrorMessage.mockReturnValue("Formatted error message");
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Wait for the action to be called
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: undefined,
});
});
// Wait for error handling
await waitFor(() => {
expect(mockGetFormattedErrorMessage).toHaveBeenCalledWith(mockErrorResult);
expect(mockToast.error).toHaveBeenCalledWith("Formatted error message", {
duration: 5000,
id: "generating-links",
});
});
});
test("shows generating state when triggered", async () => {
// Mock a promise that resolves quickly
const mockResult = { data: { downloadUrl: "test", fileName: "test.csv", count: 1 } };
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Verify loading toast is called
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
});
test("button is disabled when no segment is selected", () => {
render(<PersonalLinksTab {...defaultProps} />);
const generateButton = screen.getByTestId("button");
expect(generateButton).toBeDisabled();
});
test("button is disabled when no public segments are available", () => {
const propsWithNoPublicSegments = {
...defaultProps,
segments: [mockSegments[2]], // Only private segments
};
render(<PersonalLinksTab {...propsWithNoPublicSegments} />);
const generateButton = screen.getByTestId("button");
expect(generateButton).toBeDisabled();
});
test("handles empty segments array", () => {
const propsWithEmptySegments = {
...defaultProps,
segments: [],
};
render(<PersonalLinksTab {...propsWithEmptySegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(screen.getByTestId("button")).toBeDisabled();
});
test("calculates expiration days correctly for different dates", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "test.csv",
count: 1,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Set expiry date to 5 days from now
const dateInput = screen.getByTestId("date-input");
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 5);
const expiryDate = futureDate.toISOString().split("T")[0];
fireEvent.change(dateInput, { target: { value: expiryDate } });
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: expect.any(Number),
});
});
// Verify that expirationDays is a reasonable value (between 4-5 days)
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(4);
expect(callArgs.expirationDays).toBeLessThanOrEqual(5);
});
});
@@ -0,0 +1,231 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DatePicker } from "@/modules/ui/components/date-picker";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
import { DownloadIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { TSegment } from "@formbricks/types/segment";
import { generatePersonalLinksAction } from "../../actions";
interface PersonalLinksTabProps {
environmentId: string;
surveyId: string;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
// Custom DatePicker component with date restrictions
const RestrictedDatePicker = ({
date,
updateSurveyDate,
}: {
date: Date | null;
updateSurveyDate: (date: Date | null) => void;
}) => {
// Get tomorrow's date
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const handleDateUpdate = (date: Date) => {
updateSurveyDate(date);
};
return (
<DatePicker
date={date}
updateSurveyDate={handleDateUpdate}
minDate={tomorrow}
onClearDate={() => updateSurveyDate(null)}
/>
);
};
export const PersonalLinksTab = ({
environmentId,
segments,
surveyId,
isContactsEnabled,
isFormbricksCloud,
}: PersonalLinksTabProps) => {
const { t } = useTranslate();
const [selectedSegment, setSelectedSegment] = useState<string>("");
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const publicSegments = segments.filter((segment) => !segment.isPrivate);
// Utility function for file downloads
const downloadFile = (url: string, filename: string) => {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleGenerateLinks = async () => {
if (!selectedSegment || isGenerating) return;
setIsGenerating(true);
// Show initial toast
toast.loading(t("environments.surveys.summary.generating_links_toast"), {
duration: 5000,
id: "generating-links",
});
const result = await generatePersonalLinksAction({
surveyId: surveyId,
segmentId: selectedSegment,
environmentId: environmentId,
expirationDays: expiryDate
? Math.max(1, Math.floor((expiryDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)))
: undefined,
});
if (result?.data) {
downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv");
toast.success(t("environments.surveys.summary.links_generated_success_toast"), {
duration: 5000,
id: "generating-links",
});
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage, {
duration: 5000,
id: "generating-links",
});
}
setIsGenerating(false);
};
// Button state logic
const isButtonDisabled = !selectedSegment || isGenerating || publicSegments.length === 0;
const buttonText = isGenerating
? t("environments.surveys.summary.generating_links")
: t("environments.surveys.summary.generate_and_download_links");
if (!isContactsEnabled) {
return (
<UpgradePrompt
title={t("environments.surveys.summary.personal_links_upgrade_prompt_title")}
description={t("environments.surveys.summary.personal_links_upgrade_prompt_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
]}
/>
);
}
return (
<div className="flex h-full grow flex-col gap-6">
<div>
<h2 className="mb-2 text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.generate_personal_links_title")}
</h2>
<p className="text-sm text-slate-600">
{t("environments.surveys.summary.generate_personal_links_description")}
</p>
</div>
<div className="space-y-6">
{/* Recipients Section */}
<div>
<label htmlFor="segment-select" className="mb-2 block text-sm font-medium text-slate-700">
{t("common.recipients")}
</label>
<Select
value={selectedSegment}
onValueChange={setSelectedSegment}
disabled={publicSegments.length === 0}>
<SelectTrigger id="segment-select" className="w-full bg-white">
<SelectValue
placeholder={
publicSegments.length === 0
? t("environments.surveys.summary.no_segments_available")
: t("environments.surveys.summary.select_segment")
}
/>
</SelectTrigger>
<SelectContent>
{publicSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.title}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.create_and_manage_segments")}
</p>
</div>
{/* Expiry Date Section */}
<div>
<label htmlFor="expiry-date-picker" className="mb-2 block text-sm font-medium text-slate-700">
{t("environments.surveys.summary.expiry_date_optional")}
</label>
<div id="expiry-date-picker">
<RestrictedDatePicker
date={expiryDate}
updateSurveyDate={(date: Date | null) => setExpiryDate(date)}
/>
</div>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.expiry_date_description")}
</p>
</div>
{/* Generate Button */}
<Button
onClick={handleGenerateLinks}
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
<hr />
{/* Info Box */}
<Alert variant="info" size="small">
<AlertTitle>{t("environments.surveys.summary.personal_links_work_with_segments")}</AlertTitle>
<AlertButton>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
target="_blank"
rel="noopener noreferrer">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
</div>
);
};
@@ -101,7 +101,7 @@ const mockProject = {
highlightBorderColor: null, highlightBorderColor: null,
cardBackgroundColor: { light: "#FFFFFF", dark: "#000000" }, cardBackgroundColor: { light: "#FFFFFF", dark: "#000000" },
cardBorderColor: { light: "#FFFFFF", dark: "#000000" }, cardBorderColor: { light: "#FFFFFF", dark: "#000000" },
cardShadowColor: { light: "#FFFFFF", dark: "#000000" },
questionColor: { light: "#FFFFFF", dark: "#000000" }, questionColor: { light: "#FFFFFF", dark: "#000000" },
inputColor: { light: "#FFFFFF", dark: "#000000" }, inputColor: { light: "#FFFFFF", dark: "#000000" },
inputBorderColor: { light: "#FFFFFF", dark: "#000000" }, inputBorderColor: { light: "#FFFFFF", dark: "#000000" },
@@ -123,7 +123,7 @@ const mockComputedStyling = {
inputBorderColor: "#000000", inputBorderColor: "#000000",
cardBackgroundColor: "#FFFFFF", cardBackgroundColor: "#FFFFFF",
cardBorderColor: "#EEEEEE", cardBorderColor: "#EEEEEE",
cardShadowColor: "#AAAAAA",
highlightBorderColor: null, highlightBorderColor: null,
thankYouCardIconColor: "#007BFF", thankYouCardIconColor: "#007BFF",
thankYouCardIconBgColor: "#DDDDDD", thankYouCardIconBgColor: "#DDDDDD",
@@ -2,10 +2,12 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage"; import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary"; import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE } from "@/lib/constants"; import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
@@ -36,6 +38,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration // Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId); const initialSurveySummary = await getSurveySummary(surveyId);
@@ -54,6 +58,9 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
user={user} user={user}
publicDomain={publicDomain} publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0} responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/> />
}> }>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" /> <SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
@@ -274,7 +274,7 @@ describe("getEnvironmentState", () => {
expect(withCache).toHaveBeenCalledWith(expect.any(Function), { expect(withCache).toHaveBeenCalledWith(expect.any(Function), {
key: `fb:env:${environmentId}:state`, key: `fb:env:${environmentId}:state`,
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
}); });
}); });
}); });
@@ -83,9 +83,8 @@ export const getEnvironmentState = async (
{ {
// Use enterprise-grade cache key pattern // Use enterprise-grade cache key pattern
key: createCacheKey.environment.state(environmentId), key: createCacheKey.environment.state(environmentId),
// 30 minutes TTL ensures fresh data for hourly SDK checks // This is a temporary fix for the invalidation issues, will be changed later with a proper solution
// Balances performance with freshness requirements ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
} }
); );
@@ -52,14 +52,6 @@ export const POST = withApiLogging(
} }
const inputValidation = ZActionClassInput.safeParse(actionClassInput); const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!inputValidation.success) { if (!inputValidation.success) {
return { return {
response: responses.badRequestResponse( response: responses.badRequestResponse(
@@ -70,6 +62,14 @@ export const POST = withApiLogging(
}; };
} }
const environmentId = inputValidation.data.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data); const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
auditLog.targetId = actionClass.id; auditLog.targetId = actionClass.id;
auditLog.newObject = actionClass; auditLog.newObject = actionClass;
@@ -186,6 +186,18 @@ describe("Response Lib Tests", () => {
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
}); });
test("should handle RelatedRecordDoesNotExist error with specific message", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Related record does not exist", {
code: "P2025", // PrismaErrorType.RelatedRecordDoesNotExist
clientVersion: "2.0",
});
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
await expect(createResponse(mockResponseInput)).rejects.toThrow("Display ID does not exist");
});
test("should handle generic errors", async () => { test("should handle generic errors", async () => {
const genericError = new Error("Something went wrong"); const genericError = new Error("Something went wrong");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
@@ -12,6 +12,7 @@ import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { TContactAttributes } from "@formbricks/types/contact-attribute";
@@ -176,6 +177,9 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.RelatedRecordDoesNotExist) {
throw new DatabaseError("Display ID does not exist");
}
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
} }
@@ -149,6 +149,10 @@ export const POST = withApiLogging(
return { return {
response: responses.badRequestResponse(error.message), response: responses.badRequestResponse(error.message),
}; };
} else if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
} }
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses"); logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return { return {
@@ -158,7 +162,7 @@ export const POST = withApiLogging(
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
return { return {
response: responses.badRequestResponse(error.message), response: responses.badRequestResponse("An unexpected error occurred while creating the response"),
}; };
} }
throw error; throw error;
+12 -1
View File
@@ -31,6 +31,8 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SENTRY_RELEASE: "mock-sentry-release",
SENTRY_ENVIRONMENT: "mock-sentry-environment",
})); }));
vi.mock("@/tolgee/language", () => ({ vi.mock("@/tolgee/language", () => ({
@@ -59,9 +61,18 @@ vi.mock("@/tolgee/client", () => ({
})); }));
vi.mock("@/app/sentry/SentryProvider", () => ({ vi.mock("@/app/sentry/SentryProvider", () => ({
SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => ( SentryProvider: ({
children,
sentryDsn,
sentryRelease,
}: {
children: React.ReactNode;
sentryDsn?: string;
sentryRelease?: string;
}) => (
<div data-testid="sentry-provider"> <div data-testid="sentry-provider">
SentryProvider: {sentryDsn} SentryProvider: {sentryDsn}
{sentryRelease && ` - Release: ${sentryRelease}`}
{children} {children}
</div> </div>
), ),
+6 -2
View File
@@ -1,5 +1,5 @@
import { SentryProvider } from "@/app/sentry/SentryProvider"; import { SentryProvider } from "@/app/sentry/SentryProvider";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; import { IS_PRODUCTION, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants";
import { TolgeeNextProvider } from "@/tolgee/client"; import { TolgeeNextProvider } from "@/tolgee/client";
import { getLocale } from "@/tolgee/language"; import { getLocale } from "@/tolgee/language";
import { getTolgee } from "@/tolgee/server"; import { getTolgee } from "@/tolgee/server";
@@ -25,7 +25,11 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return ( return (
<html lang={locale} translate="no"> <html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out"> <body className="flex h-dvh flex-col transition-all ease-in-out">
<SentryProvider sentryDsn={SENTRY_DSN} isEnabled={IS_PRODUCTION}> <SentryProvider
sentryDsn={SENTRY_DSN}
sentryRelease={SENTRY_RELEASE}
sentryEnvironment={SENTRY_ENVIRONMENT}
isEnabled={IS_PRODUCTION}>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}> <TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children} {children}
</TolgeeNextProvider> </TolgeeNextProvider>
+6 -6
View File
@@ -41,7 +41,7 @@ describe("Survey Builder", () => {
buttonLabel: { default: "common.next" }, buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" }, backButtonLabel: { default: "common.back" },
shuffleOption: "none", shuffleOption: "none",
required: true, required: false,
}); });
expect(question.choices.length).toBe(3); expect(question.choices.length).toBe(3);
expect(question.id).toBeDefined(); expect(question.id).toBeDefined();
@@ -141,7 +141,7 @@ describe("Survey Builder", () => {
inputType: "text", inputType: "text",
buttonLabel: { default: "common.next" }, buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" }, backButtonLabel: { default: "common.back" },
required: true, required: false,
charLimit: { charLimit: {
enabled: false, enabled: false,
}, },
@@ -204,7 +204,7 @@ describe("Survey Builder", () => {
range: 5, range: 5,
buttonLabel: { default: "common.next" }, buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" }, backButtonLabel: { default: "common.back" },
required: true, required: false,
isColorCodingEnabled: false, isColorCodingEnabled: false,
}); });
expect(question.id).toBeDefined(); expect(question.id).toBeDefined();
@@ -265,7 +265,7 @@ describe("Survey Builder", () => {
headline: { default: "NPS Question" }, headline: { default: "NPS Question" },
buttonLabel: { default: "common.next" }, buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" }, backButtonLabel: { default: "common.back" },
required: true, required: false,
isColorCodingEnabled: false, isColorCodingEnabled: false,
}); });
expect(question.id).toBeDefined(); expect(question.id).toBeDefined();
@@ -324,7 +324,7 @@ describe("Survey Builder", () => {
label: { default: "I agree to terms" }, label: { default: "I agree to terms" },
buttonLabel: { default: "common.next" }, buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" }, backButtonLabel: { default: "common.back" },
required: true, required: false,
}); });
expect(question.id).toBeDefined(); expect(question.id).toBeDefined();
}); });
@@ -377,7 +377,7 @@ describe("Survey Builder", () => {
headline: { default: "CTA Question" }, headline: { default: "CTA Question" },
buttonLabel: { default: "common.next" }, buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" }, backButtonLabel: { default: "common.back" },
required: true, required: false,
buttonExternal: false, buttonExternal: false,
}); });
expect(question.id).toBeDefined(); expect(question.id).toBeDefined();
+6 -6
View File
@@ -66,7 +66,7 @@ export const buildMultipleChoiceQuestion = ({
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []), buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []), backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
shuffleOption: shuffleOption || "none", shuffleOption: shuffleOption || "none",
required: required ?? true, required: required ?? false,
logic, logic,
}; };
}; };
@@ -105,7 +105,7 @@ export const buildOpenTextQuestion = ({
headline: createI18nString(headline, []), headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []), buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []), backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true, required: required ?? false,
longAnswer, longAnswer,
logic, logic,
charLimit: { charLimit: {
@@ -153,7 +153,7 @@ export const buildRatingQuestion = ({
range, range,
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []), buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []), backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true, required: required ?? false,
isColorCodingEnabled, isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined, lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined, upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
@@ -194,7 +194,7 @@ export const buildNPSQuestion = ({
headline: createI18nString(headline, []), headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []), buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []), backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true, required: required ?? false,
isColorCodingEnabled, isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined, lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined, upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
@@ -230,7 +230,7 @@ export const buildConsentQuestion = ({
headline: createI18nString(headline, []), headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []), buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []), backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true, required: required ?? false,
label: createI18nString(label, []), label: createI18nString(label, []),
logic, logic,
}; };
@@ -269,7 +269,7 @@ export const buildCTAQuestion = ({
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []), buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []), backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined, dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? true, required: required ?? false,
buttonExternal, buttonExternal,
buttonUrl, buttonUrl,
logic, logic,
+7 -7
View File
@@ -8,7 +8,7 @@ import { TUser } from "@formbricks/types/user";
import Page from "./page"; import Page from "./page";
vi.mock("@/lib/project/service", () => ({ vi.mock("@/lib/project/service", () => ({
getProjectEnvironmentsByOrganizationIds: vi.fn(), getUserProjectEnvironmentsByOrganizationIds: vi.fn(),
})); }));
vi.mock("@/lib/instance/service", () => ({ vi.mock("@/lib/instance/service", () => ({
@@ -152,7 +152,7 @@ describe("Page", () => {
const { getIsFreshInstance } = await import("@/lib/instance/service"); const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service"); const { getUser } = await import("@/lib/user/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service"); const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils"); const { getAccessFlags } = await import("@/lib/membership/utils");
const { redirect } = await import("next/navigation"); const { redirect } = await import("next/navigation");
@@ -220,7 +220,7 @@ describe("Page", () => {
} as any); } as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false); vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser); vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue( vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
mockUserProjects as unknown as TProject[] mockUserProjects as unknown as TProject[]
); );
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
@@ -241,7 +241,7 @@ describe("Page", () => {
const { getServerSession } = await import("next-auth"); const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service"); const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service"); const { getUser } = await import("@/lib/user/service");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service"); const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils"); const { getAccessFlags } = await import("@/lib/membership/utils");
@@ -310,7 +310,7 @@ describe("Page", () => {
} as any); } as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false); vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser); vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue( vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
mockUserProjects as unknown as TProject[] mockUserProjects as unknown as TProject[]
); );
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
@@ -334,7 +334,7 @@ describe("Page", () => {
const { getOrganizationsByUserId } = await import("@/lib/organization/service"); const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils"); const { getAccessFlags } = await import("@/lib/membership/utils");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { render } = await import("@testing-library/react"); const { render } = await import("@testing-library/react");
const mockUser: TUser = { const mockUser: TUser = {
@@ -432,7 +432,7 @@ describe("Page", () => {
vi.mocked(getUser).mockResolvedValue(mockUser); vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects); vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects);
vi.mocked(getAccessFlags).mockReturnValue({ vi.mocked(getAccessFlags).mockReturnValue({
isManager: false, isManager: false,
isOwner: false, isOwner: false,
+5 -2
View File
@@ -3,7 +3,7 @@ import { getIsFreshInstance } from "@/lib/instance/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationsByUserId } from "@/lib/organization/service"; import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getProjectEnvironmentsByOrganizationIds } from "@/lib/project/service"; import { getUserProjectEnvironmentsByOrganizationIds } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout"; import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -34,7 +34,10 @@ const Page = async () => {
return redirect("/setup/organization/create"); return redirect("/setup/organization/create");
} }
const projectsByOrg = await getProjectEnvironmentsByOrganizationIds(userOrganizations.map((org) => org.id)); const projectsByOrg = await getUserProjectEnvironmentsByOrganizationIds(
userOrganizations.map((org) => org.id),
user.id
);
// Flatten all environments from all projects across all organizations // Flatten all environments from all projects across all organizations
const allEnvironments = projectsByOrg.flatMap((project) => project.environments); const allEnvironments = projectsByOrg.flatMap((project) => project.environments);
@@ -48,6 +48,24 @@ describe("SentryProvider", () => {
); );
}); });
test("calls Sentry.init with sentryRelease when provided", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
const testRelease = "v1.2.3";
render(
<SentryProvider sentryDsn={sentryDsn} sentryRelease={testRelease} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(initSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: sentryDsn,
release: testRelease,
})
);
});
test("does not call Sentry.init when sentryDsn is not provided", () => { test("does not call Sentry.init when sentryDsn is not provided", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
+11 -1
View File
@@ -6,14 +6,24 @@ import { useEffect } from "react";
interface SentryProviderProps { interface SentryProviderProps {
children: React.ReactNode; children: React.ReactNode;
sentryDsn?: string; sentryDsn?: string;
sentryRelease?: string;
sentryEnvironment?: string;
isEnabled?: boolean; isEnabled?: boolean;
} }
export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProviderProps) => { export const SentryProvider = ({
children,
sentryDsn,
sentryRelease,
sentryEnvironment,
isEnabled,
}: SentryProviderProps) => {
useEffect(() => { useEffect(() => {
if (sentryDsn && isEnabled) { if (sentryDsn && isEnabled) {
Sentry.init({ Sentry.init({
dsn: sentryDsn, dsn: sentryDsn,
release: sentryRelease,
environment: sentryEnvironment,
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737 // No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 0, tracesSampleRate: 0,
+20 -2
View File
@@ -233,8 +233,8 @@ export enum STRIPE_PROJECT_NAMES {
} }
export enum STRIPE_PRICE_LOOKUP_KEYS { export enum STRIPE_PRICE_LOOKUP_KEYS {
STARTUP_MONTHLY = "formbricks_startup_monthly", STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
STARTUP_YEARLY = "formbricks_startup_yearly", STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
SCALE_MONTHLY = "formbricks_scale_monthly", SCALE_MONTHLY = "formbricks_scale_monthly",
SCALE_YEARLY = "formbricks_scale_yearly", SCALE_YEARLY = "formbricks_scale_yearly",
} }
@@ -273,6 +273,24 @@ export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY;
export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY; export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY;
export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY); export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY);
// Use the app version for Sentry release (updated during build in production)
// Fallback to environment variable if package.json is not accessible
export const SENTRY_RELEASE = (() => {
if (process.env.NODE_ENV !== "production") {
return undefined;
}
// Try to read from package.json with proper error handling
try {
const pkg = require("../package.json");
return pkg.version === "0.0.0" ? undefined : `v${pkg.version}`;
} catch {
// If package.json can't be read (e.g., in some deployment scenarios),
// return undefined and let Sentry work without release tracking
return undefined;
}
})();
export const SENTRY_ENVIRONMENT = env.SENTRY_ENVIRONMENT;
export const SENTRY_DSN = env.SENTRY_DSN; export const SENTRY_DSN = env.SENTRY_DSN;
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1"; export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
+2
View File
@@ -127,6 +127,7 @@ export const env = createEnv({
.string() .string()
.transform((val) => parseInt(val)) .transform((val) => parseInt(val))
.optional(), .optional(),
SENTRY_ENVIRONMENT: z.string().optional(),
}, },
/* /*
@@ -225,5 +226,6 @@ export const env = createEnv({
AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED, AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED,
AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP, AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP,
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE, SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
}, },
}); });
+2 -1
View File
@@ -65,7 +65,8 @@ export const validateSingleFile = (
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension); return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
}; };
export const validateFileUploads = (data: TResponseData, questions?: TSurveyQuestion[]): boolean => { export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQuestion[]): boolean => {
if (!data) return true;
for (const key of Object.keys(data)) { for (const key of Object.keys(data)) {
const question = questions?.find((q) => q.id === key); const question = questions?.find((q) => q.id === key);
if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue; if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue;
+73 -1
View File
@@ -1,9 +1,16 @@
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants"; import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { updateUser } from "@/lib/user/service";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { createOrganization, getOrganization, getOrganizationsByUserId, updateOrganization } from "./service"; import {
createOrganization,
getOrganization,
getOrganizationsByUserId,
subscribeOrganizationMembersToSurveyResponses,
updateOrganization,
} from "./service";
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
@@ -13,9 +20,16 @@ vi.mock("@formbricks/database", () => ({
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
}, },
user: {
findUnique: vi.fn(),
},
}, },
})); }));
vi.mock("@/lib/user/service", () => ({
updateUser: vi.fn(),
}));
describe("Organization Service", () => { describe("Organization Service", () => {
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -252,4 +266,62 @@ describe("Organization Service", () => {
}); });
}); });
}); });
describe("subscribeOrganizationMembersToSurveyResponses", () => {
test("should subscribe user to survey responses when not unsubscribed", async () => {
const mockUser = {
id: "user-123",
notificationSettings: {
alert: { "existing-survey-id": true },
weeklySummary: {},
unsubscribedOrganizationIds: [], // User is subscribed to all organizations
},
} as any;
const surveyId = "survey-123";
const userId = "user-123";
const organizationId = "org-123";
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
vi.mocked(updateUser).mockResolvedValueOnce({} as any);
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId, organizationId);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: userId },
});
expect(updateUser).toHaveBeenCalledWith(userId, {
notificationSettings: {
alert: {
"existing-survey-id": true,
"survey-123": true,
},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
});
});
test("should not subscribe user when unsubscribed from organization", async () => {
const mockUser = {
id: "user-123",
notificationSettings: {
alert: { "existing-survey-id": true },
weeklySummary: {},
unsubscribedOrganizationIds: ["org-123"], // User has unsubscribed from this organization
},
} as any;
const surveyId = "survey-123";
const userId = "user-123";
const organizationId = "org-123";
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId, organizationId);
// Should not call updateUser because user is unsubscribed from this organization
expect(updateUser).not.toHaveBeenCalled();
});
});
}); });
+145 -13
View File
@@ -7,8 +7,8 @@ import { ITEMS_PER_PAGE } from "../constants";
import { import {
getProject, getProject,
getProjectByEnvironmentId, getProjectByEnvironmentId,
getProjectEnvironmentsByOrganizationIds,
getProjects, getProjects,
getUserProjectEnvironmentsByOrganizationIds,
getUserProjects, getUserProjects,
} from "./service"; } from "./service";
@@ -21,6 +21,7 @@ vi.mock("@formbricks/database", () => ({
}, },
membership: { membership: {
findFirst: vi.fn(), findFirst: vi.fn(),
findMany: vi.fn(),
}, },
}, },
})); }));
@@ -488,6 +489,7 @@ describe("Project Service", () => {
test("getProjectsByOrganizationIds should return projects for given organization IDs", async () => { test("getProjectsByOrganizationIds should return projects for given organization IDs", async () => {
const organizationId1 = createId(); const organizationId1 = createId();
const organizationId2 = createId(); const organizationId2 = createId();
const userId = createId();
const mockProjects = [ const mockProjects = [
{ {
environments: [], environments: [],
@@ -497,16 +499,34 @@ describe("Project Service", () => {
}, },
]; ];
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
{
userId,
organizationId: organizationId2,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any); vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]); const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual(mockProjects); expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({ expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { where: {
organizationId: { OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }],
in: [organizationId1, organizationId2],
},
}, },
select: { environments: true }, select: { environments: true },
}); });
@@ -515,17 +535,36 @@ describe("Project Service", () => {
test("getProjectsByOrganizationIds should return empty array when no projects are found", async () => { test("getProjectsByOrganizationIds should return empty array when no projects are found", async () => {
const organizationId1 = createId(); const organizationId1 = createId();
const organizationId2 = createId(); const organizationId2 = createId();
const userId = createId();
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
{
userId,
organizationId: organizationId2,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockResolvedValue([]); vi.mocked(prisma.project.findMany).mockResolvedValue([]);
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]); const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual([]); expect(result).toEqual([]);
expect(prisma.project.findMany).toHaveBeenCalledWith({ expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { where: {
organizationId: { OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }],
in: [organizationId1, organizationId2],
},
}, },
select: { environments: true }, select: { environments: true },
}); });
@@ -534,18 +573,111 @@ describe("Project Service", () => {
test("getProjectsByOrganizationIds should throw DatabaseError when prisma throws", async () => { test("getProjectsByOrganizationIds should throw DatabaseError when prisma throws", async () => {
const organizationId1 = createId(); const organizationId1 = createId();
const organizationId2 = createId(); const organizationId2 = createId();
const userId = createId();
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002", code: "P2002",
clientVersion: "5.0.0", clientVersion: "5.0.0",
}); });
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError); vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
await expect(getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2])).rejects.toThrow( await expect(
DatabaseError getUserProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2], userId)
); ).rejects.toThrow(DatabaseError);
}); });
test("getProjectsByOrganizationIds should throw ValidationError with wrong input", async () => { test("getProjectsByOrganizationIds should throw ValidationError with wrong input", async () => {
await expect(getProjectEnvironmentsByOrganizationIds(["wrong-id"])).rejects.toThrow(ValidationError); const userId = createId();
await expect(getUserProjectEnvironmentsByOrganizationIds(["wrong-id"], userId)).rejects.toThrow(
ValidationError
);
});
test("getProjectsByOrganizationIds should return empty array when user has no memberships", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
// Mock no memberships found
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual([]);
expect(prisma.membership.findMany).toHaveBeenCalledWith({
where: {
userId,
organizationId: {
in: [organizationId1, organizationId2],
},
},
});
// Should not call project.findMany when no memberships
expect(prisma.project.findMany).not.toHaveBeenCalled();
});
test("getProjectsByOrganizationIds should handle member role with team access", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
const mockProjects = [
{
environments: [],
},
];
// Mock membership where user is a member
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "member" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
OR: [
{
organizationId: organizationId1,
projectTeams: {
some: {
team: {
teamUsers: {
some: {
userId,
},
},
},
},
},
},
],
},
select: { environments: true },
});
}); });
}); });
+40 -4
View File
@@ -171,20 +171,56 @@ export const getOrganizationProjectsCount = reactCache(async (organizationId: st
} }
}); });
export const getProjectEnvironmentsByOrganizationIds = reactCache( export const getUserProjectEnvironmentsByOrganizationIds = reactCache(
async (organizationIds: string[]): Promise<Pick<TProject, "environments">[]> => { async (organizationIds: string[], userId: string): Promise<Pick<TProject, "environments">[]> => {
validateInputs([organizationIds, ZId.array()]); validateInputs([organizationIds, ZId.array()], [userId, ZId]);
try { try {
if (organizationIds.length === 0) { if (organizationIds.length === 0) {
return []; return [];
} }
const projects = await prisma.project.findMany({ const memberships = await prisma.membership.findMany({
where: { where: {
userId,
organizationId: { organizationId: {
in: organizationIds, in: organizationIds,
}, },
}, },
});
if (memberships.length === 0) {
return [];
}
const whereConditions: Prisma.ProjectWhereInput[] = memberships.map((membership) => {
let projectWhereClause: Prisma.ProjectWhereInput = {
organizationId: membership.organizationId,
};
if (membership.role === "member") {
projectWhereClause = {
...projectWhereClause,
projectTeams: {
some: {
team: {
teamUsers: {
some: {
userId,
},
},
},
},
},
};
}
return projectWhereClause;
});
const projects = await prisma.project.findMany({
where: {
OR: whereConditions,
},
select: { environments: true }, select: { environments: true },
}); });
-4
View File
@@ -8,7 +8,6 @@ export const COLOR_DEFAULTS = {
inputBorderColor: "#cbd5e1", inputBorderColor: "#cbd5e1",
cardBackgroundColor: "#ffffff", cardBackgroundColor: "#ffffff",
cardBorderColor: "#f8fafc", cardBorderColor: "#f8fafc",
cardShadowColor: "#000000",
highlightBorderColor: "#64748b", highlightBorderColor: "#64748b",
} as const; } as const;
@@ -32,9 +31,6 @@ export const defaultStyling: TProjectStyling = {
cardBorderColor: { cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor, light: COLOR_DEFAULTS.cardBorderColor,
}, },
cardShadowColor: {
light: COLOR_DEFAULTS.cardShadowColor,
},
isLogoHidden: false, isLogoHidden: false,
highlightBorderColor: undefined, highlightBorderColor: undefined,
isDarkModeEnabled: false, isDarkModeEnabled: false,
+29 -1
View File
@@ -1,5 +1,8 @@
import { TagError } from "@/modules/projects/settings/types/tag";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { createTag, getTag, getTagsByEnvironmentId } from "./service"; import { createTag, getTag, getTagsByEnvironmentId } from "./service";
@@ -110,7 +113,7 @@ describe("Tag Service", () => {
vi.mocked(prisma.tag.create).mockResolvedValue(mockTag); vi.mocked(prisma.tag.create).mockResolvedValue(mockTag);
const result = await createTag("env1", "New Tag"); const result = await createTag("env1", "New Tag");
expect(result).toEqual(mockTag); expect(result).toEqual({ ok: true, data: mockTag });
expect(prisma.tag.create).toHaveBeenCalledWith({ expect(prisma.tag.create).toHaveBeenCalledWith({
data: { data: {
name: "New Tag", name: "New Tag",
@@ -118,5 +121,30 @@ describe("Tag Service", () => {
}, },
}); });
}); });
test("should handle duplicate tag name error", async () => {
// const duplicateError = new Error("Unique constraint failed");
// (duplicateError as any).code = "P2002";
const duplicateError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "4.0.0",
});
vi.mocked(prisma.tag.create).mockRejectedValue(duplicateError);
const result = await createTag("env1", "Duplicate Tag");
expect(result).toEqual({
ok: false,
error: { message: "Tag with this name already exists", code: TagError.TAG_NAME_ALREADY_EXISTS },
});
});
test("should handle general database errors", async () => {
const generalError = new Error("Database connection failed");
vi.mocked(prisma.tag.create).mockRejectedValue(generalError);
const result = await createTag("env1", "New Tag");
expect(result).toStrictEqual({
ok: false,
error: { message: "Database connection failed", code: TagError.UNEXPECTED_ERROR },
});
});
}); });
}); });
+21 -3
View File
@@ -1,7 +1,11 @@
import "server-only"; import "server-only";
import { TagError } from "@/modules/projects/settings/types/tag";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { ITEMS_PER_PAGE } from "../constants"; import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate"; import { validateInputs } from "../utils/validate";
@@ -42,7 +46,10 @@ export const getTag = reactCache(async (id: string): Promise<TTag | null> => {
} }
}); });
export const createTag = async (environmentId: string, name: string): Promise<TTag> => { export const createTag = async (
environmentId: string,
name: string
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
validateInputs([environmentId, ZId], [name, ZString]); validateInputs([environmentId, ZId], [name, ZString]);
try { try {
@@ -53,8 +60,19 @@ export const createTag = async (environmentId: string, name: string): Promise<TT
}, },
}); });
return tag; return ok(tag);
} catch (error) { } catch (error) {
throw error; if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
code: TagError.TAG_NAME_ALREADY_EXISTS,
message: "Tag with this name already exists",
});
}
}
return err({
code: TagError.UNEXPECTED_ERROR,
message: error.message,
});
} }
}; };
+44 -30
View File
@@ -34,7 +34,8 @@
"text": "Du kannst Dich jetzt mit deinem neuen Passwort einloggen" "text": "Du kannst Dich jetzt mit deinem neuen Passwort einloggen"
} }
}, },
"reset_password": "Passwort zurücksetzen" "reset_password": "Passwort zurücksetzen",
"reset_password_description": "Du wirst abgemeldet, um dein Passwort zurückzusetzen."
}, },
"invite": { "invite": {
"create_account": "Konto erstellen", "create_account": "Konto erstellen",
@@ -107,6 +108,10 @@
"thanks_for_upgrading": "Vielen Dank, dass Du dein Formbricks-Abonnement aktualisiert hast.", "thanks_for_upgrading": "Vielen Dank, dass Du dein Formbricks-Abonnement aktualisiert hast.",
"upgrade_successful": "Upgrade erfolgreich" "upgrade_successful": "Upgrade erfolgreich"
}, },
"c": {
"link_expired": "Dein Link ist abgelaufen.",
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
},
"common": { "common": {
"accepted": "Akzeptiert", "accepted": "Akzeptiert",
"account": "Konto", "account": "Konto",
@@ -191,7 +196,6 @@
"e_commerce": "E-Commerce", "e_commerce": "E-Commerce",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"email": "E-Mail", "email": "E-Mail",
"embed": "Einbetten",
"enterprise_license": "Enterprise Lizenz", "enterprise_license": "Enterprise Lizenz",
"environment_not_found": "Umgebung nicht gefunden", "environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.", "environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
@@ -309,14 +313,15 @@
"project_not_found": "Projekt nicht gefunden", "project_not_found": "Projekt nicht gefunden",
"project_permission_not_found": "Projekt-Berechtigung nicht gefunden", "project_permission_not_found": "Projekt-Berechtigung nicht gefunden",
"projects": "Projekte", "projects": "Projekte",
"projects_limit_reached": "Projektlimit erreicht",
"question": "Frage", "question": "Frage",
"question_id": "Frage-ID", "question_id": "Frage-ID",
"questions": "Fragen", "questions": "Fragen",
"read_docs": "Dokumentation lesen", "read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"remove": "Entfernen", "remove": "Entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden", "reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden", "report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
"request_trial_license": "Testlizenz anfordern", "request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen", "reset_to_default": "Auf Standard zurücksetzen",
"response": "Antwort", "response": "Antwort",
@@ -597,6 +602,7 @@
"contact_not_found": "Kein solcher Kontakt gefunden", "contact_not_found": "Kein solcher Kontakt gefunden",
"contacts_table_refresh": "Kontakte aktualisieren", "contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert", "contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
"first_name": "Vorname", "first_name": "Vorname",
"last_name": "Nachname", "last_name": "Nachname",
"no_responses_found": "Keine Antworten gefunden", "no_responses_found": "Keine Antworten gefunden",
@@ -633,6 +639,7 @@
"airtable_integration": "Airtable Integration", "airtable_integration": "Airtable Integration",
"airtable_integration_description": "Synchronisiere Antworten direkt mit Airtable.", "airtable_integration_description": "Synchronisiere Antworten direkt mit Airtable.",
"airtable_integration_is_not_configured": "Airtable Integration ist nicht konfiguriert", "airtable_integration_is_not_configured": "Airtable Integration ist nicht konfiguriert",
"airtable_logo": "Airtable-Logo",
"connect_with_airtable": "Mit Airtable verbinden", "connect_with_airtable": "Mit Airtable verbinden",
"link_airtable_table": "Airtable Tabelle verknüpfen", "link_airtable_table": "Airtable Tabelle verknüpfen",
"link_new_table": "Neue Tabelle verknüpfen", "link_new_table": "Neue Tabelle verknüpfen",
@@ -722,6 +729,7 @@
"slack_integration": "Slack Integration", "slack_integration": "Slack Integration",
"slack_integration_description": "Sende Antworten direkt an Slack.", "slack_integration_description": "Sende Antworten direkt an Slack.",
"slack_integration_is_not_configured": "Slack Integration ist in deiner Instanz von Formbricks nicht konfiguriert.", "slack_integration_is_not_configured": "Slack Integration ist in deiner Instanz von Formbricks nicht konfiguriert.",
"slack_logo": "Slack-Logo",
"slack_reconnect_button": "Erneut verbinden", "slack_reconnect_button": "Erneut verbinden",
"slack_reconnect_button_description": "<b>Hinweis:</b> Wir haben kürzlich unsere Slack-Integration geändert, um auch private Kanäle zu unterstützen. Bitte verbinden Sie Ihren Slack-Workspace erneut." "slack_reconnect_button_description": "<b>Hinweis:</b> Wir haben kürzlich unsere Slack-Integration geändert, um auch private Kanäle zu unterstützen. Bitte verbinden Sie Ihren Slack-Workspace erneut."
}, },
@@ -906,8 +914,7 @@
"tag_already_exists": "Tag existiert bereits", "tag_already_exists": "Tag existiert bereits",
"tag_deleted": "Tag gelöscht", "tag_deleted": "Tag gelöscht",
"tag_updated": "Tag aktualisiert", "tag_updated": "Tag aktualisiert",
"tags_merged": "Tags zusammengeführt", "tags_merged": "Tags zusammengeführt"
"unique_constraint_failed_on_the_fields": "Eindeutige Einschränkung für die Felder fehlgeschlagen"
}, },
"teams": { "teams": {
"manage_teams": "Teams verwalten", "manage_teams": "Teams verwalten",
@@ -980,63 +987,53 @@
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen" "api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10,000 monatliche Antworten", "1000_monthly_responses": "1,000 monatliche Antworten",
"1500_monthly_responses": "1,500 monatliche Antworten", "1_project": "1 Projekt",
"2000_monthly_identified_users": "2,000 monatlich identifizierte Nutzer", "2000_contacts": "2,000 Kontakte",
"30000_monthly_identified_users": "30,000 monatlich identifizierte Nutzer",
"3_projects": "3 Projekte", "3_projects": "3 Projekte",
"5000_monthly_responses": "5,000 monatliche Antworten", "5000_monthly_responses": "5,000 monatliche Antworten",
"5_projects": "5 Projekte", "7500_contacts": "7,500 Kontakte",
"7500_monthly_identified_users": "7,500 monatlich identifizierte Nutzer",
"advanced_targeting": "Erweitertes Targeting",
"all_integrations": "Alle Integrationen", "all_integrations": "Alle Integrationen",
"all_surveying_features": "Alle Umfragefunktionen",
"annually": "Jährlich", "annually": "Jährlich",
"api_webhooks": "API & Webhooks", "api_webhooks": "API & Webhooks",
"app_surveys": "In-app Umfragen", "app_surveys": "In-app Umfragen",
"contact_us": "Kontaktiere uns", "attribute_based_targeting": "Attributbasiertes Targeting",
"current": "aktuell", "current": "aktuell",
"current_plan": "Aktueller Plan", "current_plan": "Aktueller Plan",
"current_tier_limit": "Aktuelles Limit", "current_tier_limit": "Aktuelles Limit",
"custom_miu_limit": "Benutzerdefiniertes MIU-Limit", "custom": "Benutzerdefiniert & Skalierung",
"custom_contacts_limit": "Benutzerdefiniertes Kontaktlimit",
"custom_project_limit": "Benutzerdefiniertes Projektlimit", "custom_project_limit": "Benutzerdefiniertes Projektlimit",
"customer_success_manager": "Customer Success Manager", "custom_response_limit": "Benutzerdefiniertes Antwortlimit",
"email_embedded_surveys": "Eingebettete Umfragen in E-Mails", "email_embedded_surveys": "Eingebettete Umfragen in E-Mails",
"email_support": "E-Mail-Support", "email_follow_ups": "E-Mail Follow-ups",
"enterprise": "Enterprise",
"enterprise_description": "Premium-Support und benutzerdefinierte Limits.", "enterprise_description": "Premium-Support und benutzerdefinierte Limits.",
"everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!", "everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!",
"everything_in_free": "Alles in 'Free''", "everything_in_free": "Alles in 'Free''",
"everything_in_scale": "Alles in 'Scale''",
"everything_in_startup": "Alles in 'Startup''", "everything_in_startup": "Alles in 'Startup''",
"free": "Kostenlos", "free": "Kostenlos",
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.", "free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
"get_2_months_free": "2 Monate gratis", "get_2_months_free": "2 Monate gratis",
"get_in_touch": "Kontaktiere uns", "get_in_touch": "Kontaktiere uns",
"hosted_in_frankfurt": "Gehostet in Frankfurt",
"ios_android_sdks": "iOS & Android SDK für mobile Umfragen",
"link_surveys": "Umfragen verlinken (teilbar)", "link_surveys": "Umfragen verlinken (teilbar)",
"logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.", "logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.",
"manage_card_details": "Karteninformationen verwalten", "manage_card_details": "Karteninformationen verwalten",
"manage_subscription": "Abonnement verwalten", "manage_subscription": "Abonnement verwalten",
"monthly": "Monatlich", "monthly": "Monatlich",
"monthly_identified_users": "Monatlich identifizierte Nutzer", "monthly_identified_users": "Monatlich identifizierte Nutzer",
"multi_language_surveys": "Mehrsprachige Umfragen",
"per_month": "pro Monat", "per_month": "pro Monat",
"per_year": "pro Jahr", "per_year": "pro Jahr",
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert", "plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
"premium_support_with_slas": "Premium-Support mit SLAs", "premium_support_with_slas": "Premium-Support mit SLAs",
"priority_support": "Priorisierter Support",
"remove_branding": "Branding entfernen", "remove_branding": "Branding entfernen",
"say_hi": "Sag Hi!",
"scale": "Scale",
"scale_description": "Erweiterte Funktionen für größere Unternehmen.",
"startup": "Start-up", "startup": "Start-up",
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.", "startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
"switch_plan": "Plan wechseln", "switch_plan": "Plan wechseln",
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.", "switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
"team_access_roles": "Rollen für Teammitglieder", "team_access_roles": "Rollen für Teammitglieder",
"technical_onboarding": "Technische Einführung",
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden", "unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
"unlimited_apps_websites": "Unbegrenzte Apps & Websites",
"unlimited_miu": "Unbegrenzte MIU", "unlimited_miu": "Unbegrenzte MIU",
"unlimited_projects": "Unbegrenzte Projekte", "unlimited_projects": "Unbegrenzte Projekte",
"unlimited_responses": "Unbegrenzte Antworten", "unlimited_responses": "Unbegrenzte Antworten",
@@ -1075,6 +1072,7 @@
"create_new_organization": "Neue Organisation erstellen", "create_new_organization": "Neue Organisation erstellen",
"create_new_organization_description": "Erstelle eine neue Organisation, um weitere Projekte zu verwalten.", "create_new_organization_description": "Erstelle eine neue Organisation, um weitere Projekte zu verwalten.",
"customize_email_with_a_higher_plan": "E-Mail-Anpassung mit einem höheren Plan", "customize_email_with_a_higher_plan": "E-Mail-Anpassung mit einem höheren Plan",
"delete_member_confirmation": "Gelöschte Mitglieder verlieren den Zugriff auf alle Projekte und Umfragen deiner Organisation.",
"delete_organization": "Organisation löschen", "delete_organization": "Organisation löschen",
"delete_organization_description": "Organisation mit allen Projekten einschließlich aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen", "delete_organization_description": "Organisation mit allen Projekten einschließlich aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen",
"delete_organization_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:", "delete_organization_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:",
@@ -1231,8 +1229,9 @@
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung", "copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen", "copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren", "copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
"copy_survey_success": "Umfrage erfolgreich kopiert!", "copy_survey_success": "Umfrage erfolgreich kopiert!",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.", "delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:", "1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:",
"2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:", "2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:",
@@ -1305,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen", "card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
"card_background_color": "Hintergrundfarbe der Karte", "card_background_color": "Hintergrundfarbe der Karte",
"card_border_color": "Farbe des Kartenrandes", "card_border_color": "Farbe des Kartenrandes",
"card_shadow_color": "Farbton des Kartenschattens",
"card_styling": "Kartenstil", "card_styling": "Kartenstil",
"casual": "Lässig", "casual": "Lässig",
"caution_edit_duplicate": "Duplizieren & bearbeiten", "caution_edit_duplicate": "Duplizieren & bearbeiten",
@@ -1330,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "Markenfarbe der Umfrage ändern.", "change_the_brand_color_of_the_survey": "Markenfarbe der Umfrage ändern.",
"change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.", "change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.",
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.", "change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
"change_the_shadow_color_of_the_card": "Schattenfarbe der Karte ändern.",
"changes_saved": "Änderungen gespeichert.", "changes_saved": "Änderungen gespeichert.",
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.", "character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu", "character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
@@ -1717,6 +1714,7 @@
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.", "congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.", "connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
"copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren", "copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren",
"create_and_manage_segments": "Erstellen und verwalten Sie Ihre Segmente unter Kontakte > Segmente",
"create_single_use_links": "Single-Use Links erstellen", "create_single_use_links": "Single-Use Links erstellen",
"create_single_use_links_description": "Akzeptiere nur eine Antwort pro Link. So geht's.", "create_single_use_links_description": "Akzeptiere nur eine Antwort pro Link. So geht's.",
"custom_range": "Benutzerdefinierter Bereich...", "custom_range": "Benutzerdefinierter Bereich...",
@@ -1735,12 +1733,19 @@
"embed_on_website": "Auf Website einbetten", "embed_on_website": "Auf Website einbetten",
"embed_pop_up_survey_title": "Wie man eine Pop-up-Umfrage auf seiner Website einbindet", "embed_pop_up_survey_title": "Wie man eine Pop-up-Umfrage auf seiner Website einbindet",
"embed_survey": "Umfrage einbetten", "embed_survey": "Umfrage einbetten",
"expiry_date_description": "Sobald der Link abläuft, kann der Empfänger nicht mehr auf die Umfrage antworten.",
"expiry_date_optional": "Ablaufdatum (optional)",
"failed_to_copy_link": "Kopieren des Links fehlgeschlagen", "failed_to_copy_link": "Kopieren des Links fehlgeschlagen",
"filter_added_successfully": "Filter erfolgreich hinzugefügt", "filter_added_successfully": "Filter erfolgreich hinzugefügt",
"filter_updated_successfully": "Filter erfolgreich aktualisiert", "filter_updated_successfully": "Filter erfolgreich aktualisiert",
"filtered_responses_csv": "Gefilterte Antworten (CSV)", "filtered_responses_csv": "Gefilterte Antworten (CSV)",
"filtered_responses_excel": "Gefilterte Antworten (Excel)", "filtered_responses_excel": "Gefilterte Antworten (Excel)",
"formbricks_email_survey_preview": "Formbricks E-Mail-Umfrage Vorschau", "formbricks_email_survey_preview": "Formbricks E-Mail-Umfrage Vorschau",
"generate_and_download_links": "Links generieren und herunterladen",
"generate_personal_links_description": "Erstellen Sie persönliche Links für ein Segment und ordnen Sie Umfrageantworten jedem Kontakt zu. Eine CSV-Datei Ihrer persönlichen Links inklusive relevanter Kontaktinformationen wird automatisch heruntergeladen.",
"generate_personal_links_title": "Maximieren Sie Erkenntnisse mit persönlichen Umfragelinks",
"generating_links": "Links werden generiert",
"generating_links_toast": "Links werden generiert, der Download startet in Kürze…",
"go_to_setup_checklist": "Gehe zur Einrichtungs-Checkliste \uD83D\uDC49", "go_to_setup_checklist": "Gehe zur Einrichtungs-Checkliste \uD83D\uDC49",
"hide_embed_code": "Einbettungscode ausblenden", "hide_embed_code": "Einbettungscode ausblenden",
"how_to_create_a_panel": "Wie man ein Panel erstellt", "how_to_create_a_panel": "Wie man ein Panel erstellt",
@@ -1766,12 +1771,18 @@
"last_quarter": "Letztes Quartal", "last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr", "last_year": "Letztes Jahr",
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert", "link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
"links_generated_success_toast": "Links erfolgreich generiert, Ihr Download beginnt in Kürze.",
"make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist", "make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist",
"mobile_app": "Mobile App", "mobile_app": "Mobile App",
"no_responses_found": "Keine Antworten gefunden", "no_responses_found": "Keine Antworten gefunden",
"no_segments_available": "Keine Segmente verfügbar",
"only_completed": "Nur vollständige Antworten", "only_completed": "Nur vollständige Antworten",
"other_values_found": "Andere Werte gefunden", "other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt", "overall": "Insgesamt",
"personal_links": "Persönliche Links",
"personal_links_upgrade_prompt_description": "Erstellen Sie persönliche Links für ein Segment und verknüpfen Sie Umfrageantworten mit jedem Kontakt.",
"personal_links_upgrade_prompt_title": "Verwende persönliche Links mit einem höheren Plan",
"personal_links_work_with_segments": "Persönliche Links funktionieren mit Segmenten.",
"publish_to_web": "Im Web veröffentlichen", "publish_to_web": "Im Web veröffentlichen",
"publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.", "publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.",
"publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.", "publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.",
@@ -1780,6 +1791,7 @@
"quickstart_web_apps": "Schnellstart: Web-Apps", "quickstart_web_apps": "Schnellstart: Web-Apps",
"quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:", "quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:",
"results_are_public": "Ergebnisse sind öffentlich", "results_are_public": "Ergebnisse sind öffentlich",
"select_segment": "Segment auswählen",
"selected_responses_csv": "Ausgewählte Antworten (CSV)", "selected_responses_csv": "Ausgewählte Antworten (CSV)",
"selected_responses_excel": "Ausgewählte Antworten (Excel)", "selected_responses_excel": "Ausgewählte Antworten (Excel)",
"send_preview": "Vorschau senden", "send_preview": "Vorschau senden",
@@ -1787,6 +1799,7 @@
"setup_instructions": "Einrichtung", "setup_instructions": "Einrichtung",
"setup_integrations": "Integrationen einrichten", "setup_integrations": "Integrationen einrichten",
"share_results": "Ergebnisse teilen", "share_results": "Ergebnisse teilen",
"share_survey": "Umfrage teilen",
"share_the_link": "Teile den Link", "share_the_link": "Teile den Link",
"share_the_link_to_get_responses": "Teile den Link, um Antworten einzusammeln", "share_the_link_to_get_responses": "Teile den Link, um Antworten einzusammeln",
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen", "show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
@@ -1804,6 +1817,7 @@
"this_year": "Dieses Jahr", "this_year": "Dieses Jahr",
"time_to_complete": "Zeit zur Fertigstellung", "time_to_complete": "Zeit zur Fertigstellung",
"to_connect_your_website_with_formbricks": "deine Website mit Formbricks zu verbinden", "to_connect_your_website_with_formbricks": "deine Website mit Formbricks zu verbinden",
"to_create_personal_links_segment_required": "Um persönliche Links für Ihre Umfrage zu erstellen, müssen Sie zuerst ein Segment einrichten.",
"ttc_tooltip": "Durchschnittliche Zeit bis zum Abschluss der Umfrage.", "ttc_tooltip": "Durchschnittliche Zeit bis zum Abschluss der Umfrage.",
"unknown_question_type": "Unbekannter Fragetyp", "unknown_question_type": "Unbekannter Fragetyp",
"unpublish_from_web": "Aus dem Web entfernen", "unpublish_from_web": "Aus dem Web entfernen",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "Was, wenn überhaupt, hält Dich heute davon ab, einen Kauf zu tätigen?", "understand_purchase_intention_question_3_headline": "Was, wenn überhaupt, hält Dich heute davon ab, einen Kauf zu tätigen?",
"understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier..." "understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier..."
} }
} }
+44 -30
View File
@@ -34,7 +34,8 @@
"text": "You can now log in with your new password" "text": "You can now log in with your new password"
} }
}, },
"reset_password": "Reset password" "reset_password": "Reset password",
"reset_password_description": "You will be logged out to reset your password."
}, },
"invite": { "invite": {
"create_account": "Create an account", "create_account": "Create an account",
@@ -107,6 +108,10 @@
"thanks_for_upgrading": "Thanks a lot for upgrading your Formbricks subscription.", "thanks_for_upgrading": "Thanks a lot for upgrading your Formbricks subscription.",
"upgrade_successful": "Upgrade successful" "upgrade_successful": "Upgrade successful"
}, },
"c": {
"link_expired": "Your link is expired.",
"link_expired_description": "The link you used is no longer valid."
},
"common": { "common": {
"accepted": "Accepted", "accepted": "Accepted",
"account": "Account", "account": "Account",
@@ -191,7 +196,6 @@
"e_commerce": "E-Commerce", "e_commerce": "E-Commerce",
"edit": "Edit", "edit": "Edit",
"email": "Email", "email": "Email",
"embed": "Embed",
"enterprise_license": "Enterprise License", "enterprise_license": "Enterprise License",
"environment_not_found": "Environment not found", "environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.", "environment_notice": "You're currently in the {environment} environment.",
@@ -309,14 +313,15 @@
"project_not_found": "Project not found", "project_not_found": "Project not found",
"project_permission_not_found": "Project permission not found", "project_permission_not_found": "Project permission not found",
"projects": "Projects", "projects": "Projects",
"projects_limit_reached": "Projects limit reached",
"question": "Question", "question": "Question",
"question_id": "Question ID", "question_id": "Question ID",
"questions": "Questions", "questions": "Questions",
"read_docs": "Read Docs", "read_docs": "Read Docs",
"recipients": "Recipients",
"remove": "Remove", "remove": "Remove",
"reorder_and_hide_columns": "Reorder and hide columns", "reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey", "report_survey": "Report Survey",
"request_pricing": "Request Pricing",
"request_trial_license": "Request trial license", "request_trial_license": "Request trial license",
"reset_to_default": "Reset to default", "reset_to_default": "Reset to default",
"response": "Response", "response": "Response",
@@ -597,6 +602,7 @@
"contact_not_found": "No such contact found", "contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts", "contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully", "contacts_table_refresh_success": "Contacts refreshed successfully",
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
"first_name": "First Name", "first_name": "First Name",
"last_name": "Last Name", "last_name": "Last Name",
"no_responses_found": "No responses found", "no_responses_found": "No responses found",
@@ -633,6 +639,7 @@
"airtable_integration": "Airtable Integration", "airtable_integration": "Airtable Integration",
"airtable_integration_description": "Sync responses directly with Airtable.", "airtable_integration_description": "Sync responses directly with Airtable.",
"airtable_integration_is_not_configured": "Airtable Integration is not configured", "airtable_integration_is_not_configured": "Airtable Integration is not configured",
"airtable_logo": "Airtable logo",
"connect_with_airtable": "Connect with Airtable", "connect_with_airtable": "Connect with Airtable",
"link_airtable_table": "Link Airtable Table", "link_airtable_table": "Link Airtable Table",
"link_new_table": "Link new table", "link_new_table": "Link new table",
@@ -722,6 +729,7 @@
"slack_integration": "Slack Integration", "slack_integration": "Slack Integration",
"slack_integration_description": "Send responses directly to Slack.", "slack_integration_description": "Send responses directly to Slack.",
"slack_integration_is_not_configured": "Slack Integration is not configured in your instance of Formbricks.", "slack_integration_is_not_configured": "Slack Integration is not configured in your instance of Formbricks.",
"slack_logo": "Slack logo",
"slack_reconnect_button": "Reconnect", "slack_reconnect_button": "Reconnect",
"slack_reconnect_button_description": "<b>Note:</b> We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace." "slack_reconnect_button_description": "<b>Note:</b> We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace."
}, },
@@ -906,8 +914,7 @@
"tag_already_exists": "Tag already exists", "tag_already_exists": "Tag already exists",
"tag_deleted": "Tag deleted", "tag_deleted": "Tag deleted",
"tag_updated": "Tag updated", "tag_updated": "Tag updated",
"tags_merged": "Tags merged", "tags_merged": "Tags merged"
"unique_constraint_failed_on_the_fields": "Unique constraint failed on the fields"
}, },
"teams": { "teams": {
"manage_teams": "Manage teams", "manage_teams": "Manage teams",
@@ -980,63 +987,53 @@
"api_keys_description": "Manage API keys to access Formbricks management APIs" "api_keys_description": "Manage API keys to access Formbricks management APIs"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10000 Monthly Responses", "1000_monthly_responses": "Monthly 1,000 Responses",
"1500_monthly_responses": "1500 Monthly Responses", "1_project": "1 Project",
"2000_monthly_identified_users": "2000 Monthly Identified Users", "2000_contacts": "2,000 Contacts",
"30000_monthly_identified_users": "30000 Monthly Identified Users",
"3_projects": "3 Projects", "3_projects": "3 Projects",
"5000_monthly_responses": "5,000 Monthly Responses", "5000_monthly_responses": "5,000 Monthly Responses",
"5_projects": "5 Projects", "7500_contacts": "7,500 Contacts",
"7500_monthly_identified_users": "7500 Monthly Identified Users",
"advanced_targeting": "Advanced Targeting",
"all_integrations": "All Integrations", "all_integrations": "All Integrations",
"all_surveying_features": "All surveying features",
"annually": "Annually", "annually": "Annually",
"api_webhooks": "API & Webhooks", "api_webhooks": "API & Webhooks",
"app_surveys": "App Surveys", "app_surveys": "App Surveys",
"contact_us": "Contact Us", "attribute_based_targeting": "Attribute-based Targeting",
"current": "Current", "current": "Current",
"current_plan": "Current Plan", "current_plan": "Current Plan",
"current_tier_limit": "Current Tier Limit", "current_tier_limit": "Current Tier Limit",
"custom_miu_limit": "Custom MIU limit", "custom": "Custom & Scale",
"custom_contacts_limit": "Custom Contacts Limit",
"custom_project_limit": "Custom Project Limit", "custom_project_limit": "Custom Project Limit",
"customer_success_manager": "Customer Success Manager", "custom_response_limit": "Custom Response Limit",
"email_embedded_surveys": "Email Embedded Surveys", "email_embedded_surveys": "Email Embedded Surveys",
"email_support": "Email Support", "email_follow_ups": "Email Follow-ups",
"enterprise": "Enterprise",
"enterprise_description": "Premium support and custom limits.", "enterprise_description": "Premium support and custom limits.",
"everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!", "everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!",
"everything_in_free": "Everything in Free", "everything_in_free": "Everything in Free",
"everything_in_scale": "Everything in Scale",
"everything_in_startup": "Everything in Startup", "everything_in_startup": "Everything in Startup",
"free": "Free", "free": "Free",
"free_description": "Unlimited Surveys, Team Members, and more.", "free_description": "Unlimited Surveys, Team Members, and more.",
"get_2_months_free": "Get 2 months free", "get_2_months_free": "Get 2 months free",
"get_in_touch": "Get in touch", "get_in_touch": "Get in touch",
"hosted_in_frankfurt": "Hosted in Frankfurt",
"ios_android_sdks": "iOS & Android SDK for mobile surveys",
"link_surveys": "Link Surveys (Shareable)", "link_surveys": "Link Surveys (Shareable)",
"logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.", "logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.",
"manage_card_details": "Manage Card Details", "manage_card_details": "Manage Card Details",
"manage_subscription": "Manage Subscription", "manage_subscription": "Manage Subscription",
"monthly": "Monthly", "monthly": "Monthly",
"monthly_identified_users": "Monthly Identified Users", "monthly_identified_users": "Monthly Identified Users",
"multi_language_surveys": "Multi-Language Surveys",
"per_month": "per month", "per_month": "per month",
"per_year": "per year", "per_year": "per year",
"plan_upgraded_successfully": "Plan upgraded successfully", "plan_upgraded_successfully": "Plan upgraded successfully",
"premium_support_with_slas": "Premium support with SLAs", "premium_support_with_slas": "Premium support with SLAs",
"priority_support": "Priority Support",
"remove_branding": "Remove Branding", "remove_branding": "Remove Branding",
"say_hi": "Say Hi!",
"scale": "Scale",
"scale_description": "Advanced features for scaling your business.",
"startup": "Startup", "startup": "Startup",
"startup_description": "Everything in Free with additional features.", "startup_description": "Everything in Free with additional features.",
"switch_plan": "Switch Plan", "switch_plan": "Switch Plan",
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.", "switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
"team_access_roles": "Team Access Roles", "team_access_roles": "Team Access Roles",
"technical_onboarding": "Technical Onboarding",
"unable_to_upgrade_plan": "Unable to upgrade plan", "unable_to_upgrade_plan": "Unable to upgrade plan",
"unlimited_apps_websites": "Unlimited Apps & Websites",
"unlimited_miu": "Unlimited MIU", "unlimited_miu": "Unlimited MIU",
"unlimited_projects": "Unlimited Projects", "unlimited_projects": "Unlimited Projects",
"unlimited_responses": "Unlimited Responses", "unlimited_responses": "Unlimited Responses",
@@ -1075,6 +1072,7 @@
"create_new_organization": "Create new organization", "create_new_organization": "Create new organization",
"create_new_organization_description": "Create a new organization to handle a different set of projects.", "create_new_organization_description": "Create a new organization to handle a different set of projects.",
"customize_email_with_a_higher_plan": "Customize email with a higher plan", "customize_email_with_a_higher_plan": "Customize email with a higher plan",
"delete_member_confirmation": "Deleted members will lose access to all projects and surveys of your organization.",
"delete_organization": "Delete Organization", "delete_organization": "Delete Organization",
"delete_organization_description": "Delete organization with all its projects including all surveys, responses, people, actions and attributes", "delete_organization_description": "Delete organization with all its projects including all surveys, responses, people, actions and attributes",
"delete_organization_warning": "Before you proceed with deleting this organization, please be aware of the following consequences:", "delete_organization_warning": "Before you proceed with deleting this organization, please be aware of the following consequences:",
@@ -1231,8 +1229,9 @@
"copy_survey_description": "Copy this survey to another environment", "copy_survey_description": "Copy this survey to another environment",
"copy_survey_error": "Failed to copy survey", "copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard", "copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
"copy_survey_success": "Survey copied successfully!", "copy_survey_success": "Survey copied successfully!",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses? This action cannot be undone.", "delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:", "1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:",
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:", "2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
@@ -1305,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys", "card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
"card_background_color": "Card background color", "card_background_color": "Card background color",
"card_border_color": "Card border color", "card_border_color": "Card border color",
"card_shadow_color": "Card shadow color",
"card_styling": "Card Styling", "card_styling": "Card Styling",
"casual": "Casual", "casual": "Casual",
"caution_edit_duplicate": "Duplicate & edit", "caution_edit_duplicate": "Duplicate & edit",
@@ -1330,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "Change the brand color of the survey.", "change_the_brand_color_of_the_survey": "Change the brand color of the survey.",
"change_the_placement_of_this_survey": "Change the placement of this survey.", "change_the_placement_of_this_survey": "Change the placement of this survey.",
"change_the_question_color_of_the_survey": "Change the question color of the survey.", "change_the_question_color_of_the_survey": "Change the question color of the survey.",
"change_the_shadow_color_of_the_card": "Change the shadow color of the card.",
"changes_saved": "Changes saved.", "changes_saved": "Changes saved.",
"character_limit_toggle_description": "Limit how short or long an answer can be.", "character_limit_toggle_description": "Limit how short or long an answer can be.",
"character_limit_toggle_title": "Add character limits", "character_limit_toggle_title": "Add character limits",
@@ -1717,6 +1714,7 @@
"congrats": "Congrats! Your survey is live.", "congrats": "Congrats! Your survey is live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.", "connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
"copy_link_to_public_results": "Copy link to public results", "copy_link_to_public_results": "Copy link to public results",
"create_and_manage_segments": "Create and manage your Segments under Contacts > Segments",
"create_single_use_links": "Create single-use links", "create_single_use_links": "Create single-use links",
"create_single_use_links_description": "Accept only one submission per link. Here is how.", "create_single_use_links_description": "Accept only one submission per link. Here is how.",
"custom_range": "Custom range...", "custom_range": "Custom range...",
@@ -1735,12 +1733,19 @@
"embed_on_website": "Embed on website", "embed_on_website": "Embed on website",
"embed_pop_up_survey_title": "How to embed a pop-up survey on your website", "embed_pop_up_survey_title": "How to embed a pop-up survey on your website",
"embed_survey": "Embed survey", "embed_survey": "Embed survey",
"expiry_date_description": "Once the link expires, the recipient cannot respond to survey any longer.",
"expiry_date_optional": "Expiry date (optional)",
"failed_to_copy_link": "Failed to copy link", "failed_to_copy_link": "Failed to copy link",
"filter_added_successfully": "Filter added successfully", "filter_added_successfully": "Filter added successfully",
"filter_updated_successfully": "Filter updated successfully", "filter_updated_successfully": "Filter updated successfully",
"filtered_responses_csv": "Filtered responses (CSV)", "filtered_responses_csv": "Filtered responses (CSV)",
"filtered_responses_excel": "Filtered responses (Excel)", "filtered_responses_excel": "Filtered responses (Excel)",
"formbricks_email_survey_preview": "Formbricks Email Survey Preview", "formbricks_email_survey_preview": "Formbricks Email Survey Preview",
"generate_and_download_links": "Generate & download links",
"generate_personal_links_description": "Generate personal links for a segment and match survey responses to each contact. A CSV of you personal links incl. relevant contact information will be downloaded automatically.",
"generate_personal_links_title": "Maximize insights with personal survey links",
"generating_links": "Generating links",
"generating_links_toast": "Generating links, download will start soon…",
"go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49", "go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49",
"hide_embed_code": "Hide embed code", "hide_embed_code": "Hide embed code",
"how_to_create_a_panel": "How to create a panel", "how_to_create_a_panel": "How to create a panel",
@@ -1766,12 +1771,18 @@
"last_quarter": "Last quarter", "last_quarter": "Last quarter",
"last_year": "Last year", "last_year": "Last year",
"link_to_public_results_copied": "Link to public results copied", "link_to_public_results_copied": "Link to public results copied",
"links_generated_success_toast": "Links generated successfully, your download will start soon.",
"make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to", "make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to",
"mobile_app": "Mobile app", "mobile_app": "Mobile app",
"no_responses_found": "No responses found", "no_responses_found": "No responses found",
"no_segments_available": "No segments available",
"only_completed": "Only completed", "only_completed": "Only completed",
"other_values_found": "Other values found", "other_values_found": "Other values found",
"overall": "Overall", "overall": "Overall",
"personal_links": "Personal links",
"personal_links_upgrade_prompt_description": "Generate personal links for a segment and link survey responses to each contact.",
"personal_links_upgrade_prompt_title": "Use personal links with a higher plan",
"personal_links_work_with_segments": "Personal links work with segments.",
"publish_to_web": "Publish to web", "publish_to_web": "Publish to web",
"publish_to_web_warning": "You are about to release these survey results to the public.", "publish_to_web_warning": "You are about to release these survey results to the public.",
"publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.", "publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.",
@@ -1780,6 +1791,7 @@
"quickstart_web_apps": "Quickstart: Web apps", "quickstart_web_apps": "Quickstart: Web apps",
"quickstart_web_apps_description": "Please follow the Quickstart guide to get started:", "quickstart_web_apps_description": "Please follow the Quickstart guide to get started:",
"results_are_public": "Results are public", "results_are_public": "Results are public",
"select_segment": "Select segment",
"selected_responses_csv": "Selected responses (CSV)", "selected_responses_csv": "Selected responses (CSV)",
"selected_responses_excel": "Selected responses (Excel)", "selected_responses_excel": "Selected responses (Excel)",
"send_preview": "Send preview", "send_preview": "Send preview",
@@ -1787,6 +1799,7 @@
"setup_instructions": "Setup instructions", "setup_instructions": "Setup instructions",
"setup_integrations": "Setup integrations", "setup_integrations": "Setup integrations",
"share_results": "Share results", "share_results": "Share results",
"share_survey": "Share survey",
"share_the_link": "Share the link", "share_the_link": "Share the link",
"share_the_link_to_get_responses": "Share the link to get responses", "share_the_link_to_get_responses": "Share the link to get responses",
"show_all_responses_that_match": "Show all responses that match", "show_all_responses_that_match": "Show all responses that match",
@@ -1804,6 +1817,7 @@
"this_year": "This year", "this_year": "This year",
"time_to_complete": "Time to Complete", "time_to_complete": "Time to Complete",
"to_connect_your_website_with_formbricks": "to connect your website with Formbricks", "to_connect_your_website_with_formbricks": "to connect your website with Formbricks",
"to_create_personal_links_segment_required": "To create personal links for your survey, you need to set up a segment first.",
"ttc_tooltip": "Average time to complete the survey.", "ttc_tooltip": "Average time to complete the survey.",
"unknown_question_type": "Unknown Question Type", "unknown_question_type": "Unknown Question Type",
"unpublish_from_web": "Unpublish from web", "unpublish_from_web": "Unpublish from web",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "What, if anything, is holding you back from making a purchase today?", "understand_purchase_intention_question_3_headline": "What, if anything, is holding you back from making a purchase today?",
"understand_purchase_intention_question_3_placeholder": "Type your answer here..." "understand_purchase_intention_question_3_placeholder": "Type your answer here..."
} }
} }
+44 -30
View File
@@ -34,7 +34,8 @@
"text": "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe." "text": "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
} }
}, },
"reset_password": "Réinitialiser le mot de passe" "reset_password": "Réinitialiser le mot de passe",
"reset_password_description": "Vous serez déconnecté pour réinitialiser votre mot de passe."
}, },
"invite": { "invite": {
"create_account": "Créer un compte", "create_account": "Créer un compte",
@@ -107,6 +108,10 @@
"thanks_for_upgrading": "Merci beaucoup d'avoir mis à niveau votre abonnement Formbricks.", "thanks_for_upgrading": "Merci beaucoup d'avoir mis à niveau votre abonnement Formbricks.",
"upgrade_successful": "Mise à niveau réussie" "upgrade_successful": "Mise à niveau réussie"
}, },
"c": {
"link_expired": "Votre lien est expiré.",
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
},
"common": { "common": {
"accepted": "Accepté", "accepted": "Accepté",
"account": "Compte", "account": "Compte",
@@ -191,7 +196,6 @@
"e_commerce": "E-commerce", "e_commerce": "E-commerce",
"edit": "Modifier", "edit": "Modifier",
"email": "Email", "email": "Email",
"embed": "Intégrer",
"enterprise_license": "Licence d'entreprise", "enterprise_license": "Licence d'entreprise",
"environment_not_found": "Environnement non trouvé", "environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.", "environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
@@ -309,14 +313,15 @@
"project_not_found": "Projet non trouvé", "project_not_found": "Projet non trouvé",
"project_permission_not_found": "Autorisation de projet non trouvée", "project_permission_not_found": "Autorisation de projet non trouvée",
"projects": "Projets", "projects": "Projets",
"projects_limit_reached": "Limite de projets atteinte",
"question": "Question", "question": "Question",
"question_id": "ID de la question", "question_id": "ID de la question",
"questions": "Questions", "questions": "Questions",
"read_docs": "Lire les documents", "read_docs": "Lire les documents",
"recipients": "Destinataires",
"remove": "Retirer", "remove": "Retirer",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes", "reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête", "report_survey": "Rapport d'enquête",
"request_pricing": "Demander la tarification",
"request_trial_license": "Demander une licence d'essai", "request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut", "reset_to_default": "Réinitialiser par défaut",
"response": "Réponse", "response": "Réponse",
@@ -597,6 +602,7 @@
"contact_not_found": "Aucun contact trouvé", "contact_not_found": "Aucun contact trouvé",
"contacts_table_refresh": "Rafraîchir les contacts", "contacts_table_refresh": "Rafraîchir les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès", "contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
"first_name": "Prénom", "first_name": "Prénom",
"last_name": "Nom de famille", "last_name": "Nom de famille",
"no_responses_found": "Aucune réponse trouvée", "no_responses_found": "Aucune réponse trouvée",
@@ -633,6 +639,7 @@
"airtable_integration": "Intégration Airtable", "airtable_integration": "Intégration Airtable",
"airtable_integration_description": "Synchronisez les réponses directement avec Airtable.", "airtable_integration_description": "Synchronisez les réponses directement avec Airtable.",
"airtable_integration_is_not_configured": "L'intégration Airtable n'est pas configurée", "airtable_integration_is_not_configured": "L'intégration Airtable n'est pas configurée",
"airtable_logo": "Logo Airtable",
"connect_with_airtable": "Se connecter à Airtable", "connect_with_airtable": "Se connecter à Airtable",
"link_airtable_table": "Lier la table Airtable", "link_airtable_table": "Lier la table Airtable",
"link_new_table": "Lier nouvelle table", "link_new_table": "Lier nouvelle table",
@@ -722,6 +729,7 @@
"slack_integration": "Intégration Slack", "slack_integration": "Intégration Slack",
"slack_integration_description": "Envoyez les réponses directement sur Slack.", "slack_integration_description": "Envoyez les réponses directement sur Slack.",
"slack_integration_is_not_configured": "L'intégration Slack n'est pas configurée dans votre instance de Formbricks.", "slack_integration_is_not_configured": "L'intégration Slack n'est pas configurée dans votre instance de Formbricks.",
"slack_logo": "logo Slack",
"slack_reconnect_button": "Reconnecter", "slack_reconnect_button": "Reconnecter",
"slack_reconnect_button_description": "<b>Remarque :</b> Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack." "slack_reconnect_button_description": "<b>Remarque :</b> Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack."
}, },
@@ -906,8 +914,7 @@
"tag_already_exists": "Le tag existe déjà", "tag_already_exists": "Le tag existe déjà",
"tag_deleted": "Tag supprimé", "tag_deleted": "Tag supprimé",
"tag_updated": "Étiquette mise à jour", "tag_updated": "Étiquette mise à jour",
"tags_merged": "Étiquettes fusionnées", "tags_merged": "Étiquettes fusionnées"
"unique_constraint_failed_on_the_fields": "Échec de la contrainte unique sur les champs"
}, },
"teams": { "teams": {
"manage_teams": "Gérer les équipes", "manage_teams": "Gérer les équipes",
@@ -980,63 +987,53 @@
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks" "api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10000 Réponses Mensuelles", "1000_monthly_responses": "1000 Réponses Mensuelles",
"1500_monthly_responses": "1500 Réponses Mensuelles", "1_project": "1 Projet",
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels", "2000_contacts": "2 000 Contacts",
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
"3_projects": "3 Projets", "3_projects": "3 Projets",
"5000_monthly_responses": "5,000 Réponses Mensuelles", "5000_monthly_responses": "5,000 Réponses Mensuelles",
"5_projects": "5 Projets", "7500_contacts": "7 500 Contacts",
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
"advanced_targeting": "Ciblage Avancé",
"all_integrations": "Toutes les intégrations", "all_integrations": "Toutes les intégrations",
"all_surveying_features": "Tous les outils d'arpentage",
"annually": "Annuellement", "annually": "Annuellement",
"api_webhooks": "API et Webhooks", "api_webhooks": "API et Webhooks",
"app_surveys": "Sondages d'application", "app_surveys": "Sondages d'application",
"contact_us": "Contactez-nous", "attribute_based_targeting": "Ciblage basé sur les attributs",
"current": "Actuel", "current": "Actuel",
"current_plan": "Plan actuel", "current_plan": "Plan actuel",
"current_tier_limit": "Limite de niveau actuel", "current_tier_limit": "Limite de niveau actuel",
"custom_miu_limit": "Limite MIU personnalisé", "custom": "Personnalisé et Échelle",
"custom_contacts_limit": "Limite de contacts personnalisé",
"custom_project_limit": "Limite de projet personnalisé", "custom_project_limit": "Limite de projet personnalisé",
"customer_success_manager": "Responsable de la réussite client", "custom_response_limit": "Limite de réponse personnalisé",
"email_embedded_surveys": "Sondages intégrés par e-mail", "email_embedded_surveys": "Sondages intégrés par e-mail",
"email_support": "Support par e-mail", "email_follow_ups": "Relances par e-mail",
"enterprise": "Entreprise",
"enterprise_description": "Soutien premium et limites personnalisées.", "enterprise_description": "Soutien premium et limites personnalisées.",
"everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !", "everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !",
"everything_in_free": "Tout est gratuit", "everything_in_free": "Tout est gratuit",
"everything_in_scale": "Tout à l'échelle",
"everything_in_startup": "Tout dans le Startup", "everything_in_startup": "Tout dans le Startup",
"free": "Gratuit", "free": "Gratuit",
"free_description": "Sondages illimités, membres d'équipe, et plus encore.", "free_description": "Sondages illimités, membres d'équipe, et plus encore.",
"get_2_months_free": "Obtenez 2 mois gratuits", "get_2_months_free": "Obtenez 2 mois gratuits",
"get_in_touch": "Prenez contact", "get_in_touch": "Prenez contact",
"hosted_in_frankfurt": "Hébergé à Francfort",
"ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
"link_surveys": "Sondages par lien (partageables)", "link_surveys": "Sondages par lien (partageables)",
"logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.", "logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.",
"manage_card_details": "Gérer les détails de la carte", "manage_card_details": "Gérer les détails de la carte",
"manage_subscription": "Gérer l'abonnement", "manage_subscription": "Gérer l'abonnement",
"monthly": "Mensuel", "monthly": "Mensuel",
"monthly_identified_users": "Utilisateurs Identifiés Mensuels", "monthly_identified_users": "Utilisateurs Identifiés Mensuels",
"multi_language_surveys": "Sondages multilingues",
"per_month": "par mois", "per_month": "par mois",
"per_year": "par an", "per_year": "par an",
"plan_upgraded_successfully": "Plan mis à jour avec succès", "plan_upgraded_successfully": "Plan mis à jour avec succès",
"premium_support_with_slas": "Soutien premium avec SLA", "premium_support_with_slas": "Soutien premium avec SLA",
"priority_support": "Soutien Prioritaire",
"remove_branding": "Supprimer la marque", "remove_branding": "Supprimer la marque",
"say_hi": "Dis bonjour !",
"scale": "Échelle",
"scale_description": "Fonctionnalités avancées pour développer votre entreprise.",
"startup": "Startup", "startup": "Startup",
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.", "startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
"switch_plan": "Changer de plan", "switch_plan": "Changer de plan",
"switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.", "switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
"team_access_roles": "Rôles d'accès d'équipe", "team_access_roles": "Rôles d'accès d'équipe",
"technical_onboarding": "Intégration technique",
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan", "unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
"unlimited_apps_websites": "Applications et sites Web illimités",
"unlimited_miu": "MIU Illimité", "unlimited_miu": "MIU Illimité",
"unlimited_projects": "Projets illimités", "unlimited_projects": "Projets illimités",
"unlimited_responses": "Réponses illimitées", "unlimited_responses": "Réponses illimitées",
@@ -1075,6 +1072,7 @@
"create_new_organization": "Créer une nouvelle organisation", "create_new_organization": "Créer une nouvelle organisation",
"create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.", "create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.",
"customize_email_with_a_higher_plan": "Personnalisez l'e-mail avec un plan supérieur", "customize_email_with_a_higher_plan": "Personnalisez l'e-mail avec un plan supérieur",
"delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les projets et enquêtes de votre organisation.",
"delete_organization": "Supprimer l'organisation", "delete_organization": "Supprimer l'organisation",
"delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs.", "delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
"delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :", "delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :",
@@ -1231,8 +1229,9 @@
"copy_survey_description": "Copier cette enquête dans un autre environnement", "copy_survey_description": "Copier cette enquête dans un autre environnement",
"copy_survey_error": "Échec de la copie du sondage", "copy_survey_error": "Échec de la copie du sondage",
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers", "copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
"copy_survey_success": "Enquête copiée avec succès !", "copy_survey_success": "Enquête copiée avec succès !",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses ? Cette action ne peut pas être annulée.", "delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :", "1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :", "2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
@@ -1305,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}", "card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
"card_background_color": "Couleur de fond de la carte", "card_background_color": "Couleur de fond de la carte",
"card_border_color": "Couleur de la bordure de la carte", "card_border_color": "Couleur de la bordure de la carte",
"card_shadow_color": "Couleur de l'ombre de la carte",
"card_styling": "Style de carte", "card_styling": "Style de carte",
"casual": "Décontracté", "casual": "Décontracté",
"caution_edit_duplicate": "Dupliquer et modifier", "caution_edit_duplicate": "Dupliquer et modifier",
@@ -1330,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "Changez la couleur de la marque du sondage.", "change_the_brand_color_of_the_survey": "Changez la couleur de la marque du sondage.",
"change_the_placement_of_this_survey": "Changez le placement de cette enquête.", "change_the_placement_of_this_survey": "Changez le placement de cette enquête.",
"change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.", "change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.",
"change_the_shadow_color_of_the_card": "Changez la couleur de l'ombre de la carte.",
"changes_saved": "Modifications enregistrées.", "changes_saved": "Modifications enregistrées.",
"character_limit_toggle_description": "Limitez la longueur des réponses.", "character_limit_toggle_description": "Limitez la longueur des réponses.",
"character_limit_toggle_title": "Ajouter des limites de caractères", "character_limit_toggle_title": "Ajouter des limites de caractères",
@@ -1717,6 +1714,7 @@
"congrats": "Félicitations ! Votre enquête est en ligne.", "congrats": "Félicitations ! Votre enquête est en ligne.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.", "connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
"copy_link_to_public_results": "Copier le lien vers les résultats publics", "copy_link_to_public_results": "Copier le lien vers les résultats publics",
"create_and_manage_segments": "Créez et gérez vos Segments sous Contacts > Segments",
"create_single_use_links": "Créer des liens à usage unique", "create_single_use_links": "Créer des liens à usage unique",
"create_single_use_links_description": "Acceptez uniquement une soumission par lien. Voici comment.", "create_single_use_links_description": "Acceptez uniquement une soumission par lien. Voici comment.",
"custom_range": "Plage personnalisée...", "custom_range": "Plage personnalisée...",
@@ -1735,12 +1733,19 @@
"embed_on_website": "Incorporer sur le site web", "embed_on_website": "Incorporer sur le site web",
"embed_pop_up_survey_title": "Comment intégrer une enquête pop-up sur votre site web", "embed_pop_up_survey_title": "Comment intégrer une enquête pop-up sur votre site web",
"embed_survey": "Intégrer l'enquête", "embed_survey": "Intégrer l'enquête",
"expiry_date_description": "Une fois le lien expiré, le destinataire ne peut plus répondre au sondage.",
"expiry_date_optional": "Date d'expiration (facultatif)",
"failed_to_copy_link": "Échec de la copie du lien", "failed_to_copy_link": "Échec de la copie du lien",
"filter_added_successfully": "Filtre ajouté avec succès", "filter_added_successfully": "Filtre ajouté avec succès",
"filter_updated_successfully": "Filtre mis à jour avec succès", "filter_updated_successfully": "Filtre mis à jour avec succès",
"filtered_responses_csv": "Réponses filtrées (CSV)", "filtered_responses_csv": "Réponses filtrées (CSV)",
"filtered_responses_excel": "Réponses filtrées (Excel)", "filtered_responses_excel": "Réponses filtrées (Excel)",
"formbricks_email_survey_preview": "Aperçu de l'enquête par e-mail Formbricks", "formbricks_email_survey_preview": "Aperçu de l'enquête par e-mail Formbricks",
"generate_and_download_links": "Générer et télécharger les liens",
"generate_personal_links_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact. Un fichier CSV de vos liens personnels incluant les informations de contact pertinentes sera téléchargé automatiquement.",
"generate_personal_links_title": "Maximisez les insights avec des liens d'enquête personnels",
"generating_links": "Génération de liens",
"generating_links_toast": "Génération des liens, le téléchargement commencera bientôt…",
"go_to_setup_checklist": "Allez à la liste de contrôle de configuration \uD83D\uDC49", "go_to_setup_checklist": "Allez à la liste de contrôle de configuration \uD83D\uDC49",
"hide_embed_code": "Cacher le code d'intégration", "hide_embed_code": "Cacher le code d'intégration",
"how_to_create_a_panel": "Comment créer un panneau", "how_to_create_a_panel": "Comment créer un panneau",
@@ -1766,12 +1771,18 @@
"last_quarter": "dernier trimestre", "last_quarter": "dernier trimestre",
"last_year": "l'année dernière", "last_year": "l'année dernière",
"link_to_public_results_copied": "Lien vers les résultats publics copié", "link_to_public_results_copied": "Lien vers les résultats publics copié",
"links_generated_success_toast": "Liens générés avec succès, votre téléchargement commencera bientôt.",
"make_sure_the_survey_type_is_set_to": "Assurez-vous que le type d'enquête est défini sur", "make_sure_the_survey_type_is_set_to": "Assurez-vous que le type d'enquête est défini sur",
"mobile_app": "Application mobile", "mobile_app": "Application mobile",
"no_responses_found": "Aucune réponse trouvée", "no_responses_found": "Aucune réponse trouvée",
"no_segments_available": "Aucun segment disponible",
"only_completed": "Uniquement terminé", "only_completed": "Uniquement terminé",
"other_values_found": "D'autres valeurs trouvées", "other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement", "overall": "Globalement",
"personal_links": "Liens personnels",
"personal_links_upgrade_prompt_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.",
"personal_links_upgrade_prompt_title": "Utilisez des liens personnels avec un plan supérieur",
"personal_links_work_with_segments": "Les liens personnels fonctionnent avec les segments.",
"publish_to_web": "Publier sur le web", "publish_to_web": "Publier sur le web",
"publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.", "publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.",
"publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.", "publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.",
@@ -1780,6 +1791,7 @@
"quickstart_web_apps": "Démarrage rapide : Applications web", "quickstart_web_apps": "Démarrage rapide : Applications web",
"quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :", "quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :",
"results_are_public": "Les résultats sont publics.", "results_are_public": "Les résultats sont publics.",
"select_segment": "Sélectionner le segment",
"selected_responses_csv": "Réponses sélectionnées (CSV)", "selected_responses_csv": "Réponses sélectionnées (CSV)",
"selected_responses_excel": "Réponses sélectionnées (Excel)", "selected_responses_excel": "Réponses sélectionnées (Excel)",
"send_preview": "Envoyer un aperçu", "send_preview": "Envoyer un aperçu",
@@ -1787,6 +1799,7 @@
"setup_instructions": "Instructions d'installation", "setup_instructions": "Instructions d'installation",
"setup_integrations": "Configurer les intégrations", "setup_integrations": "Configurer les intégrations",
"share_results": "Partager les résultats", "share_results": "Partager les résultats",
"share_survey": "Partager l'enquête",
"share_the_link": "Partager le lien", "share_the_link": "Partager le lien",
"share_the_link_to_get_responses": "Partagez le lien pour obtenir des réponses", "share_the_link_to_get_responses": "Partagez le lien pour obtenir des réponses",
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes", "show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
@@ -1804,6 +1817,7 @@
"this_year": "Cette année", "this_year": "Cette année",
"time_to_complete": "Temps à compléter", "time_to_complete": "Temps à compléter",
"to_connect_your_website_with_formbricks": "connecter votre site web à Formbricks", "to_connect_your_website_with_formbricks": "connecter votre site web à Formbricks",
"to_create_personal_links_segment_required": "Pour créer des liens personnels pour votre enquête, vous devez d'abord définir un segment.",
"ttc_tooltip": "Temps moyen pour compléter l'enquête.", "ttc_tooltip": "Temps moyen pour compléter l'enquête.",
"unknown_question_type": "Type de question inconnu", "unknown_question_type": "Type de question inconnu",
"unpublish_from_web": "Désactiver la publication sur le web", "unpublish_from_web": "Désactiver la publication sur le web",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "Qu'est-ce qui vous empêche de faire un achat aujourd'hui, s'il y a quelque chose ?", "understand_purchase_intention_question_3_headline": "Qu'est-ce qui vous empêche de faire un achat aujourd'hui, s'il y a quelque chose ?",
"understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici..." "understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici..."
} }
} }
+46 -32
View File
@@ -34,7 +34,8 @@
"text": "Agora você pode fazer login com sua nova senha" "text": "Agora você pode fazer login com sua nova senha"
} }
}, },
"reset_password": "Redefinir senha" "reset_password": "Redefinir senha",
"reset_password_description": "Você será desconectado para redefinir sua senha."
}, },
"invite": { "invite": {
"create_account": "Cria uma conta", "create_account": "Cria uma conta",
@@ -107,6 +108,10 @@
"thanks_for_upgrading": "Valeu demais por atualizar sua assinatura do Formbricks.", "thanks_for_upgrading": "Valeu demais por atualizar sua assinatura do Formbricks.",
"upgrade_successful": "Atualização bem-sucedida" "upgrade_successful": "Atualização bem-sucedida"
}, },
"c": {
"link_expired": "Seu link está expirado.",
"link_expired_description": "O link que você usou não é mais válido."
},
"common": { "common": {
"accepted": "Aceito", "accepted": "Aceito",
"account": "conta", "account": "conta",
@@ -191,7 +196,6 @@
"e_commerce": "comércio eletrônico", "e_commerce": "comércio eletrônico",
"edit": "Editar", "edit": "Editar",
"email": "Email", "email": "Email",
"embed": "incorporar",
"enterprise_license": "Licença Empresarial", "enterprise_license": "Licença Empresarial",
"environment_not_found": "Ambiente não encontrado", "environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.", "environment_notice": "Você está atualmente no ambiente {environment}.",
@@ -309,14 +313,15 @@
"project_not_found": "Projeto não encontrado", "project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada", "project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos", "projects": "Projetos",
"projects_limit_reached": "Limites de projetos atingidos",
"question": "Pergunta", "question": "Pergunta",
"question_id": "ID da Pergunta", "question_id": "ID da Pergunta",
"questions": "Perguntas", "questions": "Perguntas",
"read_docs": "Ler Documentos", "read_docs": "Ler Documentos",
"recipients": "Destinatários",
"remove": "remover", "remove": "remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas", "reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa", "report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
"request_trial_license": "Pedir licença de teste", "request_trial_license": "Pedir licença de teste",
"reset_to_default": "Restaurar para o padrão", "reset_to_default": "Restaurar para o padrão",
"response": "Resposta", "response": "Resposta",
@@ -357,7 +362,7 @@
"start_free_trial": "Iniciar Teste Grátis", "start_free_trial": "Iniciar Teste Grátis",
"status": "status", "status": "status",
"step_by_step_manual": "Manual passo a passo", "step_by_step_manual": "Manual passo a passo",
"styling": "estilização", "styling": "Estilização",
"submit": "Enviar", "submit": "Enviar",
"summary": "Resumo", "summary": "Resumo",
"survey": "Pesquisa", "survey": "Pesquisa",
@@ -369,7 +374,7 @@
"survey_paused": "Pesquisa pausada.", "survey_paused": "Pesquisa pausada.",
"survey_scheduled": "Pesquisa agendada.", "survey_scheduled": "Pesquisa agendada.",
"survey_type": "Tipo de Pesquisa", "survey_type": "Tipo de Pesquisa",
"surveys": "pesquisas", "surveys": "Pesquisas",
"switch_organization": "Mudar organização", "switch_organization": "Mudar organização",
"switch_to": "Mudar para {environment}", "switch_to": "Mudar para {environment}",
"table_items_deleted_successfully": "{type}s deletados com sucesso", "table_items_deleted_successfully": "{type}s deletados com sucesso",
@@ -597,6 +602,7 @@
"contact_not_found": "Nenhum contato encontrado", "contact_not_found": "Nenhum contato encontrado",
"contacts_table_refresh": "Atualizar contatos", "contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso", "contacts_table_refresh_success": "Contatos atualizados com sucesso",
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome", "first_name": "Primeiro Nome",
"last_name": "Sobrenome", "last_name": "Sobrenome",
"no_responses_found": "Nenhuma resposta encontrada", "no_responses_found": "Nenhuma resposta encontrada",
@@ -633,6 +639,7 @@
"airtable_integration": "Integração com Airtable", "airtable_integration": "Integração com Airtable",
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.", "airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada", "airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
"airtable_logo": "Logo do Airtable",
"connect_with_airtable": "Conectar com o Airtable", "connect_with_airtable": "Conectar com o Airtable",
"link_airtable_table": "Vincular Tabela do Airtable", "link_airtable_table": "Vincular Tabela do Airtable",
"link_new_table": "Vincular nova tabela", "link_new_table": "Vincular nova tabela",
@@ -722,6 +729,7 @@
"slack_integration": "Integração com o Slack", "slack_integration": "Integração com o Slack",
"slack_integration_description": "Manda as respostas direto pro Slack.", "slack_integration_description": "Manda as respostas direto pro Slack.",
"slack_integration_is_not_configured": "A integração do Slack não está configurada na sua instância do Formbricks.", "slack_integration_is_not_configured": "A integração do Slack não está configurada na sua instância do Formbricks.",
"slack_logo": "Logotipo do Slack",
"slack_reconnect_button": "Reconectar", "slack_reconnect_button": "Reconectar",
"slack_reconnect_button_description": "<b>Observação:</b> Recentemente, alteramos nossa integração com o Slack para também suportar canais privados. Por favor, reconecte seu workspace do Slack." "slack_reconnect_button_description": "<b>Observação:</b> Recentemente, alteramos nossa integração com o Slack para também suportar canais privados. Por favor, reconecte seu workspace do Slack."
}, },
@@ -906,8 +914,7 @@
"tag_already_exists": "Tag já existe", "tag_already_exists": "Tag já existe",
"tag_deleted": "Tag apagada", "tag_deleted": "Tag apagada",
"tag_updated": "Tag atualizada", "tag_updated": "Tag atualizada",
"tags_merged": "Tags mescladas", "tags_merged": "Tags mescladas"
"unique_constraint_failed_on_the_fields": "Falha na restrição única nos campos"
}, },
"teams": { "teams": {
"manage_teams": "Gerenciar Equipes", "manage_teams": "Gerenciar Equipes",
@@ -980,63 +987,53 @@
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks" "api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10000 Respostas Mensais", "1000_monthly_responses": "1000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais", "1_project": "1 Projeto",
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente", "2000_contacts": "2.000 Contatos",
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
"3_projects": "3 Projetos", "3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais", "5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos", "7500_contacts": "7.500 Contatos",
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
"advanced_targeting": "Mira Avançada",
"all_integrations": "Todas as Integrações", "all_integrations": "Todas as Integrações",
"all_surveying_features": "Todos os recursos de levantamento",
"annually": "anualmente", "annually": "anualmente",
"api_webhooks": "API e Webhooks", "api_webhooks": "API e Webhooks",
"app_surveys": "Pesquisas de App", "app_surveys": "Pesquisas de App",
"contact_us": "Fale Conosco", "attribute_based_targeting": "Segmentação Baseada em Atributos",
"current": "atual", "current": "atual",
"current_plan": "Plano Atual", "current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual de Nível", "current_tier_limit": "Limite Atual de Nível",
"custom_miu_limit": "Limite MIU personalizado", "custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contatos Personalizado",
"custom_project_limit": "Limite de Projeto Personalizado", "custom_project_limit": "Limite de Projeto Personalizado",
"customer_success_manager": "Gerente de Sucesso do Cliente", "custom_response_limit": "Limite de Resposta Personalizado",
"email_embedded_surveys": "Pesquisas Incorporadas no Email", "email_embedded_surveys": "Pesquisas Incorporadas no Email",
"email_support": "Suporte por Email", "email_follow_ups": "Acompanhamentos por Email",
"enterprise": "Empresa",
"enterprise_description": "Suporte premium e limites personalizados.", "enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!", "everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!",
"everything_in_free": "Tudo de graça", "everything_in_free": "Tudo de graça",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup", "everything_in_startup": "Tudo em Startup",
"free": "grátis", "free": "grátis",
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.", "free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
"get_2_months_free": "Ganhe 2 meses grátis", "get_2_months_free": "Ganhe 2 meses grátis",
"get_in_touch": "Entre em contato", "get_in_touch": "Entre em contato",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK para iOS e Android para pesquisas móveis",
"link_surveys": "Link de Pesquisas (Compartilhável)", "link_surveys": "Link de Pesquisas (Compartilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.", "logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.",
"manage_card_details": "Gerenciar Detalhes do Cartão", "manage_card_details": "Gerenciar Detalhes do Cartão",
"manage_subscription": "Gerenciar Assinatura", "manage_subscription": "Gerenciar Assinatura",
"monthly": "mensal", "monthly": "mensal",
"monthly_identified_users": "Usuários Identificados Mensalmente", "monthly_identified_users": "Usuários Identificados Mensalmente",
"multi_language_surveys": "Pesquisas Multilíngues",
"per_month": "por mês", "per_month": "por mês",
"per_year": "por ano", "per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso", "plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs", "premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca", "remove_branding": "Remover Marca",
"say_hi": "Diz oi!",
"scale": "escala",
"scale_description": "Recursos avançados pra escalar seu negócio.",
"startup": "startup", "startup": "startup",
"startup_description": "Tudo no Grátis com recursos adicionais.", "startup_description": "Tudo no Grátis com recursos adicionais.",
"switch_plan": "Mudar Plano", "switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.", "switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipe", "team_access_roles": "Funções de Acesso da Equipe",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não foi possível atualizar o plano", "unable_to_upgrade_plan": "Não foi possível atualizar o plano",
"unlimited_apps_websites": "Apps e Sites Ilimitados",
"unlimited_miu": "MIU Ilimitado", "unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados", "unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas", "unlimited_responses": "Respostas Ilimitadas",
@@ -1075,6 +1072,7 @@
"create_new_organization": "Criar nova organização", "create_new_organization": "Criar nova organização",
"create_new_organization_description": "Criar uma nova organização para lidar com um conjunto diferente de projetos.", "create_new_organization_description": "Criar uma nova organização para lidar com um conjunto diferente de projetos.",
"customize_email_with_a_higher_plan": "Personalize o email com um plano superior", "customize_email_with_a_higher_plan": "Personalize o email com um plano superior",
"delete_member_confirmation": "Membros apagados perderão acesso a todos os projetos e pesquisas da sua organização.",
"delete_organization": "Excluir Organização", "delete_organization": "Excluir Organização",
"delete_organization_description": "Excluir organização com todos os seus projetos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos", "delete_organization_description": "Excluir organização com todos os seus projetos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:", "delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:",
@@ -1231,8 +1229,9 @@
"copy_survey_description": "Copiar essa pesquisa para outro ambiente", "copy_survey_description": "Copiar essa pesquisa para outro ambiente",
"copy_survey_error": "Falha ao copiar pesquisa", "copy_survey_error": "Falha ao copiar pesquisa",
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência", "copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
"copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.",
"copy_survey_success": "Pesquisa copiada com sucesso!", "copy_survey_success": "Pesquisa copiada com sucesso!",
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas? Essa ação não pode ser desfeita.", "delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:", "1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:", "2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
@@ -1305,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}", "card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão", "card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão", "card_border_color": "Cor da borda do cartão",
"card_shadow_color": "cor da sombra do cartão",
"card_styling": "Estilização de Cartão", "card_styling": "Estilização de Cartão",
"casual": "Casual", "casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar", "caution_edit_duplicate": "Duplicar e editar",
@@ -1330,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "Muda a cor da marca da pesquisa.", "change_the_brand_color_of_the_survey": "Muda a cor da marca da pesquisa.",
"change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.", "change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.",
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.", "change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
"change_the_shadow_color_of_the_card": "Muda a cor da sombra do cartão.",
"changes_saved": "Mudanças salvas.", "changes_saved": "Mudanças salvas.",
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.", "character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres", "character_limit_toggle_title": "Adicionar limites de caracteres",
@@ -1717,6 +1714,7 @@
"congrats": "Parabéns! Sua pesquisa está no ar.", "congrats": "Parabéns! Sua pesquisa está no ar.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.", "connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos", "copy_link_to_public_results": "Copiar link para resultados públicos",
"create_and_manage_segments": "Crie e gerencie seus Segmentos em Contatos > Segmentos",
"create_single_use_links": "Crie links de uso único", "create_single_use_links": "Crie links de uso único",
"create_single_use_links_description": "Aceite apenas uma submissão por link. Aqui está como.", "create_single_use_links_description": "Aceite apenas uma submissão por link. Aqui está como.",
"custom_range": "Intervalo personalizado...", "custom_range": "Intervalo personalizado...",
@@ -1735,12 +1733,19 @@
"embed_on_website": "Incorporar no site", "embed_on_website": "Incorporar no site",
"embed_pop_up_survey_title": "Como incorporar uma pesquisa pop-up no seu site", "embed_pop_up_survey_title": "Como incorporar uma pesquisa pop-up no seu site",
"embed_survey": "Incorporar pesquisa", "embed_survey": "Incorporar pesquisa",
"expiry_date_description": "Quando o link expirar, o destinatário não poderá mais responder à pesquisa.",
"expiry_date_optional": "Data de expiração (opcional)",
"failed_to_copy_link": "Falha ao copiar link", "failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso", "filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso", "filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)", "filtered_responses_csv": "Respostas filtradas (CSV)",
"filtered_responses_excel": "Respostas filtradas (Excel)", "filtered_responses_excel": "Respostas filtradas (Excel)",
"formbricks_email_survey_preview": "Prévia da Pesquisa por E-mail do Formbricks", "formbricks_email_survey_preview": "Prévia da Pesquisa por E-mail do Formbricks",
"generate_and_download_links": "Gerar & baixar links",
"generate_personal_links_description": "Gerar links pessoais para um segmento e associar respostas de pesquisa a cada contato. Um CSV dos seus links pessoais com as informações de contato relevantes será baixado automaticamente.",
"generate_personal_links_title": "Maximize insights com links de pesquisa personalizados",
"generating_links": "Gerando links",
"generating_links_toast": "Gerando links, o download começará em breve…",
"go_to_setup_checklist": "Vai para a Lista de Configuração \uD83D\uDC49", "go_to_setup_checklist": "Vai para a Lista de Configuração \uD83D\uDC49",
"hide_embed_code": "Esconder código de incorporação", "hide_embed_code": "Esconder código de incorporação",
"how_to_create_a_panel": "Como criar um painel", "how_to_create_a_panel": "Como criar um painel",
@@ -1766,12 +1771,18 @@
"last_quarter": "Último trimestre", "last_quarter": "Último trimestre",
"last_year": "Último ano", "last_year": "Último ano",
"link_to_public_results_copied": "Link pros resultados públicos copiado", "link_to_public_results_copied": "Link pros resultados públicos copiado",
"links_generated_success_toast": "Links gerados com sucesso, o download começará em breve.",
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como", "make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como",
"mobile_app": "app de celular", "mobile_app": "app de celular",
"no_responses_found": "Nenhuma resposta encontrada", "no_responses_found": "Nenhuma resposta encontrada",
"no_segments_available": "Nenhum segmento disponível",
"only_completed": "Somente concluído", "only_completed": "Somente concluído",
"other_values_found": "Outros valores encontrados", "other_values_found": "Outros valores encontrados",
"overall": "No geral", "overall": "No geral",
"personal_links": "Links pessoais",
"personal_links_upgrade_prompt_description": "Gerar links pessoais para um segmento e vincular respostas de pesquisa a cada contato.",
"personal_links_upgrade_prompt_title": "Use links pessoais com um plano superior",
"personal_links_work_with_segments": "Links pessoais funcionam com segmentos.",
"publish_to_web": "Publicar na web", "publish_to_web": "Publicar na web",
"publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.", "publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.",
"publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.", "publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.",
@@ -1780,6 +1791,7 @@
"quickstart_web_apps": "Início rápido: Aplicativos web", "quickstart_web_apps": "Início rápido: Aplicativos web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:", "quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"results_are_public": "Os resultados são públicos", "results_are_public": "Os resultados são públicos",
"select_segment": "Selecionar segmento",
"selected_responses_csv": "Respostas selecionadas (CSV)", "selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)", "selected_responses_excel": "Respostas selecionadas (Excel)",
"send_preview": "Enviar prévia", "send_preview": "Enviar prévia",
@@ -1787,6 +1799,7 @@
"setup_instructions": "Instruções de configuração", "setup_instructions": "Instruções de configuração",
"setup_integrations": "Configurar integrações", "setup_integrations": "Configurar integrações",
"share_results": "Compartilhar resultados", "share_results": "Compartilhar resultados",
"share_survey": "Compartilhar pesquisa",
"share_the_link": "Compartilha o link", "share_the_link": "Compartilha o link",
"share_the_link_to_get_responses": "Compartilha o link pra receber respostas", "share_the_link_to_get_responses": "Compartilha o link pra receber respostas",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem", "show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
@@ -1804,6 +1817,7 @@
"this_year": "Este ano", "this_year": "Este ano",
"time_to_complete": "Tempo para Concluir", "time_to_complete": "Tempo para Concluir",
"to_connect_your_website_with_formbricks": "conectar seu site com o Formbricks", "to_connect_your_website_with_formbricks": "conectar seu site com o Formbricks",
"to_create_personal_links_segment_required": "Para criar links pessoais para sua pesquisa, você precisa configurar um segmento primeiro.",
"ttc_tooltip": "Tempo médio para completar a pesquisa.", "ttc_tooltip": "Tempo médio para completar a pesquisa.",
"unknown_question_type": "Tipo de pergunta desconhecido", "unknown_question_type": "Tipo de pergunta desconhecido",
"unpublish_from_web": "Despublicar da web", "unpublish_from_web": "Despublicar da web",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "O que, se é que tem algo, está te impedindo de fazer a compra hoje?", "understand_purchase_intention_question_3_headline": "O que, se é que tem algo, está te impedindo de fazer a compra hoje?",
"understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui..." "understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui..."
} }
} }
+44 -30
View File
@@ -34,7 +34,8 @@
"text": "Pode agora iniciar sessão com a sua nova palavra-passe" "text": "Pode agora iniciar sessão com a sua nova palavra-passe"
} }
}, },
"reset_password": "Redefinir palavra-passe" "reset_password": "Redefinir palavra-passe",
"reset_password_description": "Será desconectado para redefinir a sua palavra-passe."
}, },
"invite": { "invite": {
"create_account": "Criar uma conta", "create_account": "Criar uma conta",
@@ -107,6 +108,10 @@
"thanks_for_upgrading": "Muito obrigado por atualizar a sua subscrição do Formbricks.", "thanks_for_upgrading": "Muito obrigado por atualizar a sua subscrição do Formbricks.",
"upgrade_successful": "Atualização bem-sucedida" "upgrade_successful": "Atualização bem-sucedida"
}, },
"c": {
"link_expired": "O seu link expirou.",
"link_expired_description": "O link que utilizou já não é válido."
},
"common": { "common": {
"accepted": "Aceite", "accepted": "Aceite",
"account": "Conta", "account": "Conta",
@@ -191,7 +196,6 @@
"e_commerce": "Comércio Eletrónico", "e_commerce": "Comércio Eletrónico",
"edit": "Editar", "edit": "Editar",
"email": "Email", "email": "Email",
"embed": "Incorporar",
"enterprise_license": "Licença Enterprise", "enterprise_license": "Licença Enterprise",
"environment_not_found": "Ambiente não encontrado", "environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.", "environment_notice": "Está atualmente no ambiente {environment}.",
@@ -309,14 +313,15 @@
"project_not_found": "Projeto não encontrado", "project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada", "project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos", "projects": "Projetos",
"projects_limit_reached": "Limite de projetos atingido",
"question": "Pergunta", "question": "Pergunta",
"question_id": "ID da pergunta", "question_id": "ID da pergunta",
"questions": "Perguntas", "questions": "Perguntas",
"read_docs": "Ler Documentos", "read_docs": "Ler Documentos",
"recipients": "Destinatários",
"remove": "Remover", "remove": "Remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas", "reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Inquérito", "report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
"request_trial_license": "Solicitar licença de teste", "request_trial_license": "Solicitar licença de teste",
"reset_to_default": "Repor para o padrão", "reset_to_default": "Repor para o padrão",
"response": "Resposta", "response": "Resposta",
@@ -597,6 +602,7 @@
"contact_not_found": "Nenhum contacto encontrado", "contact_not_found": "Nenhum contacto encontrado",
"contacts_table_refresh": "Atualizar contactos", "contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso", "contacts_table_refresh_success": "Contactos atualizados com sucesso",
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome", "first_name": "Primeiro Nome",
"last_name": "Apelido", "last_name": "Apelido",
"no_responses_found": "Nenhuma resposta encontrada", "no_responses_found": "Nenhuma resposta encontrada",
@@ -633,6 +639,7 @@
"airtable_integration": "Integração com o Airtable", "airtable_integration": "Integração com o Airtable",
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.", "airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada", "airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
"airtable_logo": "logotipo Airtable",
"connect_with_airtable": "Ligar ao Airtable", "connect_with_airtable": "Ligar ao Airtable",
"link_airtable_table": "Ligar Tabela Airtable", "link_airtable_table": "Ligar Tabela Airtable",
"link_new_table": "Ligar nova tabela", "link_new_table": "Ligar nova tabela",
@@ -722,6 +729,7 @@
"slack_integration": "Integração com Slack", "slack_integration": "Integração com Slack",
"slack_integration_description": "Enviar respostas diretamente para o Slack.", "slack_integration_description": "Enviar respostas diretamente para o Slack.",
"slack_integration_is_not_configured": "A integração com o Slack não está configurada na sua instância do Formbricks.", "slack_integration_is_not_configured": "A integração com o Slack não está configurada na sua instância do Formbricks.",
"slack_logo": "Logótipo Slack",
"slack_reconnect_button": "Reconectar", "slack_reconnect_button": "Reconectar",
"slack_reconnect_button_description": "<b>Nota:</b> Recentemente alterámos a nossa integração com o Slack para também suportar canais privados. Por favor, reconecte o seu espaço de trabalho do Slack." "slack_reconnect_button_description": "<b>Nota:</b> Recentemente alterámos a nossa integração com o Slack para também suportar canais privados. Por favor, reconecte o seu espaço de trabalho do Slack."
}, },
@@ -906,8 +914,7 @@
"tag_already_exists": "A etiqueta já existe", "tag_already_exists": "A etiqueta já existe",
"tag_deleted": "Etiqueta eliminada", "tag_deleted": "Etiqueta eliminada",
"tag_updated": "Etiqueta atualizada", "tag_updated": "Etiqueta atualizada",
"tags_merged": "Etiquetas fundidas", "tags_merged": "Etiquetas fundidas"
"unique_constraint_failed_on_the_fields": "A restrição de unicidade falhou nos campos"
}, },
"teams": { "teams": {
"manage_teams": "Gerir equipas", "manage_teams": "Gerir equipas",
@@ -980,63 +987,53 @@
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks" "api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10000 Respostas Mensais", "1000_monthly_responses": "1000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais", "1_project": "1 Projeto",
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente", "2000_contacts": "2,000 Contactos",
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
"3_projects": "3 Projetos", "3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais", "5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos", "7500_contacts": "7,500 Contactos",
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
"advanced_targeting": "Segmentação Avançada",
"all_integrations": "Todas as Integrações", "all_integrations": "Todas as Integrações",
"all_surveying_features": "Todas as funcionalidades de inquérito",
"annually": "Anualmente", "annually": "Anualmente",
"api_webhooks": "API e Webhooks", "api_webhooks": "API e Webhooks",
"app_surveys": "Inquéritos da Aplicação", "app_surveys": "Inquéritos da Aplicação",
"contact_us": "Contacte-nos", "attribute_based_targeting": "Segmentação Baseada em Atributos",
"current": "Atual", "current": "Atual",
"current_plan": "Plano Atual", "current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual do Nível", "current_tier_limit": "Limite Atual do Nível",
"custom_miu_limit": "Limite MIU Personalizado", "custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contactos Personalizado",
"custom_project_limit": "Limite de Projeto Personalizado", "custom_project_limit": "Limite de Projeto Personalizado",
"customer_success_manager": "Gestor de Sucesso do Cliente", "custom_response_limit": "Limite de Resposta Personalizado",
"email_embedded_surveys": "Inquéritos Incorporados no Email", "email_embedded_surveys": "Inquéritos Incorporados no Email",
"email_support": "Suporte por Email", "email_follow_ups": "Acompanhamentos por Email",
"enterprise": "Empresa",
"enterprise_description": "Suporte premium e limites personalizados.", "enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!", "everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!",
"everything_in_free": "Tudo em Gratuito", "everything_in_free": "Tudo em Gratuito",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup", "everything_in_startup": "Tudo em Startup",
"free": "Grátis", "free": "Grátis",
"free_description": "Inquéritos ilimitados, membros da equipa e mais.", "free_description": "Inquéritos ilimitados, membros da equipa e mais.",
"get_2_months_free": "Obtenha 2 meses grátis", "get_2_months_free": "Obtenha 2 meses grátis",
"get_in_touch": "Entre em contacto", "get_in_touch": "Entre em contacto",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
"link_surveys": "Ligar Inquéritos (Partilhável)", "link_surveys": "Ligar Inquéritos (Partilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.", "logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.",
"manage_card_details": "Gerir Detalhes do Cartão", "manage_card_details": "Gerir Detalhes do Cartão",
"manage_subscription": "Gerir Subscrição", "manage_subscription": "Gerir Subscrição",
"monthly": "Mensal", "monthly": "Mensal",
"monthly_identified_users": "Utilizadores Identificados Mensalmente", "monthly_identified_users": "Utilizadores Identificados Mensalmente",
"multi_language_surveys": "Inquéritos Multilingues",
"per_month": "por mês", "per_month": "por mês",
"per_year": "por ano", "per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso", "plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs", "premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca", "remove_branding": "Remover Marca",
"say_hi": "Diga Olá!",
"scale": "Escala",
"scale_description": "Funcionalidades avançadas para escalar o seu negócio.",
"startup": "Inicialização", "startup": "Inicialização",
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.", "startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
"switch_plan": "Mudar Plano", "switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.", "switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipa", "team_access_roles": "Funções de Acesso da Equipa",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não é possível atualizar o plano", "unable_to_upgrade_plan": "Não é possível atualizar o plano",
"unlimited_apps_websites": "Aplicações e Websites Ilimitados",
"unlimited_miu": "MIU Ilimitado", "unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados", "unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas", "unlimited_responses": "Respostas Ilimitadas",
@@ -1075,6 +1072,7 @@
"create_new_organization": "Criar nova organização", "create_new_organization": "Criar nova organização",
"create_new_organization_description": "Crie uma nova organização para gerir um conjunto diferente de projetos.", "create_new_organization_description": "Crie uma nova organização para gerir um conjunto diferente de projetos.",
"customize_email_with_a_higher_plan": "Personalize o e-mail com um plano superior", "customize_email_with_a_higher_plan": "Personalize o e-mail com um plano superior",
"delete_member_confirmation": "Membros eliminados perderão acesso a todos os projetos e inquéritos da sua organização.",
"delete_organization": "Eliminar Organização", "delete_organization": "Eliminar Organização",
"delete_organization_description": "Eliminar organização com todos os seus projetos, incluindo todos os inquéritos, respostas, pessoas, ações e atributos", "delete_organization_description": "Eliminar organização com todos os seus projetos, incluindo todos os inquéritos, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de prosseguir com a eliminação desta organização, esteja ciente das seguintes consequências:", "delete_organization_warning": "Antes de prosseguir com a eliminação desta organização, esteja ciente das seguintes consequências:",
@@ -1231,8 +1229,9 @@
"copy_survey_description": "Copiar este questionário para outro ambiente", "copy_survey_description": "Copiar este questionário para outro ambiente",
"copy_survey_error": "Falha ao copiar inquérito", "copy_survey_error": "Falha ao copiar inquérito",
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência", "copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
"copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
"copy_survey_success": "Inquérito copiado com sucesso!", "copy_survey_success": "Inquérito copiado com sucesso!",
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas? Esta ação não pode ser desfeita.", "delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:", "1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:", "2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
@@ -1305,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}", "card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão", "card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão", "card_border_color": "Cor da borda do cartão",
"card_shadow_color": "Cor da sombra do cartão",
"card_styling": "Estilo do cartão", "card_styling": "Estilo do cartão",
"casual": "Casual", "casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar", "caution_edit_duplicate": "Duplicar e editar",
@@ -1330,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "Alterar a cor da marca do inquérito", "change_the_brand_color_of_the_survey": "Alterar a cor da marca do inquérito",
"change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.", "change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.",
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito", "change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
"change_the_shadow_color_of_the_card": "Alterar a cor da sombra do cartão.",
"changes_saved": "Alterações guardadas.", "changes_saved": "Alterações guardadas.",
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.", "character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres", "character_limit_toggle_title": "Adicionar limites de caracteres",
@@ -1717,6 +1714,7 @@
"congrats": "Parabéns! O seu inquérito está ativo.", "congrats": "Parabéns! O seu inquérito está ativo.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.", "connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos", "copy_link_to_public_results": "Copiar link para resultados públicos",
"create_and_manage_segments": "Crie e gere os seus Segmentos em Contactos > Segmentos",
"create_single_use_links": "Criar links de uso único", "create_single_use_links": "Criar links de uso único",
"create_single_use_links_description": "Aceitar apenas uma submissão por link. Aqui está como.", "create_single_use_links_description": "Aceitar apenas uma submissão por link. Aqui está como.",
"custom_range": "Intervalo personalizado...", "custom_range": "Intervalo personalizado...",
@@ -1735,12 +1733,19 @@
"embed_on_website": "Incorporar no site", "embed_on_website": "Incorporar no site",
"embed_pop_up_survey_title": "Como incorporar um questionário pop-up no seu site", "embed_pop_up_survey_title": "Como incorporar um questionário pop-up no seu site",
"embed_survey": "Incorporar inquérito", "embed_survey": "Incorporar inquérito",
"expiry_date_description": "Uma vez que o link expira, o destinatário não pode mais responder ao questionário.",
"expiry_date_optional": "Data de expiração (opcional)",
"failed_to_copy_link": "Falha ao copiar link", "failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso", "filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso", "filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)", "filtered_responses_csv": "Respostas filtradas (CSV)",
"filtered_responses_excel": "Respostas filtradas (Excel)", "filtered_responses_excel": "Respostas filtradas (Excel)",
"formbricks_email_survey_preview": "Pré-visualização da Pesquisa de E-mail do Formbricks", "formbricks_email_survey_preview": "Pré-visualização da Pesquisa de E-mail do Formbricks",
"generate_and_download_links": "Gerar & descarregar links",
"generate_personal_links_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto. Um ficheiro CSV dos seus links pessoais, incluindo a informação relevante de contacto, será descarregado automaticamente.",
"generate_personal_links_title": "Maximize os insights com links pessoais de inquérito",
"generating_links": "Gerando links",
"generating_links_toast": "A gerar links, o download começará em breve…",
"go_to_setup_checklist": "Ir para a Lista de Verificação de Configuração \uD83D\uDC49", "go_to_setup_checklist": "Ir para a Lista de Verificação de Configuração \uD83D\uDC49",
"hide_embed_code": "Ocultar código de incorporação", "hide_embed_code": "Ocultar código de incorporação",
"how_to_create_a_panel": "Como criar um painel", "how_to_create_a_panel": "Como criar um painel",
@@ -1766,12 +1771,18 @@
"last_quarter": "Último trimestre", "last_quarter": "Último trimestre",
"last_year": "Ano passado", "last_year": "Ano passado",
"link_to_public_results_copied": "Link para resultados públicos copiado", "link_to_public_results_copied": "Link para resultados públicos copiado",
"links_generated_success_toast": "Links gerados com sucesso, o seu download começará em breve.",
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de inquérito está definido para", "make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de inquérito está definido para",
"mobile_app": "Aplicação móvel", "mobile_app": "Aplicação móvel",
"no_responses_found": "Nenhuma resposta encontrada", "no_responses_found": "Nenhuma resposta encontrada",
"no_segments_available": "Sem segmentos disponíveis",
"only_completed": "Apenas concluído", "only_completed": "Apenas concluído",
"other_values_found": "Outros valores encontrados", "other_values_found": "Outros valores encontrados",
"overall": "Geral", "overall": "Geral",
"personal_links": "Links pessoais",
"personal_links_upgrade_prompt_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.",
"personal_links_upgrade_prompt_title": "Utilize links pessoais com um plano superior",
"personal_links_work_with_segments": "Os links pessoais funcionam com segmentos.",
"publish_to_web": "Publicar na web", "publish_to_web": "Publicar na web",
"publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.", "publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.",
"publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.", "publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.",
@@ -1780,6 +1791,7 @@
"quickstart_web_apps": "Início rápido: Aplicações web", "quickstart_web_apps": "Início rápido: Aplicações web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:", "quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"results_are_public": "Os resultados são públicos", "results_are_public": "Os resultados são públicos",
"select_segment": "Selecionar segmento",
"selected_responses_csv": "Respostas selecionadas (CSV)", "selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)", "selected_responses_excel": "Respostas selecionadas (Excel)",
"send_preview": "Enviar pré-visualização", "send_preview": "Enviar pré-visualização",
@@ -1787,6 +1799,7 @@
"setup_instructions": "Instruções de configuração", "setup_instructions": "Instruções de configuração",
"setup_integrations": "Configurar integrações", "setup_integrations": "Configurar integrações",
"share_results": "Partilhar resultados", "share_results": "Partilhar resultados",
"share_survey": "Partilhar inquérito",
"share_the_link": "Partilhar o link", "share_the_link": "Partilhar o link",
"share_the_link_to_get_responses": "Partilhe o link para obter respostas", "share_the_link_to_get_responses": "Partilhe o link para obter respostas",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem", "show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
@@ -1804,6 +1817,7 @@
"this_year": "Este ano", "this_year": "Este ano",
"time_to_complete": "Tempo para Concluir", "time_to_complete": "Tempo para Concluir",
"to_connect_your_website_with_formbricks": "para ligar o seu website ao Formbricks", "to_connect_your_website_with_formbricks": "para ligar o seu website ao Formbricks",
"to_create_personal_links_segment_required": "Para criar links pessoais para o seu inquérito, é necessário configurar primeiro um segmento.",
"ttc_tooltip": "Tempo médio para concluir o inquérito.", "ttc_tooltip": "Tempo médio para concluir o inquérito.",
"unknown_question_type": "Tipo de Pergunta Desconhecido", "unknown_question_type": "Tipo de Pergunta Desconhecido",
"unpublish_from_web": "Despublicar da web", "unpublish_from_web": "Despublicar da web",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "O que, se alguma coisa, o está a impedir de fazer uma compra hoje?", "understand_purchase_intention_question_3_headline": "O que, se alguma coisa, o está a impedir de fazer uma compra hoje?",
"understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui..." "understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui..."
} }
} }
+44 -30
View File
@@ -34,7 +34,8 @@
"text": "您現在可以使用新密碼登入" "text": "您現在可以使用新密碼登入"
} }
}, },
"reset_password": "重設密碼" "reset_password": "重設密碼",
"reset_password_description": "您將被登出以重設您的密碼。"
}, },
"invite": { "invite": {
"create_account": "建立帳戶", "create_account": "建立帳戶",
@@ -107,6 +108,10 @@
"thanks_for_upgrading": "非常感謝您升級您的 Formbricks 訂閱。", "thanks_for_upgrading": "非常感謝您升級您的 Formbricks 訂閱。",
"upgrade_successful": "升級成功" "upgrade_successful": "升級成功"
}, },
"c": {
"link_expired": "您 的 連結 已過期。",
"link_expired_description": "您 使用 的 連結 已無效。"
},
"common": { "common": {
"accepted": "已接受", "accepted": "已接受",
"account": "帳戶", "account": "帳戶",
@@ -191,7 +196,6 @@
"e_commerce": "電子商務", "e_commerce": "電子商務",
"edit": "編輯", "edit": "編輯",
"email": "電子郵件", "email": "電子郵件",
"embed": "嵌入",
"enterprise_license": "企業授權", "enterprise_license": "企業授權",
"environment_not_found": "找不到環境", "environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。", "environment_notice": "您目前在 '{'environment'}' 環境中。",
@@ -309,14 +313,15 @@
"project_not_found": "找不到專案", "project_not_found": "找不到專案",
"project_permission_not_found": "找不到專案權限", "project_permission_not_found": "找不到專案權限",
"projects": "專案", "projects": "專案",
"projects_limit_reached": "已達到專案上限",
"question": "問題", "question": "問題",
"question_id": "問題 ID", "question_id": "問題 ID",
"questions": "問題", "questions": "問題",
"read_docs": "閱讀文件", "read_docs": "閱讀文件",
"recipients": "收件者",
"remove": "移除", "remove": "移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位", "reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷", "report_survey": "報告問卷",
"request_pricing": "請求定價",
"request_trial_license": "請求試用授權", "request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值", "reset_to_default": "重設為預設值",
"response": "回應", "response": "回應",
@@ -597,6 +602,7 @@
"contact_not_found": "找不到此聯絡人", "contact_not_found": "找不到此聯絡人",
"contacts_table_refresh": "重新整理聯絡人", "contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理", "contacts_table_refresh_success": "聯絡人已成功重新整理",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"first_name": "名字", "first_name": "名字",
"last_name": "姓氏", "last_name": "姓氏",
"no_responses_found": "找不到回應", "no_responses_found": "找不到回應",
@@ -633,6 +639,7 @@
"airtable_integration": "Airtable 整合", "airtable_integration": "Airtable 整合",
"airtable_integration_description": "直接與 Airtable 同步回應。", "airtable_integration_description": "直接與 Airtable 同步回應。",
"airtable_integration_is_not_configured": "尚未設定 Airtable 整合", "airtable_integration_is_not_configured": "尚未設定 Airtable 整合",
"airtable_logo": "Airtable 標誌",
"connect_with_airtable": "連線 Airtable", "connect_with_airtable": "連線 Airtable",
"link_airtable_table": "連結 Airtable 表格", "link_airtable_table": "連結 Airtable 表格",
"link_new_table": "連結新表格", "link_new_table": "連結新表格",
@@ -722,6 +729,7 @@
"slack_integration": "Slack 整合", "slack_integration": "Slack 整合",
"slack_integration_description": "直接將回應傳送至 Slack。", "slack_integration_description": "直接將回應傳送至 Slack。",
"slack_integration_is_not_configured": "您的 Formbricks 執行個體中尚未設定 Slack 整合。", "slack_integration_is_not_configured": "您的 Formbricks 執行個體中尚未設定 Slack 整合。",
"slack_logo": "Slack 標誌",
"slack_reconnect_button": "重新連線", "slack_reconnect_button": "重新連線",
"slack_reconnect_button_description": "<b>注意:</b>我們最近變更了我們的 Slack 整合以支援私人頻道。請重新連線您的 Slack 工作區。" "slack_reconnect_button_description": "<b>注意:</b>我們最近變更了我們的 Slack 整合以支援私人頻道。請重新連線您的 Slack 工作區。"
}, },
@@ -906,8 +914,7 @@
"tag_already_exists": "標籤已存在", "tag_already_exists": "標籤已存在",
"tag_deleted": "標籤已刪除", "tag_deleted": "標籤已刪除",
"tag_updated": "標籤已更新", "tag_updated": "標籤已更新",
"tags_merged": "標籤已合併", "tags_merged": "標籤已合併"
"unique_constraint_failed_on_the_fields": "欄位上唯一性限制失敗"
}, },
"teams": { "teams": {
"manage_teams": "管理團隊", "manage_teams": "管理團隊",
@@ -980,63 +987,53 @@
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API" "api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10000 個每月回應", "1000_monthly_responses": "1000 個每月回應",
"1500_monthly_responses": "1500 個每月回應", "1_project": "1 個專案",
"2000_monthly_identified_users": "2000 個每月識別使用者", "2000_contacts": "2000 個聯絡人",
"30000_monthly_identified_users": "30000 個每月識別使用者",
"3_projects": "3 個專案", "3_projects": "3 個專案",
"5000_monthly_responses": "5000 個每月回應", "5000_monthly_responses": "5000 個每月回應",
"5_projects": "5 個專案", "7500_contacts": "7500 個聯絡人",
"7500_monthly_identified_users": "7500 個每月識別使用者",
"advanced_targeting": "進階目標設定",
"all_integrations": "所有整合", "all_integrations": "所有整合",
"all_surveying_features": "所有調查功能",
"annually": "每年", "annually": "每年",
"api_webhooks": "API 和 Webhook", "api_webhooks": "API 和 Webhook",
"app_surveys": "應用程式問卷", "app_surveys": "應用程式問卷",
"contact_us": "聯絡我們", "attribute_based_targeting": "基於屬性的定位",
"current": "目前", "current": "目前",
"current_plan": "目前方案", "current_plan": "目前方案",
"current_tier_limit": "目前層級限制", "current_tier_limit": "目前層級限制",
"custom_miu_limit": "自訂 MIU 上限", "custom": "自訂 & 規模",
"custom_contacts_limit": "自訂聯絡人上限",
"custom_project_limit": "自訂專案上限", "custom_project_limit": "自訂專案上限",
"customer_success_manager": "客戶成功經理", "custom_response_limit": "自訂回應上限",
"email_embedded_surveys": "電子郵件嵌入式問卷", "email_embedded_surveys": "電子郵件嵌入式問卷",
"email_support": "電子郵件支援", "email_follow_ups": "電子郵件後續追蹤",
"enterprise": "企業版",
"enterprise_description": "頂級支援和自訂限制。", "enterprise_description": "頂級支援和自訂限制。",
"everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!", "everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!",
"everything_in_free": "免費方案中的所有功能", "everything_in_free": "免費方案中的所有功能",
"everything_in_scale": "進階方案中的所有功能",
"everything_in_startup": "啟動方案中的所有功能", "everything_in_startup": "啟動方案中的所有功能",
"free": "免費", "free": "免費",
"free_description": "無限問卷、團隊成員等。", "free_description": "無限問卷、團隊成員等。",
"get_2_months_free": "免費獲得 2 個月", "get_2_months_free": "免費獲得 2 個月",
"get_in_touch": "取得聯繫", "get_in_touch": "取得聯繫",
"hosted_in_frankfurt": "託管在 Frankfurt",
"ios_android_sdks": "iOS 和 Android SDK 用於行動問卷",
"link_surveys": "連結問卷(可分享)", "link_surveys": "連結問卷(可分享)",
"logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。", "logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。",
"manage_card_details": "管理卡片詳細資料", "manage_card_details": "管理卡片詳細資料",
"manage_subscription": "管理訂閱", "manage_subscription": "管理訂閱",
"monthly": "每月", "monthly": "每月",
"monthly_identified_users": "每月識別使用者", "monthly_identified_users": "每月識別使用者",
"multi_language_surveys": "多語言問卷",
"per_month": "每月", "per_month": "每月",
"per_year": "每年", "per_year": "每年",
"plan_upgraded_successfully": "方案已成功升級", "plan_upgraded_successfully": "方案已成功升級",
"premium_support_with_slas": "具有 SLA 的頂級支援", "premium_support_with_slas": "具有 SLA 的頂級支援",
"priority_support": "優先支援",
"remove_branding": "移除品牌", "remove_branding": "移除品牌",
"say_hi": "打個招呼!",
"scale": "進階版",
"scale_description": "用於擴展業務的進階功能。",
"startup": "啟動版", "startup": "啟動版",
"startup_description": "免費方案中的所有功能以及其他功能。", "startup_description": "免費方案中的所有功能以及其他功能。",
"switch_plan": "切換方案", "switch_plan": "切換方案",
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。", "switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
"team_access_roles": "團隊存取角色", "team_access_roles": "團隊存取角色",
"technical_onboarding": "技術新手上路",
"unable_to_upgrade_plan": "無法升級方案", "unable_to_upgrade_plan": "無法升級方案",
"unlimited_apps_websites": "無限應用程式和網站",
"unlimited_miu": "無限 MIU", "unlimited_miu": "無限 MIU",
"unlimited_projects": "無限專案", "unlimited_projects": "無限專案",
"unlimited_responses": "無限回應", "unlimited_responses": "無限回應",
@@ -1075,6 +1072,7 @@
"create_new_organization": "建立新組織", "create_new_organization": "建立新組織",
"create_new_organization_description": "建立新組織以處理一組不同的專案。", "create_new_organization_description": "建立新組織以處理一組不同的專案。",
"customize_email_with_a_higher_plan": "使用更高等級的方案自訂電子郵件", "customize_email_with_a_higher_plan": "使用更高等級的方案自訂電子郵件",
"delete_member_confirmation": "刪除的成員將失去存取您組織的所有專案和問卷的權限。",
"delete_organization": "刪除組織", "delete_organization": "刪除組織",
"delete_organization_description": "刪除包含所有專案的組織,包括所有問卷、回應、人員、操作和屬性", "delete_organization_description": "刪除包含所有專案的組織,包括所有問卷、回應、人員、操作和屬性",
"delete_organization_warning": "在您繼續刪除此組織之前,請注意以下後果:", "delete_organization_warning": "在您繼續刪除此組織之前,請注意以下後果:",
@@ -1231,8 +1229,9 @@
"copy_survey_description": "將此問卷複製到另一個環境", "copy_survey_description": "將此問卷複製到另一個環境",
"copy_survey_error": "無法複製問卷", "copy_survey_error": "無法複製問卷",
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿", "copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
"copy_survey_success": "問卷已成功複製!", "copy_survey_success": "問卷已成功複製!",
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?此操作無法復原。", "delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:", "1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:",
"2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:", "2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:",
@@ -1305,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列", "card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
"card_background_color": "卡片背景顏色", "card_background_color": "卡片背景顏色",
"card_border_color": "卡片邊框顏色", "card_border_color": "卡片邊框顏色",
"card_shadow_color": "卡片陰影顏色",
"card_styling": "卡片樣式設定", "card_styling": "卡片樣式設定",
"casual": "隨意", "casual": "隨意",
"caution_edit_duplicate": "複製 & 編輯", "caution_edit_duplicate": "複製 & 編輯",
@@ -1330,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "變更問卷的品牌顏色。", "change_the_brand_color_of_the_survey": "變更問卷的品牌顏色。",
"change_the_placement_of_this_survey": "變更此問卷的位置。", "change_the_placement_of_this_survey": "變更此問卷的位置。",
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。", "change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
"change_the_shadow_color_of_the_card": "變更卡片的陰影顏色。",
"changes_saved": "已儲存變更。", "changes_saved": "已儲存變更。",
"character_limit_toggle_description": "限制答案的長度或短度。", "character_limit_toggle_description": "限制答案的長度或短度。",
"character_limit_toggle_title": "新增字元限制", "character_limit_toggle_title": "新增字元限制",
@@ -1717,6 +1714,7 @@
"congrats": "恭喜!您的問卷已上線。", "congrats": "恭喜!您的問卷已上線。",
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。", "connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
"copy_link_to_public_results": "複製公開結果的連結", "copy_link_to_public_results": "複製公開結果的連結",
"create_and_manage_segments": "在 聯絡人 > 分段 中建立和管理您的分段",
"create_single_use_links": "建立單次使用連結", "create_single_use_links": "建立單次使用連結",
"create_single_use_links_description": "每個連結只接受一次提交。以下是如何操作。", "create_single_use_links_description": "每個連結只接受一次提交。以下是如何操作。",
"custom_range": "自訂範圍...", "custom_range": "自訂範圍...",
@@ -1735,12 +1733,19 @@
"embed_on_website": "嵌入網站", "embed_on_website": "嵌入網站",
"embed_pop_up_survey_title": "如何在您的網站上嵌入彈出式問卷", "embed_pop_up_survey_title": "如何在您的網站上嵌入彈出式問卷",
"embed_survey": "嵌入問卷", "embed_survey": "嵌入問卷",
"expiry_date_description": "一旦連結過期,收件者將無法再回應 survey。",
"expiry_date_optional": "到期日 (可選)",
"failed_to_copy_link": "無法複製連結", "failed_to_copy_link": "無法複製連結",
"filter_added_successfully": "篩選器已成功新增", "filter_added_successfully": "篩選器已成功新增",
"filter_updated_successfully": "篩選器已成功更新", "filter_updated_successfully": "篩選器已成功更新",
"filtered_responses_csv": "篩選回應 (CSV)", "filtered_responses_csv": "篩選回應 (CSV)",
"filtered_responses_excel": "篩選回應 (Excel)", "filtered_responses_excel": "篩選回應 (Excel)",
"formbricks_email_survey_preview": "Formbricks 電子郵件問卷預覽", "formbricks_email_survey_preview": "Formbricks 電子郵件問卷預覽",
"generate_and_download_links": "生成 & 下載 連結",
"generate_personal_links_description": "為 一個 群組 生成 個人 連結,並 將 調查 回應 對應 到 每個 聯絡人。含 有 相關 聯絡信息 的 個人 連結 CSV 會 自動 下載。",
"generate_personal_links_title": "透過個人化調查連結最大化洞察",
"generating_links": "生成 連結",
"generating_links_toast": "生成 連結,下載 將 會 很快 開始…",
"go_to_setup_checklist": "前往設定檢查清單 \uD83D\uDC49", "go_to_setup_checklist": "前往設定檢查清單 \uD83D\uDC49",
"hide_embed_code": "隱藏嵌入程式碼", "hide_embed_code": "隱藏嵌入程式碼",
"how_to_create_a_panel": "如何建立小組", "how_to_create_a_panel": "如何建立小組",
@@ -1766,12 +1771,18 @@
"last_quarter": "上一季", "last_quarter": "上一季",
"last_year": "去年", "last_year": "去年",
"link_to_public_results_copied": "已複製公開結果的連結", "link_to_public_results_copied": "已複製公開結果的連結",
"links_generated_success_toast": "連結 成功 生成,您的 下載 將 會 很快 開始。",
"make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為", "make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為",
"mobile_app": "行動應用程式", "mobile_app": "行動應用程式",
"no_responses_found": "找不到回應", "no_responses_found": "找不到回應",
"no_segments_available": "沒有可用的區段",
"only_completed": "僅已完成", "only_completed": "僅已完成",
"other_values_found": "找到其他值", "other_values_found": "找到其他值",
"overall": "整體", "overall": "整體",
"personal_links": "個人 連結",
"personal_links_upgrade_prompt_description": "為一個群組生成個人連結,並將調查回應連結到每個聯絡人。",
"personal_links_upgrade_prompt_title": "使用 個人 連結 與 更高 的 計劃",
"personal_links_work_with_segments": "個人 連結 可 與 分段 一起 使用",
"publish_to_web": "發布至網站", "publish_to_web": "發布至網站",
"publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。", "publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。",
"publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。", "publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。",
@@ -1780,6 +1791,7 @@
"quickstart_web_apps": "快速入門:Web apps", "quickstart_web_apps": "快速入門:Web apps",
"quickstart_web_apps_description": "請按照 Quickstart 指南開始:", "quickstart_web_apps_description": "請按照 Quickstart 指南開始:",
"results_are_public": "結果是公開的", "results_are_public": "結果是公開的",
"select_segment": "選擇 區隔",
"selected_responses_csv": "選擇的回應 (CSV)", "selected_responses_csv": "選擇的回應 (CSV)",
"selected_responses_excel": "選擇的回應 (Excel)", "selected_responses_excel": "選擇的回應 (Excel)",
"send_preview": "發送預覽", "send_preview": "發送預覽",
@@ -1787,6 +1799,7 @@
"setup_instructions": "設定說明", "setup_instructions": "設定說明",
"setup_integrations": "設定整合", "setup_integrations": "設定整合",
"share_results": "分享結果", "share_results": "分享結果",
"share_survey": "分享問卷",
"share_the_link": "分享連結", "share_the_link": "分享連結",
"share_the_link_to_get_responses": "分享連結以取得回應", "share_the_link_to_get_responses": "分享連結以取得回應",
"show_all_responses_that_match": "顯示所有相符的回應", "show_all_responses_that_match": "顯示所有相符的回應",
@@ -1804,6 +1817,7 @@
"this_year": "今年", "this_year": "今年",
"time_to_complete": "完成時間", "time_to_complete": "完成時間",
"to_connect_your_website_with_formbricks": "以將您的網站與 Formbricks 連線", "to_connect_your_website_with_formbricks": "以將您的網站與 Formbricks 連線",
"to_create_personal_links_segment_required": "要為 問卷 創建 個人連結,您 必須先 設置 一個 分段。",
"ttc_tooltip": "完成問卷的平均時間。", "ttc_tooltip": "完成問卷的平均時間。",
"unknown_question_type": "未知的問題類型", "unknown_question_type": "未知的問題類型",
"unpublish_from_web": "從網站取消發布", "unpublish_from_web": "從網站取消發布",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "有什麼阻礙您今天進行購買嗎?", "understand_purchase_intention_question_3_headline": "有什麼阻礙您今天進行購買嗎?",
"understand_purchase_intention_question_3_placeholder": "在此輸入您的答案..." "understand_purchase_intention_question_3_placeholder": "在此輸入您的答案..."
} }
} }
@@ -1,4 +1,3 @@
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
@@ -100,8 +99,6 @@ describe("DeleteAccountModal", () => {
/> />
); );
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
const input = screen.getByTestId("deleteAccountConfirmation"); const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } }); fireEvent.change(input, { target: { value: mockUser.email } });
@@ -113,8 +110,8 @@ describe("DeleteAccountModal", () => {
expect(mockSignOut).toHaveBeenCalledWith({ expect(mockSignOut).toHaveBeenCalledWith({
reason: "account_deletion", reason: "account_deletion",
redirect: false, // Updated to match new implementation redirect: false, // Updated to match new implementation
clearEnvironmentId: true,
}); });
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(window.location.replace).toHaveBeenCalledWith("/auth/login"); expect(window.location.replace).toHaveBeenCalledWith("/auth/login");
expect(mockSetOpen).toHaveBeenCalledWith(false); expect(mockSetOpen).toHaveBeenCalledWith(false);
}); });
@@ -151,15 +148,13 @@ describe("DeleteAccountModal", () => {
const form = screen.getByTestId("deleteAccountForm"); const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form); fireEvent.submit(form);
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
await waitFor(() => { await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled(); expect(deleteUserAction).toHaveBeenCalled();
expect(mockSignOut).toHaveBeenCalledWith({ expect(mockSignOut).toHaveBeenCalledWith({
reason: "account_deletion", reason: "account_deletion",
redirect: false, // Updated to match new implementation redirect: false, // Updated to match new implementation
clearEnvironmentId: true,
}); });
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(window.location.replace).toHaveBeenCalledWith( expect(window.location.replace).toHaveBeenCalledWith(
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2" "https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2"
); );
@@ -1,6 +1,5 @@
"use client"; "use client";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
@@ -39,12 +38,11 @@ export const DeleteAccountModal = ({
setDeleting(true); setDeleting(true);
await deleteUserAction(); await deleteUserAction();
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
// Sign out with account deletion reason (no automatic redirect) // Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({ await signOutWithAudit({
reason: "account_deletion", reason: "account_deletion",
redirect: false, // Prevent NextAuth automatic redirect redirect: false, // Prevent NextAuth automatic redirect
clearEnvironmentId: true,
}); });
// Manual redirect after signOut completes // Manual redirect after signOut completes
@@ -13,6 +13,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
autoFocus={true} autoFocus={true}
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent" className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
value={surveyUrl} value={surveyUrl}
readOnly
/> />
) : ( ) : (
//loading state //loading state
@@ -50,8 +50,14 @@ export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction
}); });
ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createTag(parsedInput.environmentId, parsedInput.tagName); const result = await createTag(parsedInput.environmentId, parsedInput.tagName);
ctx.auditLoggingCtx.tagId = result.id;
ctx.auditLoggingCtx.newObject = result; if (result.ok) {
ctx.auditLoggingCtx.tagId = result.data.id;
ctx.auditLoggingCtx.newObject = result.data;
} else {
ctx.auditLoggingCtx.newObject = null;
}
return result; return result;
} }
) )
@@ -1,3 +1,5 @@
import { TagError } from "@/modules/projects/settings/types/tag";
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -150,7 +152,9 @@ describe("ResponseTagsWrapper", () => {
}); });
test("creates a new tag via TagsCombobox and calls updateFetchedResponses on success", async () => { test("creates a new tag via TagsCombobox and calls updateFetchedResponses on success", async () => {
vi.mocked(createTagAction).mockResolvedValueOnce({ data: { id: "newTagId", name: "NewTag" } } as any); vi.mocked(createTagAction).mockResolvedValueOnce({
data: { ok: true, data: { id: "newTagId", name: "NewTag" } },
} as any);
vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any); vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any);
render( render(
<ResponseTagsWrapper <ResponseTagsWrapper
@@ -176,7 +180,10 @@ describe("ResponseTagsWrapper", () => {
test("handles createTagAction failure and shows toast error", async () => { test("handles createTagAction failure and shows toast error", async () => {
vi.mocked(createTagAction).mockResolvedValueOnce({ vi.mocked(createTagAction).mockResolvedValueOnce({
error: { details: [{ issue: "Unique constraint failed on the fields" }] }, data: {
ok: false,
error: { message: "Unique constraint failed on the fields", code: TagError.TAG_NAME_ALREADY_EXISTS },
},
} as any); } as any);
render( render(
<ResponseTagsWrapper <ResponseTagsWrapper
@@ -1,6 +1,7 @@
"use client"; "use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TagError } from "@/modules/projects/settings/types/tag";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Tag } from "@/modules/ui/components/tag"; import { Tag } from "@/modules/ui/components/tag";
import { TagsCombobox } from "@/modules/ui/components/tags-combobox"; import { TagsCombobox } from "@/modules/ui/components/tags-combobox";
@@ -58,6 +59,57 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [tagIdToHighlight]); }, [tagIdToHighlight]);
const handleCreateTag = async (tagName: string) => {
setOpen(false);
const createTagResponse = await createTagAction({
environmentId,
tagName: tagName?.trim() ?? "",
});
if (createTagResponse?.data?.ok) {
const tag = createTagResponse.data.data;
setTagsState((prevTags) => [
...prevTags,
{
tagId: tag.id,
tagName: tag.name,
},
]);
const createTagToResponseActionResponse = await createTagToResponseAction({
responseId,
tagId: tag.id,
});
if (createTagToResponseActionResponse?.data) {
updateFetchedResponses();
setSearchValue("");
} else {
const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse);
toast.error(errorMessage);
}
return;
}
if (createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS) {
toast.error(t("environments.surveys.responses.tag_already_exists"), {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
});
setSearchValue("");
return;
}
const errorMessage = getFormattedErrorMessage(createTagResponse);
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
setSearchValue("");
};
return ( return (
<div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4"> <div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4">
{!isReadOnly && ( {!isReadOnly && (
@@ -93,46 +145,7 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
setSearchValue={setSearchValue} setSearchValue={setSearchValue}
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []} tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))} currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
createTag={async (tagName) => { createTag={handleCreateTag}
setOpen(false);
const createTagResponse = await createTagAction({
environmentId,
tagName: tagName?.trim() ?? "",
});
if (createTagResponse?.data) {
setTagsState((prevTags) => [
...prevTags,
{
tagId: createTagResponse.data?.id ?? "",
tagName: createTagResponse.data?.name ?? "",
},
]);
const createTagToResponseActionResponse = await createTagToResponseAction({
responseId,
tagId: createTagResponse.data.id,
});
if (createTagToResponseActionResponse?.data) {
updateFetchedResponses();
setSearchValue("");
}
} else {
const errorMessage = getFormattedErrorMessage(createTagResponse);
if (errorMessage.includes("Unique constraint failed on the fields")) {
toast.error(t("environments.surveys.responses.tag_already_exists"), {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
});
} else {
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
}
setSearchValue("");
}
}}
addTag={(tagId) => { addTag={(tagId) => {
setTagsState((prevTags) => [ setTagsState((prevTags) => [
...prevTags, ...prevTags,
@@ -150,9 +150,10 @@ export const SingleResponseCard = ({
<DeleteDialog <DeleteDialog
open={deleteDialogOpen} open={deleteDialogOpen}
setOpen={setDeleteDialogOpen} setOpen={setDeleteDialogOpen}
deleteWhat="response" deleteWhat={t("common.response")}
onDelete={handleDeleteResponse} onDelete={handleDeleteResponse}
isDeleting={isDeleting} isDeleting={isDeleting}
text={t("environments.surveys.responses.delete_response_confirmation")}
/> />
</div> </div>
{user && pageType === "response" && ( {user && pageType === "response" && (
+2 -1
View File
@@ -33,10 +33,11 @@ export const validateOtherOptionLengthForMultipleChoice = ({
surveyQuestions, surveyQuestions,
responseLanguage, responseLanguage,
}: { }: {
responseData: TResponseData; responseData?: TResponseData;
surveyQuestions: TSurveyQuestion[]; surveyQuestions: TSurveyQuestion[];
responseLanguage?: string; responseLanguage?: string;
}): string | undefined => { }): string | undefined => {
if (!responseData) return undefined;
for (const [questionId, answer] of Object.entries(responseData)) { for (const [questionId, answer] of Object.entries(responseData)) {
const question = surveyQuestions.find((q) => q.id === questionId); const question = surveyQuestions.find((q) => q.id === questionId);
if (!question) continue; if (!question) continue;
@@ -1,7 +1,6 @@
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ContactAttributeKey } from "@prisma/client"; import { ContactAttributeKey, Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error"; import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -55,7 +54,7 @@ export const updateContactAttributeKey = async (
return ok(updatedKey); return ok(updatedKey);
} catch (error) { } catch (error) {
if (error instanceof PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
if ( if (
error.code === PrismaErrorType.RecordDoesNotExist || error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist error.code === PrismaErrorType.RelatedRecordDoesNotExist
@@ -106,7 +105,7 @@ export const deleteContactAttributeKey = async (
return ok(deletedKey); return ok(deletedKey);
} catch (error) { } catch (error) {
if (error instanceof PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
if ( if (
error.code === PrismaErrorType.RecordDoesNotExist || error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist error.code === PrismaErrorType.RelatedRecordDoesNotExist
@@ -16,7 +16,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
id: ZContactAttributeKeyIdSchema, id: ZContactAttributeKeyIdSchema,
}), }),
}, },
tags: ["Management API > Contact Attribute Keys"], tags: ["Management API - Contact Attribute Keys"],
responses: { responses: {
"200": { "200": {
description: "Contact attribute key retrieved successfully.", description: "Contact attribute key retrieved successfully.",
@@ -33,7 +33,7 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttributeKey", operationId: "updateContactAttributeKey",
summary: "Update a contact attribute key", summary: "Update a contact attribute key",
description: "Updates a contact attribute key in the database.", description: "Updates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"], tags: ["Management API - Contact Attribute Keys"],
requestParams: { requestParams: {
path: z.object({ path: z.object({
id: ZContactAttributeKeyIdSchema, id: ZContactAttributeKeyIdSchema,
@@ -64,7 +64,7 @@ export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttributeKey", operationId: "deleteContactAttributeKey",
summary: "Delete a contact attribute key", summary: "Delete a contact attribute key",
description: "Deletes a contact attribute key from the database.", description: "Deletes a contact attribute key from the database.",
tags: ["Management API > Contact Attribute Keys"], tags: ["Management API - Contact Attribute Keys"],
requestParams: { requestParams: {
path: z.object({ path: z.object({
id: ZContactAttributeKeyIdSchema, id: ZContactAttributeKeyIdSchema,
@@ -1,6 +1,5 @@
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { ContactAttributeKey } from "@prisma/client"; import { ContactAttributeKey, Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error"; import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -44,12 +43,12 @@ const mockUpdateInput: TContactAttributeKeyUpdateSchema = {
description: "User's verified email address", description: "User's verified email address",
}; };
const prismaNotFoundError = new PrismaClientKnownRequestError("Mock error message", { const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist, code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1", clientVersion: "0.0.1",
}); });
const prismaUniqueConstraintError = new PrismaClientKnownRequestError("Mock error message", { const prismaUniqueConstraintError = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation, code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1", clientVersion: "0.0.1",
}); });
@@ -5,7 +5,6 @@ import {
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ContactAttributeKey, Prisma } from "@prisma/client"; import { ContactAttributeKey, Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error"; import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -58,7 +57,7 @@ export const createContactAttributeKey = async (
return ok(createdContactAttributeKey); return ok(createdContactAttributeKey);
} catch (error) { } catch (error) {
if (error instanceof PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
if ( if (
error.code === PrismaErrorType.RecordDoesNotExist || error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist error.code === PrismaErrorType.RelatedRecordDoesNotExist
@@ -16,7 +16,7 @@ export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributeKeys", operationId: "getContactAttributeKeys",
summary: "Get contact attribute keys", summary: "Get contact attribute keys",
description: "Gets contact attribute keys from the database.", description: "Gets contact attribute keys from the database.",
tags: ["Management API > Contact Attribute Keys"], tags: ["Management API - Contact Attribute Keys"],
requestParams: { requestParams: {
query: ZGetContactAttributeKeysFilter.sourceType(), query: ZGetContactAttributeKeysFilter.sourceType(),
}, },
@@ -36,7 +36,7 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "createContactAttributeKey", operationId: "createContactAttributeKey",
summary: "Create a contact attribute key", summary: "Create a contact attribute key",
description: "Creates a contact attribute key in the database.", description: "Creates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"], tags: ["Management API - Contact Attribute Keys"],
requestBody: { requestBody: {
required: true, required: true,
description: "The contact attribute key to create", description: "The contact attribute key to create",
@@ -2,8 +2,7 @@ import {
TContactAttributeKeyInput, TContactAttributeKeyInput,
TGetContactAttributeKeysFilter, TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ContactAttributeKey } from "@prisma/client"; import { ContactAttributeKey, Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error"; import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -106,7 +105,7 @@ describe("createContactAttributeKey", () => {
}); });
test("returns conflict error when key already exists", async () => { test("returns conflict error when key already exists", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", { const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation, code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1", clientVersion: "0.0.1",
}); });
@@ -129,7 +128,7 @@ describe("createContactAttributeKey", () => {
}); });
test("returns not found error when related record does not exist", async () => { test("returns not found error when related record does not exist", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", { const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist, code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1", clientVersion: "0.0.1",
}); });

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