mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
88 Commits
docs-quota
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cb3881cd0 | ||
|
|
ec78038caa | ||
|
|
908614b4e2 | ||
|
|
c584901337 | ||
|
|
087699fb57 | ||
|
|
58213969e8 | ||
|
|
ef973c8995 | ||
|
|
bea02ba3b5 | ||
|
|
1c1e2ee09c | ||
|
|
2bf7fe6c54 | ||
|
|
9639402c39 | ||
|
|
53213b41ee | ||
|
|
b8b5eead7a | ||
|
|
a0044ce376 | ||
|
|
b3a1f24683 | ||
|
|
f06d48698a | ||
|
|
acd508ba19 | ||
|
|
e5591686b4 | ||
|
|
7be7466eee | ||
|
|
8af6c15998 | ||
|
|
17d60eb1e7 | ||
|
|
d6ecafbc23 | ||
|
|
599e847686 | ||
|
|
4e52556f7e | ||
|
|
492a59e7de | ||
|
|
e0be53805e | ||
|
|
5c2860d1a4 | ||
|
|
18ba5bbd8a | ||
|
|
572b613034 | ||
|
|
a9c7140ba6 | ||
|
|
7fa95cd74a | ||
|
|
8c7f36d496 | ||
|
|
42dcbd3e7e | ||
|
|
1c1cd99510 | ||
|
|
b0a7e212dd | ||
|
|
0c1f6f3c3a | ||
|
|
9399b526b8 | ||
|
|
cd60032bc9 | ||
|
|
a941f994ea | ||
|
|
75d170bce5 | ||
|
|
16caae6dd6 | ||
|
|
a490600479 | ||
|
|
be28641722 | ||
|
|
4fdea3221b | ||
|
|
fef30c54b2 | ||
|
|
75362eac7a | ||
|
|
6e3b224944 | ||
|
|
ef1be219b4 | ||
|
|
ba9b01a969 | ||
|
|
e810e38333 | ||
|
|
dab8ad00d5 | ||
|
|
2c34f43c83 | ||
|
|
979fd71a11 | ||
|
|
1be23eebbb | ||
|
|
d10cff917d | ||
|
|
da72101320 | ||
|
|
5f02ad49c1 | ||
|
|
6644bba6ea | ||
|
|
0b7734f725 | ||
|
|
1536bf6907 | ||
|
|
e81190214f | ||
|
|
48c8906a89 | ||
|
|
717b30115b | ||
|
|
1f3962d2d5 | ||
|
|
619f6e408f | ||
|
|
4a8719abaa | ||
|
|
7b59eb3b26 | ||
|
|
8ac280268d | ||
|
|
34e8f4931d | ||
|
|
ac46850a24 | ||
|
|
6328be220a | ||
|
|
882ad99ed7 | ||
|
|
ce47b4c2d8 | ||
|
|
ce8f9de8ec | ||
|
|
ed3c2d2b58 | ||
|
|
9ae226329b | ||
|
|
12c3899b85 | ||
|
|
ccb1353eb5 | ||
|
|
22eb0b79ee | ||
|
|
5eb7a496da | ||
|
|
7ea55e199f | ||
|
|
83eb472acd | ||
|
|
d9fe6ee4f4 | ||
|
|
51b58be079 | ||
|
|
397643330a | ||
|
|
e5fa4328e1 | ||
|
|
4b777f1907 | ||
|
|
c3547ccb36 |
216
.cursor/rules/component-migration.mdc
Normal file
216
.cursor/rules/component-migration.mdc
Normal 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)
|
||||
@@ -189,7 +189,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
@@ -210,6 +209,8 @@ UNKEY_ROOT_KEY=
|
||||
# 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.
|
||||
# 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)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
@@ -217,7 +218,7 @@ UNKEY_ROOT_KEY=
|
||||
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
||||
# SESSION_MAX_AGE=86400
|
||||
|
||||
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
|
||||
# Audit logs options. Default 0.
|
||||
# AUDIT_LOG_ENABLED=0
|
||||
# If the ip should be added in the log or not. Default 0
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Feature request
|
||||
description: "Suggest an idea for this project \U0001F680"
|
||||
type: feature
|
||||
projects: "formbricks/21"
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/task.yml
vendored
11
.github/ISSUE_TEMPLATE/task.yml
vendored
@@ -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
|
||||
121
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
Normal file
121
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
Normal file
@@ -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"
|
||||
7
.github/workflows/e2e.yml
vendored
7
.github/workflows/e2e.yml
vendored
@@ -89,6 +89,7 @@ jobs:
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
@@ -102,6 +103,12 @@ jobs:
|
||||
# pnpm prisma migrate deploy
|
||||
pnpm db:migrate:dev
|
||||
|
||||
- name: Run Rate Limiter Load Tests
|
||||
run: |
|
||||
echo "Running rate limiter load tests with Redis/Valkey..."
|
||||
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
|
||||
shell: bash
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
|
||||
22
.github/workflows/formbricks-release.yml
vendored
22
.github/workflows/formbricks-release.yml
vendored
@@ -32,3 +32,25 @@ jobs:
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
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 }}
|
||||
|
||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -10,8 +10,6 @@ permissions:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
1
.github/workflows/sonarqube.yml
vendored
1
.github/workflows/sonarqube.yml
vendored
@@ -43,6 +43,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -41,6 +41,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
46
.github/workflows/upload-sentry-sourcemaps.yml
vendored
Normal file
46
.github/workflows/upload-sentry-sourcemaps.yml
vendored
Normal file
@@ -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 }}
|
||||
@@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
|
||||
|
||||
## 🛠 Crafting Pull Requests
|
||||
|
||||
Ready to dive into the code and make a real impact? Here's your path:
|
||||
|
||||
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!
|
||||
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.
|
||||
|
||||
## 🚀 Aspiring Features
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -14,10 +14,9 @@ const config: StorybookConfig = {
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-onboarding"),
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
getAbsolutePath("@storybook/addon-essentials"),
|
||||
getAbsolutePath("@chromatic-com/storybook"),
|
||||
getAbsolutePath("@storybook/addon-interactions"),
|
||||
getAbsolutePath("@storybook/addon-a11y"),
|
||||
getAbsolutePath("@storybook/addon-docs"),
|
||||
],
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/react-vite"),
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { Preview } from "@storybook/react";
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TolgeeProvider } from "@tolgee/react";
|
||||
import React from "react";
|
||||
import "../../web/modules/ui/globals.css";
|
||||
import { TolgeeBase } from "../../web/tolgee/shared";
|
||||
|
||||
// Create a Storybook-specific Tolgee decorator
|
||||
const withTolgee = (Story: any) => {
|
||||
const tolgee = TolgeeBase().init({
|
||||
tagNewKeys: [], // No branch tagging in Storybook
|
||||
});
|
||||
|
||||
return React.createElement(
|
||||
TolgeeProvider,
|
||||
{ tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } },
|
||||
React.createElement(Story)
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
@@ -10,6 +26,7 @@ const preview: Preview = {
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [withTolgee],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
||||
@@ -14,23 +14,19 @@
|
||||
"eslint-plugin-react-refresh": "0.4.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@storybook/addon-a11y": "8.6.12",
|
||||
"@storybook/addon-essentials": "8.6.12",
|
||||
"@storybook/addon-interactions": "8.6.12",
|
||||
"@storybook/addon-links": "8.6.12",
|
||||
"@storybook/addon-onboarding": "8.6.12",
|
||||
"@storybook/blocks": "8.6.12",
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@chromatic-com/storybook": "^4.0.1",
|
||||
"@storybook/addon-a11y": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/react-vite": "9.0.15",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
||||
"@typescript-eslint/parser": "8.32.0",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"esbuild": "0.25.4",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.6.12",
|
||||
"vite": "6.3.5"
|
||||
"storybook": "9.0.15",
|
||||
"vite": "6.3.5",
|
||||
"@storybook/addon-docs": "9.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta } from "@storybook/blocks";
|
||||
import { Meta } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import Accessibility from "./assets/accessibility.png";
|
||||
import AddonLibrary from "./assets/addon-library.png";
|
||||
|
||||
@@ -25,21 +25,9 @@ RUN corepack prepare pnpm@9.15.9 --activate
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
|
||||
# BuildKit secret handling without hardcoded fallback values
|
||||
# This approach relies entirely on secrets passed from GitHub Actions
|
||||
RUN echo '#!/bin/sh' > /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
|
||||
# Copy the secrets handling script
|
||||
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
|
||||
RUN chmod +x /tmp/read-secrets.sh
|
||||
|
||||
# Increase Node.js memory limit as a regular build argument
|
||||
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||
@@ -62,6 +50,9 @@ RUN touch apps/web/.env
|
||||
# Install the dependencies
|
||||
RUN pnpm install --ignore-scripts
|
||||
|
||||
# Build the database package first
|
||||
RUN pnpm build --filter=@formbricks/database
|
||||
|
||||
# Build the project using our secret reader script
|
||||
# This mounts the secrets only during this build step without storing them in layers
|
||||
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
|
||||
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
|
||||
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
|
||||
|
||||
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/packages/database/dist ./packages/database/dist
|
||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
||||
|
||||
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
|
||||
@@ -142,12 +121,14 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./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
|
||||
|
||||
# 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
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV NODE_ENV="production"
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
# Prepare volume for uploads
|
||||
@@ -158,12 +139,4 @@ VOLUME /home/nextjs/apps/web/uploads/
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
|
||||
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
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
@@ -86,7 +86,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ describe("LandingSidebar component", () => {
|
||||
organizationId: "o1",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,6 +130,7 @@ export const LandingSidebar = ({
|
||||
organizationId: organization.id,
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
|
||||
@@ -89,7 +89,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -8,23 +8,40 @@ import { ActionDetailModal } from "./ActionDetailModal";
|
||||
// Import mocked components
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
|
||||
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
|
||||
<div data-testid="modal-with-tabs">
|
||||
<span data-testid="modal-label">{label}</span>
|
||||
<span data-testid="modal-description">{description}</span>
|
||||
<span data-testid="modal-open">{open.toString()}</span>
|
||||
<button onClick={() => setOpen(false)}>Close</button>
|
||||
{icon}
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.title}>
|
||||
<h2>{tab.title}</h2>
|
||||
{tab.children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
// Mock the Dialog components
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="dialog">
|
||||
{children}
|
||||
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
|
||||
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", () => ({
|
||||
@@ -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 mockSetOpen = vi.fn();
|
||||
|
||||
@@ -89,58 +122,68 @@ describe("ActionDetailModal", () => {
|
||||
vi.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
test("renders ModalWithTabs with correct props", () => {
|
||||
test("renders correctly when open", () => {
|
||||
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();
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
test("does not render when open is false", () => {
|
||||
render(<ActionDetailModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check basic props
|
||||
expect(props.open).toBe(true);
|
||||
expect(props.setOpen).toBe(mockSetOpen);
|
||||
expect(props.label).toBe(mockActionClass.name);
|
||||
expect(props.description).toBe(mockActionClass.description);
|
||||
test("switches tabs correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
// Check icon data-testid based on the mock for the default 'code' type
|
||||
expect(props.icon).toBeDefined();
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
|
||||
// Initially shows activity tab (first tab is active)
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
|
||||
// Check tabs structure
|
||||
expect(props.tabs).toHaveLength(2);
|
||||
expect(props.tabs[0].title).toBe("common.activity");
|
||||
expect(props.tabs[1].title).toBe("common.settings");
|
||||
// Click settings tab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
|
||||
// Check if the correct mocked components are used as children
|
||||
// Access the mocked functions directly
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
|
||||
// Now shows settings tab content
|
||||
expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
// Click activity tab again
|
||||
const activityTab = screen.getByText("Activity");
|
||||
await user.click(activityTab);
|
||||
|
||||
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
|
||||
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
|
||||
// Back to activity tab content
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check props passed to child components
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
|
||||
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);
|
||||
test("resets to first tab when modal is reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.actionClass).toBe(mockActionClass);
|
||||
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
|
||||
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
|
||||
expect(settingsTabProps.isReadOnly).toBe(false);
|
||||
// Switch to settings tab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
|
||||
|
||||
// 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", () => {
|
||||
@@ -148,33 +191,68 @@ describe("ActionDetailModal", () => {
|
||||
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
expect(screen.getByTestId("nocode-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expect the 'nocode-icon' based on the updated mock and action type
|
||||
expect(props.icon).toBeDefined();
|
||||
test("handles action without description", () => {
|
||||
const actionWithoutDescription = { ...mockActionClass, description: "" };
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
|
||||
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
|
||||
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", () => {
|
||||
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) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.isReadOnly).toBe(true);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.isReadOnly).toBe(true);
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
expect(mockedActionActivityTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isReadOnly: true,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<ModalWithTabs
|
||||
@@ -67,7 +77,7 @@ export const ActionDetailModal = ({
|
||||
tabs={tabs}
|
||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
label={actionClass.name}
|
||||
description={actionClass.description || ""}
|
||||
description={typeDescription()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,22 +11,21 @@ export const ActionClassDataRow = ({
|
||||
locale: TUserLocale;
|
||||
}) => {
|
||||
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="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
|
||||
<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-start py-3 pl-6 text-sm">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="mt-1 h-5 w-5 flex-shrink-0 text-slate-500">
|
||||
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-medium text-slate-900">{actionClass.name}</div>
|
||||
<div className="text-xs text-slate-400">{actionClass.description}</div>
|
||||
<div className="text-left">
|
||||
<div className="break-words font-medium text-slate-900">{actionClass.name}</div>
|
||||
<div className="break-words text-xs text-slate-400">{actionClass.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -210,14 +210,13 @@ export const ActionSettingsTab = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
||||
<div>
|
||||
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!isReadOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setOpenDeleteDialog(true)}
|
||||
className="mr-3"
|
||||
id="deleteActionModalTrigger">
|
||||
<TrashIcon />
|
||||
{t("common.delete")}
|
||||
|
||||
@@ -22,14 +22,29 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, ...props }: any) =>
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal" {...props}>
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
<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, 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", () => ({
|
||||
@@ -70,17 +85,21 @@ describe("AddActionModal", () => {
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "common.add_action" })).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(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
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.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -108,35 +127,35 @@ describe("AddActionModal", () => {
|
||||
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(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked Modal's close button
|
||||
const closeModalButton = screen.getByText("Close Modal");
|
||||
await userEvent.click(closeModalButton);
|
||||
// Simulate closing via the mocked Dialog's close button
|
||||
const closeDialogButton = screen.getByText("Close Dialog");
|
||||
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(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked CreateNewActionTab's button
|
||||
const closeFromTabButton = screen.getByText("Close from Tab");
|
||||
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 { 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 { MousePointerClickIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -26,36 +33,26 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
|
||||
{t("common.add_action")}
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<MousePointerClickIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.actions.track_new_user_action")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<MousePointerClickIcon />
|
||||
<DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -101,6 +101,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
isPendingDowngrade={isPendingDowngrade ?? false}
|
||||
active={active}
|
||||
environmentId={environment.id}
|
||||
locale={user.locale}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
|
||||
@@ -221,7 +221,6 @@ describe("MainNavigation", () => {
|
||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||
|
||||
// Set up localStorage spy on the mocked localStorage
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
@@ -243,23 +242,18 @@ describe("MainNavigation", () => {
|
||||
const logoutButton = screen.getByText("common.logout");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
// Verify localStorage.removeItem is called with the correct key
|
||||
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
|
||||
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "org1",
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
// Clean up spy
|
||||
removeItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
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 FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
@@ -391,14 +390,13 @@ export const MainNavigation = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { EnvironmentContextWrapper, useEnvironment } from "./environment-context";
|
||||
|
||||
// Mock environment data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "test-project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
// Mock project data
|
||||
const mockProject = {
|
||||
id: "test-project-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "test-org-id",
|
||||
config: {
|
||||
channel: "app",
|
||||
industry: "saas",
|
||||
},
|
||||
linkSurveyBranding: true,
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: {
|
||||
light: "#ffffff",
|
||||
dark: "#000000",
|
||||
},
|
||||
questionColor: {
|
||||
light: "#000000",
|
||||
dark: "#ffffff",
|
||||
},
|
||||
inputColor: {
|
||||
light: "#000000",
|
||||
dark: "#ffffff",
|
||||
},
|
||||
inputBorderColor: {
|
||||
light: "#cccccc",
|
||||
dark: "#444444",
|
||||
},
|
||||
cardBackgroundColor: {
|
||||
light: "#ffffff",
|
||||
dark: "#000000",
|
||||
},
|
||||
cardBorderColor: {
|
||||
light: "#cccccc",
|
||||
dark: "#444444",
|
||||
},
|
||||
isDarkModeEnabled: false,
|
||||
isLogoHidden: false,
|
||||
hideProgressBar: false,
|
||||
roundness: 8,
|
||||
cardArrangement: {
|
||||
linkSurveys: "casual",
|
||||
appSurveys: "casual",
|
||||
},
|
||||
},
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
logo: {
|
||||
url: "test-logo.png",
|
||||
bgColor: "#ffffff",
|
||||
},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
} as TProject;
|
||||
|
||||
// Test component that uses the hook
|
||||
const TestComponent = () => {
|
||||
const { environment, project } = useEnvironment();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="environment-id">{environment.id}</div>
|
||||
<div data-testid="environment-type">{environment.type}</div>
|
||||
<div data-testid="project-id">{project.id}</div>
|
||||
<div data-testid="project-organization-id">{project.organizationId}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe("EnvironmentContext", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("provides environment and project data to child components", () => {
|
||||
render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
||||
expect(screen.getByTestId("project-organization-id")).toHaveTextContent("test-org-id");
|
||||
});
|
||||
|
||||
test("throws error when useEnvironment is used outside of provider", () => {
|
||||
const TestComponentWithoutProvider = () => {
|
||||
useEnvironment();
|
||||
return <div>Should not render</div>;
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponentWithoutProvider />);
|
||||
}).toThrow("useEnvironment must be used within an EnvironmentProvider");
|
||||
});
|
||||
|
||||
test("updates context value when environment or project changes", () => {
|
||||
const { rerender } = render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
||||
|
||||
const updatedEnvironment = {
|
||||
...mockEnvironment,
|
||||
type: "production" as const,
|
||||
};
|
||||
|
||||
rerender(
|
||||
<EnvironmentContextWrapper environment={updatedEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("production");
|
||||
});
|
||||
|
||||
test("memoizes context value correctly", () => {
|
||||
const { rerender } = render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
// Re-render with same props
|
||||
rerender(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
// Should still work correctly
|
||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
}
|
||||
|
||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||
|
||||
export const useEnvironment = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
throw new Error("useEnvironment must be used within an EnvironmentProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
project,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
}),
|
||||
[environment, project]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -92,14 +92,24 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen }) =>
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
<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("@/modules/ui/components/alert", () => ({
|
||||
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 { Button } from "@/modules/ui/components/button";
|
||||
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 { Modal } from "@/modules/ui/components/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,11 +27,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { TIntegrationItem } from "@formbricks/types/integration";
|
||||
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 = ({
|
||||
open,
|
||||
setOpenWithStates,
|
||||
@@ -210,182 +292,148 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={handleClose} noPadding>
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent className="overflow-visible md:overflow-visible">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" />
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={AirtableLogo}
|
||||
alt={t("environments.integrations.airtable.airtable_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.airtable.link_airtable_table")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.airtable.sync_responses_with_airtable")}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(submitHandler)}>
|
||||
<div className="flex rounded-lg p-6">
|
||||
<div className="flex w-full flex-col gap-y-4 pt-5">
|
||||
{airtableArray.length ? (
|
||||
<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
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
|
||||
<DialogBody className="overflow-visible">
|
||||
<div className="flex w-full flex-col gap-y-4">
|
||||
{airtableArray.length ? (
|
||||
<BaseSelectDropdown
|
||||
control={control}
|
||||
name="table"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
disabled={!tables.length}
|
||||
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>
|
||||
)}
|
||||
isLoading={isLoading}
|
||||
fetchTable={fetchTable}
|
||||
airtableArray={airtableArray}
|
||||
setValue={setValue}
|
||||
defaultValue={defaultData?.base}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NoBaseFoundError />
|
||||
)}
|
||||
|
||||
{surveys.length ? (
|
||||
<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">
|
||||
<Controller
|
||||
control={control}
|
||||
name="survey"
|
||||
name="table"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
disabled={!tables.length}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("questions", []);
|
||||
}}
|
||||
defaultValue={defaultData?.survey}>
|
||||
defaultValue={defaultData?.table}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
{tables.length ? (
|
||||
<SelectContent>
|
||||
{tables.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
) : null}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!surveys.length ? (
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.create_survey_warning")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{survey && selectedSurvey && (
|
||||
<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>
|
||||
{surveys.length ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="survey">{t("common.select_survey")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="survey"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("questions", []);
|
||||
}}
|
||||
defaultValue={defaultData?.survey}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</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}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.create_survey_warning")}
|
||||
</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>
|
||||
</form>
|
||||
</Modal>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{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}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="submit">{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -88,9 +88,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
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", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -304,10 +319,9 @@ describe("AddIntegrationModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
@@ -332,10 +346,9 @@ describe("AddIntegrationModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
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 { Button } from "@/modules/ui/components/button";
|
||||
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 { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -202,31 +210,28 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image
|
||||
className="w-12"
|
||||
src={GoogleSheetLogo}
|
||||
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={GoogleSheetLogo}
|
||||
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(linkSheet)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
|
||||
<DialogBody>
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{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")}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</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>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -74,13 +74,41 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
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", () => ({
|
||||
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", () => ({
|
||||
// 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.getByTestId("dropdown-select-a-database")).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-survey")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
|
||||
@@ -381,7 +409,7 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
expect(columnDropdowns[1]).toHaveValue("p2");
|
||||
|
||||
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();
|
||||
@@ -445,8 +473,8 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
|
||||
|
||||
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
|
||||
await userEvent.click(xButton);
|
||||
const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button
|
||||
await userEvent.click(trashButton);
|
||||
|
||||
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 { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
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 { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, XIcon } from "lucide-react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -336,9 +344,9 @@ export const AddIntegrationModal = ({
|
||||
col={mapping[idx].column}
|
||||
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="w-[340px] max-w-full">
|
||||
<div className="max-w-full flex-1">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
||||
items={filteredQuestionItems}
|
||||
@@ -384,7 +392,7 @@ export const AddIntegrationModal = ({
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
|
||||
items={getFilteredDbItems()}
|
||||
@@ -430,53 +438,45 @@ export const AddIntegrationModal = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1 hover:bg-slate-300 ${
|
||||
idx === mapping.length - 1 ? "visible" : "invisible"
|
||||
}`}
|
||||
onClick={addRow}>
|
||||
<PlusIcon className="h-5 w-5 font-bold text-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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 className="flex space-x-2">
|
||||
{mapping.length > 1 && (
|
||||
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg">
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image
|
||||
className="w-12"
|
||||
src={NotionLogo}
|
||||
alt={t("environments.integrations.notion.notion_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.notion.link_notion_database")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 flex items-start space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={NotionLogo}
|
||||
alt={t("environments.integrations.notion.notion_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.notion.notion_integration_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4">
|
||||
<DialogBody>
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
|
||||
<Label>
|
||||
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
|
||||
</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) => (
|
||||
<MappingRow idx={idx} key={idx} />
|
||||
))}
|
||||
@@ -530,43 +530,40 @@ export const AddIntegrationModal = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{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>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLinkingDatabase}
|
||||
disabled={mapping.filter((m) => m.error).length > 0}>
|
||||
{selectedIntegration
|
||||
? t("common.update")
|
||||
: t("environments.integrations.notion.link_database")}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</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>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "mock-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -83,9 +83,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
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", () => ({
|
||||
// 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.selected_questions") return "Selected questions";
|
||||
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.delete") return "Delete";
|
||||
if (key === "common.cancel") return "Cancel";
|
||||
@@ -312,10 +329,9 @@ describe("AddChannelMappingModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
|
||||
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
@@ -339,10 +355,9 @@ describe("AddChannelMappingModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
|
||||
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
|
||||
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
|
||||
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 { Button } from "@/modules/ui/components/button";
|
||||
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 { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
@@ -189,24 +197,28 @@ export const AddChannelMappingModal = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image className="w-12" src={SlackLogo} alt="Slack logo" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.slack.link_slack_channel")}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={SlackLogo}
|
||||
alt={t("environments.integrations.slack.slack_logo")}
|
||||
/>
|
||||
</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>
|
||||
<form onSubmit={handleSubmit(linkChannel)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(linkChannel)}>
|
||||
<DialogBody>
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -289,31 +301,29 @@ export const AddChannelMappingModal = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{selectedIntegration ? (
|
||||
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
|
||||
{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")}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
|
||||
{t("common.delete")}
|
||||
</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>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
@@ -5,6 +6,7 @@ import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { Session } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
@@ -13,12 +15,20 @@ import EnvLayout from "./layout";
|
||||
|
||||
// Mock sub-components to render identifiable elements
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
|
||||
EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>,
|
||||
EnvironmentLayout: ({ children, environmentId, session }: any) => (
|
||||
<div data-testid="EnvironmentLayout" data-environment-id={environmentId} data-session={session?.user?.id}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
||||
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
|
||||
<div data-testid="EnvironmentIdBaseLayout">
|
||||
{environmentId}
|
||||
EnvironmentIdBaseLayout: ({ children, environmentId, session, user, organization }: any) => (
|
||||
<div
|
||||
data-testid="EnvironmentIdBaseLayout"
|
||||
data-environment-id={environmentId}
|
||||
data-session={session?.user?.id}
|
||||
data-user={user?.id}
|
||||
data-organization={organization?.id}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
@@ -27,7 +37,24 @@ vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||
ToasterClient: () => <div data-testid="ToasterClient" />,
|
||||
}));
|
||||
vi.mock("./components/EnvironmentStorageHandler", () => ({
|
||||
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
|
||||
default: ({ environmentId }: any) => (
|
||||
<div data-testid="EnvironmentStorageHandler" data-environment-id={environmentId} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
|
||||
EnvironmentContextWrapper: ({ children, environment, project }: any) => (
|
||||
<div
|
||||
data-testid="EnvironmentContextWrapper"
|
||||
data-environment-id={environment?.id}
|
||||
data-project-id={project?.id}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mocks for dependencies
|
||||
@@ -37,26 +64,43 @@ vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getProjectByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("EnvLayout", () => {
|
||||
const mockSession = { user: { id: "user1" } } as Session;
|
||||
const mockUser = { id: "user1", email: "user1@example.com" } as TUser;
|
||||
const mockOrganization = { id: "org1", name: "Org1", billing: {} } as TOrganization;
|
||||
const mockProject = { id: "proj1", name: "Test Project" } as TProject;
|
||||
const mockEnvironment = { id: "env1", type: "production" } as TEnvironment;
|
||||
const mockMembership = {
|
||||
id: "member1",
|
||||
role: "owner",
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
} as TMembership;
|
||||
const mockTranslation = ((key: string) => key) as any;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders successfully when all dependencies return valid data", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
|
||||
id: "member1",
|
||||
} as unknown as TMembership);
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
|
||||
|
||||
const result = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
@@ -64,56 +108,43 @@ describe("EnvLayout", () => {
|
||||
});
|
||||
render(result);
|
||||
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
|
||||
// Verify main layout structure
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-environment-id", "env1");
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-session", "user1");
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-user", "user1");
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-organization", "org1");
|
||||
|
||||
// Verify environment storage handler
|
||||
expect(screen.getByTestId("EnvironmentStorageHandler")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveAttribute("data-environment-id", "env1");
|
||||
|
||||
// Verify context wrapper
|
||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1");
|
||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-project-id", "proj1");
|
||||
|
||||
// Verify environment layout
|
||||
expect(screen.getByTestId("EnvironmentLayout")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-environment-id", "env1");
|
||||
expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-session", "user1");
|
||||
|
||||
// Verify children are rendered
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Content");
|
||||
|
||||
// Verify all services were called with correct parameters
|
||||
expect(environmentIdLayoutChecks).toHaveBeenCalledWith("env1");
|
||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env1");
|
||||
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
|
||||
});
|
||||
|
||||
test("throws error if project is not found", async () => {
|
||||
test("redirects when session is null", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
|
||||
id: "member1",
|
||||
} as unknown as TMembership);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.project_not_found");
|
||||
});
|
||||
|
||||
test("throws error if membership is not found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.membership_not_found");
|
||||
});
|
||||
|
||||
test("calls redirect when session is null", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: undefined as unknown as Session,
|
||||
user: undefined as unknown as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
t: mockTranslation,
|
||||
session: null as unknown as Session,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||
throw new Error("Redirect called");
|
||||
@@ -125,18 +156,16 @@ describe("EnvLayout", () => {
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("Redirect called");
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws error if user is null", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: undefined as unknown as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
|
||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||
throw new Error("Redirect called");
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: null as unknown as TUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -145,5 +174,154 @@ describe("EnvLayout", () => {
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.user_not_found");
|
||||
|
||||
// Verify redirect was not called
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws error if project is not found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.project_not_found");
|
||||
|
||||
// Verify both project and environment were called in Promise.all
|
||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env1");
|
||||
});
|
||||
|
||||
test("throws error if environment is not found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.environment_not_found");
|
||||
|
||||
// Verify both project and environment were called in Promise.all
|
||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env1");
|
||||
});
|
||||
|
||||
test("throws error if membership is not found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.membership_not_found");
|
||||
|
||||
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
|
||||
});
|
||||
|
||||
test("handles Promise.all correctly for project and environment", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
|
||||
// Mock Promise.all to verify it's called correctly
|
||||
const getProjectSpy = vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
const getEnvironmentSpy = vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
|
||||
|
||||
const result = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div data-testid="child">Content</div>,
|
||||
});
|
||||
render(result);
|
||||
|
||||
// Verify both calls were made
|
||||
expect(getProjectSpy).toHaveBeenCalledWith("env1");
|
||||
expect(getEnvironmentSpy).toHaveBeenCalledWith("env1");
|
||||
|
||||
// Verify successful rendering
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles different environment types correctly", async () => {
|
||||
const developmentEnvironment = { id: "env1", type: "development" } as TEnvironment;
|
||||
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(developmentEnvironment);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
|
||||
|
||||
const result = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div data-testid="child">Content</div>,
|
||||
});
|
||||
render(result);
|
||||
|
||||
// Verify context wrapper receives the development environment
|
||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1");
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles different user roles correctly", async () => {
|
||||
const memberMembership = {
|
||||
id: "member1",
|
||||
role: "member",
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
} as TMembership;
|
||||
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(memberMembership);
|
||||
|
||||
const result = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div data-testid="child">Content</div>,
|
||||
});
|
||||
render(result);
|
||||
|
||||
// Verify successful rendering with member role
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
@@ -11,7 +13,6 @@ const EnvLayout = async (props: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||
@@ -24,11 +25,19 @@ const EnvLayout = async (props: {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(params.environmentId);
|
||||
const [project, environment] = await Promise.all([
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
|
||||
if (!membership) {
|
||||
@@ -42,9 +51,11 @@ const EnvLayout = async (props: {
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||
{children}
|
||||
</EnvironmentLayout>
|
||||
<EnvironmentContextWrapper environment={environment} project={project}>
|
||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||
{children}
|
||||
</EnvironmentLayout>
|
||||
</EnvironmentContextWrapper>
|
||||
</EnvironmentIdBaseLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/modules/ui/components/switch", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
updateNotificationSettingsAction: vi.fn(() => Promise.resolve()),
|
||||
updateNotificationSettingsAction: vi.fn(() => Promise.resolve({ data: true })),
|
||||
}));
|
||||
|
||||
const surveyId = "survey1";
|
||||
@@ -246,4 +246,204 @@ describe("NotificationSwitch", () => {
|
||||
});
|
||||
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";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
@@ -24,6 +26,7 @@ export const NotificationSwitch = ({
|
||||
}: NotificationSwitchProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
const isChecked =
|
||||
notificationType === "unsubscribedOrganizationIds"
|
||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
||||
@@ -50,7 +53,20 @@ export const NotificationSwitch = ({
|
||||
!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);
|
||||
};
|
||||
|
||||
@@ -104,9 +120,6 @@ export const NotificationSwitch = ({
|
||||
disabled={isLoading}
|
||||
onCheckedChange={async () => {
|
||||
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 { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
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 { ZId } from "@formbricks/types/common";
|
||||
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("Password reset is not allowed for this user.");
|
||||
}
|
||||
|
||||
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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { updateUserAction } from "../actions";
|
||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
||||
import { EditProfileDetailsForm } from "./EditProfileDetailsForm";
|
||||
|
||||
const mockUser = {
|
||||
@@ -24,6 +24,8 @@ const mockUser = {
|
||||
objective: "other",
|
||||
} as unknown as TUser;
|
||||
|
||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
||||
|
||||
// Mock window.location.reload
|
||||
const originalLocation = window.location;
|
||||
beforeEach(() => {
|
||||
@@ -35,6 +37,11 @@ beforeEach(() => {
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
|
||||
updateUserAction: vi.fn(),
|
||||
resetPasswordAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/actions", () => ({
|
||||
forgotPasswordAction: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
@@ -50,7 +57,13 @@ describe("EditProfileDetailsForm", () => {
|
||||
test("renders with initial user data and updates successfully", async () => {
|
||||
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");
|
||||
expect(nameInput).toHaveValue(mockUser.name);
|
||||
@@ -91,7 +104,13 @@ describe("EditProfileDetailsForm", () => {
|
||||
const errorMessage = "Update failed";
|
||||
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");
|
||||
await userEvent.clear(nameInput);
|
||||
@@ -109,7 +128,13 @@ describe("EditProfileDetailsForm", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
expect(updateButton).toBeDisabled();
|
||||
|
||||
@@ -117,4 +142,68 @@ describe("EditProfileDetailsForm", () => {
|
||||
await userEvent.type(nameInput, " updated");
|
||||
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";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/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 { z } from "zod";
|
||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||
import { updateUserAction } from "../actions";
|
||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
||||
|
||||
// Schema & types
|
||||
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>;
|
||||
|
||||
interface IEditProfileDetailsFormProps {
|
||||
user: TUser;
|
||||
isPasswordResetEnabled?: boolean;
|
||||
emailVerificationDisabled: boolean;
|
||||
}
|
||||
|
||||
export const EditProfileDetailsForm = ({
|
||||
user,
|
||||
isPasswordResetEnabled,
|
||||
emailVerificationDisabled,
|
||||
}: {
|
||||
user: TUser;
|
||||
emailVerificationDisabled: boolean;
|
||||
}) => {
|
||||
}: IEditProfileDetailsFormProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const form = useForm<TEditProfileNameForm>({
|
||||
@@ -50,6 +55,8 @@ export const EditProfileDetailsForm = ({
|
||||
});
|
||||
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
@@ -90,6 +97,7 @@ export const EditProfileDetailsForm = ({
|
||||
redirectUrl: "/email-change-without-verification-success",
|
||||
redirect: true,
|
||||
callbackUrl: "/email-change-without-verification-success",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
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(errorMessage);
|
||||
}
|
||||
|
||||
setIsResettingPassword(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
|
||||
@@ -4,18 +4,27 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { PasswordConfirmationModal } from "./password-confirmation-modal";
|
||||
|
||||
// Mock the Modal component
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, title }: any) =>
|
||||
// Mock the Dialog component
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button data-testid="modal-close" onClick={() => setOpen(false)}>
|
||||
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</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>,
|
||||
}));
|
||||
|
||||
// Mock the PasswordInput component
|
||||
@@ -54,13 +63,13 @@ describe("PasswordConfirmationModal", () => {
|
||||
|
||||
test("renders nothing when open is 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} />);
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays old and new email addresses", () => {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
"use client";
|
||||
|
||||
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 { Modal } from "@/modules/ui/components/modal";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -54,64 +62,69 @@ export const PasswordConfirmationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.email-change.confirm_password_description")}
|
||||
</p>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("auth.forgot-password.reset.confirm_password")}</DialogTitle>
|
||||
<DialogDescription>{t("auth.email-change.confirm_password_description")}</DialogDescription>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-x-2 text-right">
|
||||
<Button type="button" variant="secondary" onClick={handleCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={handleCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,8 @@ import Page from "./page";
|
||||
|
||||
// Mock services and utils
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
IS_FORMBRICKS_CLOUD: 1,
|
||||
PASSWORD_RESET_DISABLED: 1,
|
||||
EMAIL_VERIFICATION_DISABLED: true,
|
||||
}));
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
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 { getUser } from "@/lib/user/service";
|
||||
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"));
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.account_settings")}>
|
||||
@@ -42,7 +44,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
<SettingsCard
|
||||
title={t("environments.settings.profile.personal_information")}
|
||||
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
|
||||
title={t("common.avatar")}
|
||||
|
||||
@@ -30,7 +30,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { H3, Small } from "@/modules/ui/components/typography";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
export const SettingsCard = ({
|
||||
@@ -31,7 +32,7 @@ export const SettingsCard = ({
|
||||
id={title}>
|
||||
<div className="border-b border-slate-200 px-4 pb-4">
|
||||
<div className="flex">
|
||||
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
|
||||
<H3 className="capitalize">{title}</H3>
|
||||
<div className="ml-2">
|
||||
{beta && <Badge size="normal" type="warning" text="Beta" />}
|
||||
{soon && (
|
||||
@@ -39,7 +40,9 @@ export const SettingsCard = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
<Small color="muted" margin="headerDescription">
|
||||
{description}
|
||||
</Small>
|
||||
</div>
|
||||
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -26,8 +26,26 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
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 = [
|
||||
@@ -163,12 +181,12 @@ describe("ResponseCardModal", () => {
|
||||
test("should not render if selectedResponseId is null", () => {
|
||||
const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />);
|
||||
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} />);
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("single-response-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -204,14 +222,6 @@ describe("ResponseCardModal", () => {
|
||||
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", () => {
|
||||
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(true);
|
||||
@@ -229,11 +239,10 @@ describe("ResponseCardModal", () => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("should render ChevronLeft, ChevronRight, and XIcon", () => {
|
||||
test("should render ChevronLeft and ChevronRight icons", () => {
|
||||
render(<ResponseCardModal {...defaultProps} />);
|
||||
expect(document.querySelector(".lucide-chevron-left")).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 { Button } from "@/modules/ui/components/button";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react";
|
||||
import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
@@ -64,42 +64,20 @@ export const ResponseCardModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedResponseId(null);
|
||||
const handleClose = (open: boolean) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setSelectedResponseId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// If no response is selected or currentIndex is null, do not render the modal
|
||||
if (selectedResponseId === null || currentIndex === null) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
hideCloseButton
|
||||
open={open}
|
||||
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>
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent width="wide">
|
||||
<DialogBody>
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
response={responses[currentIndex]}
|
||||
@@ -113,8 +91,20 @@ export const ResponseCardModal = ({
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<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 { 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 { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
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 { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -33,6 +35,9 @@ const Page = async (props) => {
|
||||
|
||||
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
@@ -51,6 +56,9 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"use server";
|
||||
|
||||
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 { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { convertToCsv } from "@/lib/utils/file-conversion";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
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 { sendEmbedSurveyPreviewEmail } from "@/modules/email";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
|
||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -222,3 +227,128 @@ export const getEmailHtmlAction = authenticatedActionClient
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
const ZUpdateSingleUseLinksAction = z.object({
|
||||
surveyId: ZId,
|
||||
environmentId: ZId,
|
||||
isSingleUse: z.boolean(),
|
||||
isSingleUseEncryption: z.boolean(),
|
||||
});
|
||||
|
||||
export const updateSingleUseLinksAction = authenticatedActionClient
|
||||
.schema(ZUpdateSingleUseLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
const updatedSurvey = await updateSurvey({
|
||||
...survey,
|
||||
singleUse: { enabled: parsedInput.isSingleUse, isEncrypted: parsedInput.isSingleUseEncryption },
|
||||
});
|
||||
|
||||
return updatedSurvey;
|
||||
});
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySingleUse,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
// Mock data
|
||||
const mockSurveyWeb = {
|
||||
id: "survey1",
|
||||
name: "Web Survey",
|
||||
environmentId: "env1",
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: 0,
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
closeOnDate: null,
|
||||
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
|
||||
triggers: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [],
|
||||
styling: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
INTERCOM_SECRET_KEY: "test-secret-key",
|
||||
IS_INTERCOM_CONFIGURED: true,
|
||||
INTERCOM_APP_ID: "test-app-id",
|
||||
ENCRYPTION_KEY: "test-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
|
||||
GITHUB_ID: "test-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
POSTHOG_API_HOST: "test-posthog-api-host",
|
||||
POSTHOG_API_KEY: "test-posthog-api-key",
|
||||
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
|
||||
IS_FORMBRICKS_ENABLED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
}));
|
||||
|
||||
const mockSurveyLink = {
|
||||
...mockSurveyWeb,
|
||||
id: "survey2",
|
||||
name: "Link Survey",
|
||||
type: "link",
|
||||
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
role: "project_manager",
|
||||
objective: "other",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
locale: "en-US",
|
||||
} as unknown as TUser;
|
||||
|
||||
// Mocks
|
||||
const mockRouterRefresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: mockRouterRefresh,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (str: string) => str,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
|
||||
ShareSurveyLink: vi.fn(() => <div>ShareSurveyLinkMock</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/badge", () => ({
|
||||
Badge: vi.fn(({ text }) => <span data-testid="badge-mock">{text}</span>),
|
||||
}));
|
||||
|
||||
const mockEmbedViewComponent = vi.fn();
|
||||
vi.mock("./shareEmbedModal/EmbedView", () => ({
|
||||
EmbedView: (props: any) => mockEmbedViewComponent(props),
|
||||
}));
|
||||
|
||||
const mockPanelInfoViewComponent = vi.fn();
|
||||
vi.mock("./shareEmbedModal/PanelInfoView", () => ({
|
||||
PanelInfoView: (props: any) => mockPanelInfoViewComponent(props),
|
||||
}));
|
||||
|
||||
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
|
||||
vi.mock("@/modules/ui/components/dialog", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/modules/ui/components/dialog")>(
|
||||
"@/modules/ui/components/dialog"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
Dialog: (props: React.ComponentProps<typeof actual.Dialog>) => {
|
||||
capturedDialogOnOpenChange = props.onOpenChange;
|
||||
return <actual.Dialog {...props} />;
|
||||
},
|
||||
// DialogTitle, DialogContent, DialogDescription will be the actual components
|
||||
// due to ...actual spread and no specific mock for them here.
|
||||
};
|
||||
});
|
||||
|
||||
describe("ShareEmbedSurvey", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
capturedDialogOnOpenChange = undefined;
|
||||
});
|
||||
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
survey: mockSurveyWeb,
|
||||
publicDomain: "https://public-domain.com",
|
||||
open: true,
|
||||
modalView: "start" as "start" | "embed" | "panel",
|
||||
setOpen: mockSetOpen,
|
||||
user: mockUser,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockEmbedViewComponent.mockImplementation(
|
||||
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
|
||||
<div>
|
||||
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
|
||||
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
|
||||
<div data-testid="embedview-activeid">{activeId}</div>
|
||||
<div data-testid="embedview-survey-id">{survey.id}</div>
|
||||
<div data-testid="embedview-email">{email}</div>
|
||||
<div data-testid="embedview-surveyUrl">{surveyUrl}</div>
|
||||
<div data-testid="embedview-publicDomain">{publicDomain}</div>
|
||||
<div data-testid="embedview-locale">{locale}</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => (
|
||||
<button onClick={() => handleInitialPageButton()}>PanelInfoViewMockContent</button>
|
||||
));
|
||||
});
|
||||
|
||||
test("renders initial 'start' view correctly when open and modalView is 'start'", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
|
||||
expect(screen.getByText("ShareSurveyLinkMock")).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 () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
|
||||
await userEvent.click(embedButton);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches to 'panel' view when 'Send to panel' button is clicked", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||
const panelButton = screen.getByText("environments.surveys.summary.send_to_panel");
|
||||
await userEvent.click(panelButton);
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).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)", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} open={true} survey={mockSurveyWeb} />);
|
||||
expect(capturedDialogOnOpenChange).toBeDefined();
|
||||
|
||||
// Simulate Dialog closing
|
||||
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(false);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockRouterRefresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate Dialog opening
|
||||
mockRouterRefresh.mockClear();
|
||||
mockSetOpen.mockClear();
|
||||
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(true);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(true);
|
||||
expect(mockRouterRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("correctly configures for 'link' survey type in embed view", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
|
||||
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
|
||||
tabs: { id: string; label: string; icon: LucideIcon }[];
|
||||
activeId: string;
|
||||
};
|
||||
expect(embedViewProps.tabs.length).toBe(3);
|
||||
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
|
||||
expect(embedViewProps.tabs[0].id).toBe("email");
|
||||
expect(embedViewProps.activeId).toBe("email");
|
||||
});
|
||||
|
||||
test("correctly configures for 'web' survey type in embed view", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />);
|
||||
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
|
||||
tabs: { id: string; label: string; icon: LucideIcon }[];
|
||||
activeId: string;
|
||||
};
|
||||
expect(embedViewProps.tabs.length).toBe(1);
|
||||
expect(embedViewProps.tabs[0].id).toBe("app");
|
||||
expect(embedViewProps.activeId).toBe("app");
|
||||
});
|
||||
|
||||
test("useEffect does not change activeId if survey.type changes from web to link (while in embed view)", () => {
|
||||
const { rerender } = render(
|
||||
<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />
|
||||
);
|
||||
expect(vi.mocked(mockEmbedViewComponent).mock.calls[0][0].activeId).toBe("app");
|
||||
|
||||
rerender(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
|
||||
expect(vi.mocked(mockEmbedViewComponent).mock.calls[1][0].activeId).toBe("app"); // Current behavior
|
||||
});
|
||||
|
||||
test("initial showView is set by modalView prop when open is true", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("useEffect sets showView to 'start' when open becomes false", () => {
|
||||
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed
|
||||
|
||||
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
|
||||
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found
|
||||
expect(screen.queryByText("EmbedViewMockContent")).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", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
|
||||
let embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
|
||||
tabs: { id: string; label: string }[];
|
||||
};
|
||||
let linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
|
||||
expect(linkTab?.label).toBe("environments.surveys.summary.share_the_link");
|
||||
cleanup();
|
||||
vi.mocked(mockEmbedViewComponent).mockClear();
|
||||
|
||||
const mockSurveyLinkSingleUse: TSurvey = {
|
||||
...mockSurveyLink,
|
||||
singleUse: { enabled: true, isEncrypted: true },
|
||||
};
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLinkSingleUse} modalView="embed" />);
|
||||
embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
|
||||
tabs: { id: string; label: string }[];
|
||||
};
|
||||
linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
|
||||
expect(linkTab?.label).toBe("environments.surveys.summary.single_use_links");
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||
import { getSurveyUrl } from "@/modules/analysis/utils";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
BellRing,
|
||||
BlocksIcon,
|
||||
Code2Icon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
SmartphoneIcon,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { EmbedView } from "./shareEmbedModal/EmbedView";
|
||||
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
|
||||
|
||||
interface ShareEmbedSurveyProps {
|
||||
survey: TSurvey;
|
||||
publicDomain: string;
|
||||
open: boolean;
|
||||
modalView: "start" | "embed" | "panel";
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
user: TUser;
|
||||
}
|
||||
|
||||
export const ShareEmbedSurvey = ({
|
||||
survey,
|
||||
publicDomain,
|
||||
open,
|
||||
modalView,
|
||||
setOpen,
|
||||
user,
|
||||
}: ShareEmbedSurveyProps) => {
|
||||
const router = useRouter();
|
||||
const environmentId = survey.environmentId;
|
||||
const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false;
|
||||
const { email } = user;
|
||||
const { t } = useTranslate();
|
||||
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",
|
||||
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{ id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon },
|
||||
].filter((tab) => !(survey.type === "link" && tab.id === "app")),
|
||||
[t, isSingleUseLinkSurvey, survey.type]
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id);
|
||||
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
|
||||
const [surveyUrl, setSurveyUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSurveyUrl = async () => {
|
||||
try {
|
||||
const url = await getSurveyUrl(survey, publicDomain, "default");
|
||||
setSurveyUrl(url);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch survey URL:", error);
|
||||
// Fallback to a default URL if fetching fails
|
||||
setSurveyUrl(`${publicDomain}/s/${survey.id}`);
|
||||
}
|
||||
};
|
||||
fetchSurveyUrl();
|
||||
}, [survey, publicDomain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.type !== "link") {
|
||||
setActiveId(tabs[3].id);
|
||||
}
|
||||
}, [survey.type, tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setShowView(modalView);
|
||||
} else {
|
||||
setShowView("start");
|
||||
}
|
||||
}, [open, modalView]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id);
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setShowView("start");
|
||||
}
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleInitialPageButton = () => {
|
||||
setShowView("start");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTitle className="sr-only" />
|
||||
<DialogContent className="w-full max-w-xl bg-white p-0 md:max-w-3xl lg:h-[700px] lg:max-w-5xl">
|
||||
{showView === "start" ? (
|
||||
<div className="h-full max-w-full 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">
|
||||
<DialogTitle>
|
||||
<p className="pt-2 text-xl font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.your_survey_is_public")} 🎉
|
||||
</p>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
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">
|
||||
<p className="-mt-8 text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowView("embed")}
|
||||
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
|
||||
<Code2Icon className="h-6 w-6 text-slate-700" />
|
||||
{t("environments.surveys.summary.embed_survey")}
|
||||
</button>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/settings/notifications`}
|
||||
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
|
||||
<BellRing className="h-6 w-6 text-slate-700" />
|
||||
{t("environments.surveys.summary.configure_alerts")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/integrations`}
|
||||
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
|
||||
<BlocksIcon className="h-6 w-6 text-slate-700" />
|
||||
{t("environments.surveys.summary.setup_integrations")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowView("panel")}
|
||||
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
|
||||
<UsersRound className="h-6 w-6 text-slate-700" />
|
||||
{t("environments.surveys.summary.send_to_panel")}
|
||||
<Badge
|
||||
size="tiny"
|
||||
type="success"
|
||||
className="absolute right-3 top-3"
|
||||
text={t("common.new")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : showView === "embed" ? (
|
||||
<EmbedView
|
||||
handleInitialPageButton={handleInitialPageButton}
|
||||
tabs={survey.type === "link" ? tabs : [tabs[3]]}
|
||||
disableBack={false}
|
||||
activeId={activeId}
|
||||
environmentId={environmentId}
|
||||
setActiveId={setActiveId}
|
||||
survey={survey}
|
||||
email={email}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
/>
|
||||
) : showView === "panel" ? (
|
||||
<PanelInfoView handleInitialPageButton={handleInitialPageButton} disableBack={false} />
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -20,9 +20,22 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Modal
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
|
||||
// Mock Dialog
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
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
|
||||
@@ -120,7 +133,7 @@ describe("ShareSurveyResults", () => {
|
||||
|
||||
test("does not render content when modal is closed (open is 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.survey_results_are_public")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
|
||||
import { Clipboard } from "lucide-react";
|
||||
@@ -26,70 +26,72 @@ export const ShareSurveyResults = ({
|
||||
}: ShareEmbedSurveyProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} size="lg">
|
||||
{showPublishModal && surveyUrl ? (
|
||||
<div className="flex flex-col rounded-2xl bg-white px-12 py-6">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<CheckCircle2Icon className="h-20 w-20 text-slate-300" />
|
||||
<div>
|
||||
<p className="text-lg font-medium text-slate-600">
|
||||
{t("environments.surveys.summary.survey_results_are_public")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
|
||||
<span>{surveyUrl}</span>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogBody>
|
||||
{showPublishModal && surveyUrl ? (
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<CheckCircle2Icon className="text-primary h-20 w-20" />
|
||||
<div>
|
||||
<p className="text-primary text-lg font-medium">
|
||||
{t("environments.surveys.summary.survey_results_are_public")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<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>
|
||||
<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 className="flex flex-col rounded-2xl bg-white p-8">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col rounded-2xl bg-white p-8">
|
||||
<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>
|
||||
)}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -117,13 +117,13 @@ export const SummaryMetadata = ({
|
||||
)}
|
||||
</span>
|
||||
{!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">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,410 +1,437 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { SurveyAnalysisCTA } from "./SurveyAnalysisCTA";
|
||||
|
||||
vi.mock("@/lib/utils/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/audit-logs/lib/utils", () => ({
|
||||
withAuditLogging: vi.fn((...args: any[]) => {
|
||||
// Check if the last argument is a function and return it directly
|
||||
if (typeof args[args.length - 1] === "function") {
|
||||
return args[args.length - 1];
|
||||
}
|
||||
// Otherwise, return a new function that takes a function as an argument and returns it
|
||||
return (fn: any) => fn;
|
||||
// Mock the useTranslate hook
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
if (key === "environments.surveys.summary.configure_alerts") {
|
||||
return "Configure alerts";
|
||||
}
|
||||
if (key === "common.preview") {
|
||||
return "Preview";
|
||||
}
|
||||
if (key === "common.edit") {
|
||||
return "Edit";
|
||||
}
|
||||
if (key === "environments.surveys.summary.share_survey") {
|
||||
return "Share survey";
|
||||
}
|
||||
if (key === "environments.surveys.summary.results_are_public") {
|
||||
return "Results are public";
|
||||
}
|
||||
if (key === "environments.surveys.survey_duplicated_successfully") {
|
||||
return "Survey duplicated successfully";
|
||||
}
|
||||
if (key === "environments.surveys.edit.caution_edit_duplicate") {
|
||||
return "Duplicate & Edit";
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockPublicDomain = "https://public-domain.com";
|
||||
// Mock Next.js hooks
|
||||
const mockPush = vi.fn();
|
||||
const mockPathname = "/environments/env-id/surveys/survey-id/summary";
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
ENCRYPTION_KEY: "test",
|
||||
ENTERPRISE_LICENSE_KEY: "test",
|
||||
GITHUB_ID: "test",
|
||||
GITHUB_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_ISSUER: "mock-oidc-issuer",
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "mock-url",
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
usePathname: () => mockPathname,
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
// Mock react-hot-toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Create a spy for refreshSingleUseId so we can override it in tests
|
||||
const refreshSingleUseIdSpy = vi.fn(() => Promise.resolve("newSingleUseId"));
|
||||
|
||||
// Mock useSingleUseId hook
|
||||
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
||||
useSingleUseId: () => ({
|
||||
refreshSingleUseId: refreshSingleUseIdSpy,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
const mockPush = vi.fn();
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
usePathname: () => "/current",
|
||||
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
|
||||
}));
|
||||
|
||||
// Mock copySurveyLink to return a predictable string
|
||||
vi.mock("@/modules/survey/lib/client-utils", () => ({
|
||||
copySurveyLink: vi.fn((url: string, suId: string) => `${url}?suId=${suId}`),
|
||||
}));
|
||||
|
||||
// Mock the copy survey action
|
||||
const mockCopySurveyToOtherEnvironmentAction = vi.fn();
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: (args: any) => mockCopySurveyToOtherEnvironmentAction(args),
|
||||
}));
|
||||
|
||||
// Mock getFormattedErrorMessage function
|
||||
// Mock helper functions
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn((response) => response?.error || "Unknown error"),
|
||||
getFormattedErrorMessage: vi.fn(() => "Error message"),
|
||||
}));
|
||||
|
||||
// Mock ResponseCountProvider dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
useResponseFilter: vi.fn(() => ({ selectedFilter: "all", dateRange: {} })),
|
||||
// Mock actions
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({
|
||||
getResponseCountAction: vi.fn(() => Promise.resolve({ data: 5 })),
|
||||
// Mock child components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage",
|
||||
() => ({
|
||||
SuccessMessage: ({ environment, survey }: any) => (
|
||||
<div data-testid="success-message">
|
||||
Success Message for {environment.id} - {survey.id}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal",
|
||||
() => ({
|
||||
ShareSurveyModal: ({ survey, open, setOpen, modalView, user }: any) => (
|
||||
<div data-testid="share-survey-modal" data-open={open} data-modal-view={modalView}>
|
||||
Share Survey Modal for {survey.id} - User: {user.id}
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
Close Modal
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown",
|
||||
() => ({
|
||||
SurveyStatusDropdown: ({ environment, survey }: any) => (
|
||||
<div data-testid="survey-status-dropdown">
|
||||
Status Dropdown for {environment.id} - {survey.id}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/modules/survey/components/edit-public-survey-alert-dialog", () => ({
|
||||
EditPublicSurveyAlertDialog: ({
|
||||
open,
|
||||
setOpen,
|
||||
isLoading,
|
||||
primaryButtonAction,
|
||||
primaryButtonText,
|
||||
secondaryButtonAction,
|
||||
secondaryButtonText,
|
||||
}: any) => (
|
||||
<div data-testid="edit-public-survey-alert-dialog" data-open={open} data-loading={isLoading}>
|
||||
<button type="button" onClick={primaryButtonAction} data-testid="primary-button">
|
||||
{primaryButtonText}
|
||||
</button>
|
||||
<button type="button" onClick={secondaryButtonAction} data-testid="secondary-button">
|
||||
{secondaryButtonText}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
Close Dialog
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||
getFormattedFilters: vi.fn(() => []),
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/badge", () => ({
|
||||
Badge: ({ type, size, className, text }: any) => (
|
||||
<div data-testid="badge" data-type={type} data-size={size} className={className}>
|
||||
{text}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getResponseCountBySurveySharingKeyAction: vi.fn(() => Promise.resolve({ data: 5 })),
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, className }: any) => (
|
||||
<button type="button" data-testid="button" onClick={onClick} className={className}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getPublicUrl", () => ({
|
||||
getPublicDomain: vi.fn(() => mockPublicDomain),
|
||||
vi.mock("@/modules/ui/components/iconbar", () => ({
|
||||
IconBar: ({ actions }: any) => (
|
||||
<div data-testid="icon-bar">
|
||||
{actions
|
||||
.filter((action: any) => action.isVisible)
|
||||
.map((action: any, index: number) => (
|
||||
<button
|
||||
type="button"
|
||||
key={index} // NOSONAR // We don't need to check this in the test
|
||||
onClick={action.onClick}
|
||||
title={action.tooltip}
|
||||
data-testid={`icon-bar-action-${index}`}>
|
||||
<action.icon />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.spyOn(toast, "success");
|
||||
vi.spyOn(toast, "error");
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
BellRing: () => <svg data-testid="bell-ring-icon" />,
|
||||
Eye: () => <svg data-testid="eye-icon" />,
|
||||
SquarePenIcon: () => <svg data-testid="square-pen-icon" />,
|
||||
}));
|
||||
|
||||
// Mock clipboard API
|
||||
const writeTextMock = vi.fn().mockImplementation(() => Promise.resolve());
|
||||
// Mock data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "test-project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
// Define it at the global level
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: writeTextMock },
|
||||
configurable: true,
|
||||
});
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "test-survey-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
type: "app",
|
||||
environmentId: "test-env-id",
|
||||
status: "inProgress",
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
|
||||
const dummySurvey = {
|
||||
id: "survey123",
|
||||
type: "link",
|
||||
environmentId: "env123",
|
||||
status: "active",
|
||||
} as unknown as TSurvey;
|
||||
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
|
||||
const dummyUser = { id: "user123", name: "Test User" } as TUser;
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
|
||||
questions: [],
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false },
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
|
||||
describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
segment: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
singleUse: { enabled: false, isEncrypted: false },
|
||||
projectOverwrites: null,
|
||||
surveyClosedMessage: null,
|
||||
delay: 0,
|
||||
isVerifyEmailEnabled: false,
|
||||
createdBy: null,
|
||||
variables: [],
|
||||
followUps: [],
|
||||
runOnDate: null,
|
||||
closeOnDate: null,
|
||||
styling: null,
|
||||
pin: null,
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
test("calls copySurveyLink and clipboard.writeText on success", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
const mockUser: TUser = {
|
||||
id: "test-user-id",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "https://example.com/avatar.jpg",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
|
||||
fireEvent.click(copyButton);
|
||||
role: "other",
|
||||
objective: "other",
|
||||
locale: "en-US",
|
||||
lastLoginAt: new Date(),
|
||||
isActive: true,
|
||||
notificationSettings: {
|
||||
alert: {
|
||||
weeklySummary: true,
|
||||
responseFinished: true,
|
||||
},
|
||||
weeklySummary: {
|
||||
test: true,
|
||||
},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
|
||||
expect(writeTextMock).toHaveBeenCalledWith("https://public-domain.com/s/survey123?suId=newSingleUseId");
|
||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
});
|
||||
const mockSegments: TSegment[] = [];
|
||||
|
||||
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 defaultProps = {
|
||||
survey: mockSurvey,
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
user: mockUser,
|
||||
publicDomain: "https://example.com",
|
||||
responseCount: 0,
|
||||
segments: mockSegments,
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
};
|
||||
|
||||
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", () => {
|
||||
describe("SurveyAnalysisCTA", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.delete("share");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
test("renders share survey button", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
// Find the edit button
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Check if dialog is shown
|
||||
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
|
||||
expect(dialogTitle).toBeInTheDocument();
|
||||
expect(screen.getByText("Share survey")).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}
|
||||
/>
|
||||
);
|
||||
test("renders success message component", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
// 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`
|
||||
);
|
||||
expect(screen.getByTestId("success-message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't show edit button when isReadOnly is true", () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={true}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
test("renders survey status dropdown when app setup is completed", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
// Try to find the edit button (it shouldn't exist)
|
||||
const editButton = screen.queryByRole("button", { name: "common.edit" });
|
||||
expect(editButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Updated test description to mention EditPublicSurveyAlertDialog
|
||||
describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertDialog", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("duplicates survey successfully and navigates to edit page", async () => {
|
||||
// Mock the API response
|
||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
|
||||
data: { id: "duplicated-survey-456" },
|
||||
});
|
||||
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find and click the edit button to show dialog
|
||||
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
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
|
||||
|
||||
// Verify navigation to edit page
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
`/environments/${dummyEnvironment.id}/surveys/duplicated-survey-456/edit`
|
||||
);
|
||||
});
|
||||
|
||||
test("shows error toast when duplication fails with error object", async () => {
|
||||
// Mock API failure with error object
|
||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
|
||||
error: "Test error message",
|
||||
});
|
||||
|
||||
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 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);
|
||||
|
||||
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 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
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render survey status dropdown when read-only", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} />);
|
||||
|
||||
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders icon bar with correct actions", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toBeInTheDocument(); // Bell ring
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toBeInTheDocument(); // Square pen
|
||||
});
|
||||
|
||||
test("shows preview icon for link surveys", () => {
|
||||
const linkSurvey = { ...mockSurvey, type: "link" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={linkSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
|
||||
});
|
||||
|
||||
test("shows public results badge when resultShareKey exists", () => {
|
||||
const surveyWithShareKey = { ...mockSurvey, resultShareKey: "share-key" };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithShareKey} />);
|
||||
|
||||
expect(screen.getByTestId("badge")).toBeInTheDocument();
|
||||
expect(screen.getByText("Results are public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens share modal when share button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
});
|
||||
|
||||
test("opens share modal when share param is true", () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "start");
|
||||
});
|
||||
|
||||
test("navigates to edit when edit button is clicked and no responses", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/test-survey-id/edit");
|
||||
});
|
||||
|
||||
test("shows caution dialog when edit button is clicked and has responses", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
|
||||
});
|
||||
|
||||
test("navigates to notifications when bell icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-0"));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/settings/notifications");
|
||||
});
|
||||
|
||||
test("opens preview window when preview icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const linkSurvey = { ...mockSurvey, type: "link" as const };
|
||||
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={linkSurvey} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith("https://example.com/s/test-survey-id?preview=true", "_blank");
|
||||
windowOpenSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("does not show icon bar actions when read-only", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} />);
|
||||
|
||||
const iconBar = screen.getByTestId("icon-bar");
|
||||
expect(iconBar).toBeInTheDocument();
|
||||
// Should only show preview icon for link surveys, but this is app survey
|
||||
expect(screen.queryByTestId("icon-bar-action-0")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles modal close correctly", async () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
// Verify modal is open initially
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
await user.click(screen.getByText("Close Modal"));
|
||||
|
||||
// Verify modal is closed
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("shows status dropdown for link surveys", () => {
|
||||
const linkSurvey = { ...mockSurvey, type: "link" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={linkSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not show status dropdown for draft surveys", () => {
|
||||
const draftSurvey = { ...mockSurvey, status: "draft" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={draftSurvey} />);
|
||||
|
||||
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not show status dropdown when app setup is not completed", () => {
|
||||
const environmentWithoutAppSetup = { ...mockEnvironment, appSetupCompleted: false };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} environment={environmentWithoutAppSetup} />);
|
||||
|
||||
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly with all props", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Share survey")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("success-message")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
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 { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
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 { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
@@ -26,13 +27,14 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
start: boolean;
|
||||
share: boolean;
|
||||
embed: boolean;
|
||||
panel: boolean;
|
||||
dropdown: boolean;
|
||||
}
|
||||
|
||||
export const SurveyAnalysisCTA = ({
|
||||
@@ -42,21 +44,20 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslate();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
share: searchParams.get("share") === "true",
|
||||
embed: false,
|
||||
panel: false,
|
||||
dropdown: false,
|
||||
start: searchParams.get("share") === "true",
|
||||
share: false,
|
||||
});
|
||||
|
||||
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
|
||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||
|
||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
@@ -64,35 +65,23 @@ export const SurveyAnalysisCTA = ({
|
||||
useEffect(() => {
|
||||
setModalState((prev) => ({
|
||||
...prev,
|
||||
share: searchParams.get("share") === "true",
|
||||
start: searchParams.get("share") === "true",
|
||||
}));
|
||||
}, [searchParams]);
|
||||
|
||||
const handleShareModalToggle = (open: boolean) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (open) {
|
||||
params.set("share", "true");
|
||||
} else {
|
||||
params.delete("share");
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
setModalState((prev) => ({ ...prev, share: open }));
|
||||
};
|
||||
const currentShareParam = params.get("share") === "true";
|
||||
|
||||
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 }));
|
||||
if (open && !currentShareParam) {
|
||||
params.set("share", "true");
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
} else if (!open && currentShareParam) {
|
||||
params.delete("share");
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}
|
||||
|
||||
setModalState((prev) => ({ ...prev, start: open }));
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
||||
@@ -113,45 +102,23 @@ export const SurveyAnalysisCTA = ({
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getPreviewUrl = () => {
|
||||
const separator = surveyUrl.includes("?") ? "&" : "?";
|
||||
return `${surveyUrl}${separator}preview=true`;
|
||||
};
|
||||
const getPreviewUrl = async () => {
|
||||
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
|
||||
|
||||
const handleModalState = (modalView: keyof Omit<ModalState, "dropdown">) => {
|
||||
return (open: boolean | ((prevState: boolean) => boolean)) => {
|
||||
const newValue = typeof open === "function" ? open(modalState[modalView]) : open;
|
||||
setModalState((prev) => ({ ...prev, [modalView]: newValue }));
|
||||
};
|
||||
};
|
||||
if (survey.singleUse?.enabled) {
|
||||
const newId = await refreshSingleUseId();
|
||||
if (newId) {
|
||||
surveyUrl.searchParams.set("suId", newId);
|
||||
}
|
||||
}
|
||||
|
||||
const shareEmbedViews = [
|
||||
{ key: "share", modalView: "start" as const, setOpen: handleShareModalToggle },
|
||||
{ key: "embed", modalView: "embed" as const, setOpen: handleModalState("embed") },
|
||||
{ key: "panel", modalView: "panel" as const, setOpen: handleModalState("panel") },
|
||||
];
|
||||
surveyUrl.searchParams.set("preview", "true");
|
||||
return surveyUrl.toString();
|
||||
};
|
||||
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
|
||||
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,
|
||||
tooltip: t("environments.surveys.summary.configure_alerts"),
|
||||
@@ -159,13 +126,13 @@ export const SurveyAnalysisCTA = ({
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
{
|
||||
icon: UsersRound,
|
||||
tooltip: t("environments.surveys.summary.send_to_panel"),
|
||||
onClick: () => {
|
||||
handleModalState("panel")(true);
|
||||
setModalState((prev) => ({ ...prev, dropdown: false }));
|
||||
icon: Eye,
|
||||
tooltip: t("common.preview"),
|
||||
onClick: async () => {
|
||||
const previewUrl = await getPreviewUrl();
|
||||
window.open(previewUrl, "_blank");
|
||||
},
|
||||
isVisible: !isReadOnly,
|
||||
isVisible: survey.type === "link",
|
||||
},
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
@@ -195,23 +162,32 @@ export const SurveyAnalysisCTA = ({
|
||||
)}
|
||||
|
||||
<IconBar actions={iconActions} />
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModalState((prev) => ({ ...prev, share: true }));
|
||||
}}>
|
||||
{t("environments.surveys.summary.share_survey")}
|
||||
</Button>
|
||||
|
||||
{user && (
|
||||
<>
|
||||
{shareEmbedViews.map(({ key, modalView, setOpen }) => (
|
||||
<ShareEmbedSurvey
|
||||
key={key}
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
open={modalState[key as keyof ModalState]}
|
||||
setOpen={setOpen}
|
||||
user={user}
|
||||
modalView={modalView}
|
||||
/>
|
||||
))}
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
</>
|
||||
<ShareSurveyModal
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
open={modalState.start || modalState.share}
|
||||
setOpen={(open) => {
|
||||
if (!open) {
|
||||
handleShareModalToggle(false);
|
||||
setModalState((prev) => ({ ...prev, share: false }));
|
||||
}
|
||||
}}
|
||||
user={user}
|
||||
modalView={modalState.start ? "start" : "share"}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
|
||||
{responseCount > 0 && (
|
||||
<EditPublicSurveyAlertDialog
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { ShareSurveyModal } from "./share-survey-modal";
|
||||
|
||||
// Mock getPublicDomain - must be first to prevent server-side env access
|
||||
vi.mock("@/lib/getPublicUrl", () => ({
|
||||
getPublicDomain: vi.fn().mockReturnValue("https://example.com"),
|
||||
}));
|
||||
|
||||
// Mock env to prevent server-side env access
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
IS_FORMBRICKS_CLOUD: "0",
|
||||
NODE_ENV: "test",
|
||||
E2E_TESTING: "0",
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-characters",
|
||||
WEBAPP_URL: "https://example.com",
|
||||
CRON_SECRET: "test-cron-secret",
|
||||
PUBLIC_URL: "https://example.com",
|
||||
VERCEL_URL: "",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the useTranslate hook
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"environments.surveys.summary.single_use_links": "Single-use links",
|
||||
"environments.surveys.summary.share_the_link": "Share the link",
|
||||
"environments.surveys.summary.qr_code": "QR Code",
|
||||
"environments.surveys.summary.personal_links": "Personal links",
|
||||
"environments.surveys.summary.embed_in_an_email": "Embed in email",
|
||||
"environments.surveys.summary.embed_on_website": "Embed on website",
|
||||
"environments.surveys.summary.dynamic_popup": "Dynamic popup",
|
||||
"environments.surveys.summary.in_app.title": "In-app survey",
|
||||
"environments.surveys.summary.in_app.description": "Display survey in your app",
|
||||
"environments.surveys.share.anonymous_links.nav_title": "Share the link",
|
||||
"environments.surveys.share.single_use_links.nav_title": "Single-use links",
|
||||
"environments.surveys.share.personal_links.nav_title": "Personal links",
|
||||
"environments.surveys.share.embed_on_website.nav_title": "Embed on website",
|
||||
"environments.surveys.share.send_email.nav_title": "Embed in email",
|
||||
"environments.surveys.share.social_media.title": "Social media",
|
||||
"environments.surveys.share.dynamic_popup.nav_title": "Dynamic popup",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock analysis utils
|
||||
vi.mock("@/modules/analysis/utils", () => ({
|
||||
getSurveyUrl: vi.fn().mockResolvedValue("https://example.com/s/test-survey-id"),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock dialog components
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ open, onOpenChange, children }: any) => (
|
||||
<div data-testid="dialog" data-open={open} onClick={() => onOpenChange(false)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogContent: ({ children, width }: any) => (
|
||||
<div data-testid="dialog-content" data-width={width}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogTitle: ({ children }: any) => <div data-testid="dialog-title">{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock VisuallyHidden
|
||||
vi.mock("@radix-ui/react-visually-hidden", () => ({
|
||||
VisuallyHidden: ({ asChild, children }: any) => (
|
||||
<div data-testid="visually-hidden">{asChild ? children : <span>{children}</span>}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab",
|
||||
() => ({
|
||||
AppTab: () => <div data-testid="app-tab">App Tab Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container",
|
||||
() => ({
|
||||
TabContainer: ({ title, description, children }: any) => (
|
||||
<div data-testid="tab-container">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("./shareEmbedModal/share-view", () => ({
|
||||
ShareView: ({ tabs, activeId, setActiveId }: any) => (
|
||||
<div data-testid="share-view" data-active-id={activeId}>
|
||||
<h3>Share View</h3>
|
||||
<div data-testid="share-view-data">
|
||||
<div>Active Tab: {activeId}</div>
|
||||
</div>
|
||||
<div data-testid="tabs">
|
||||
{tabs.map((tab: any) => (
|
||||
<button key={tab.id} onClick={() => setActiveId(tab.id)} data-testid={`tab-${tab.id}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./shareEmbedModal/success-view", () => ({
|
||||
SuccessView: ({
|
||||
survey,
|
||||
surveyUrl,
|
||||
publicDomain,
|
||||
user,
|
||||
tabs,
|
||||
handleViewChange,
|
||||
handleEmbedViewWithTab,
|
||||
}: any) => (
|
||||
<div data-testid="success-view">
|
||||
<h3>Success View</h3>
|
||||
<div data-testid="success-view-data">
|
||||
<div>Survey: {survey?.id}</div>
|
||||
<div>URL: {surveyUrl}</div>
|
||||
<div>Domain: {publicDomain}</div>
|
||||
<div>User: {user?.id}</div>
|
||||
</div>
|
||||
<div data-testid="success-tabs">
|
||||
{tabs.map((tab: any) => {
|
||||
// Handle single-use links case
|
||||
let displayLabel = tab.label;
|
||||
if (tab.id === "anon-links" && survey?.singleUse?.enabled) {
|
||||
displayLabel = "Single-use links";
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleEmbedViewWithTab(tab.id)}
|
||||
data-testid={`success-tab-${tab.id}`}>
|
||||
{displayLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button onClick={() => handleViewChange("share")} data-testid="go-to-share-view">
|
||||
Go to Share View
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as any;
|
||||
return {
|
||||
...actual,
|
||||
Code2Icon: () => <svg data-testid="code2-icon" />,
|
||||
LinkIcon: () => <svg data-testid="link-icon" />,
|
||||
MailIcon: () => <svg data-testid="mail-icon" />,
|
||||
QrCodeIcon: () => <svg data-testid="qrcode-icon" />,
|
||||
SmartphoneIcon: () => <svg data-testid="smartphone-icon" />,
|
||||
SquareStack: () => <svg data-testid="square-stack-icon" />,
|
||||
UserIcon: () => <svg data-testid="user-icon" />,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock data
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "test-survey-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
environmentId: "test-env-id",
|
||||
status: "inProgress",
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
|
||||
questions: [],
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false },
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
|
||||
segment: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
singleUse: { enabled: false, isEncrypted: false },
|
||||
projectOverwrites: null,
|
||||
surveyClosedMessage: null,
|
||||
delay: 0,
|
||||
isVerifyEmailEnabled: false,
|
||||
createdBy: null,
|
||||
variables: [],
|
||||
followUps: [],
|
||||
runOnDate: null,
|
||||
closeOnDate: null,
|
||||
styling: null,
|
||||
pin: null,
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
const mockAppSurvey: TSurvey = {
|
||||
...mockSurvey,
|
||||
type: "app",
|
||||
};
|
||||
|
||||
const mockUser: TUser = {
|
||||
id: "test-user-id",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "https://example.com/avatar.jpg",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
role: "other",
|
||||
objective: "other",
|
||||
locale: "en-US",
|
||||
lastLoginAt: new Date(),
|
||||
isActive: true,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const mockSegments: TSegment[] = [];
|
||||
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
survey: mockSurvey,
|
||||
publicDomain: "https://example.com",
|
||||
open: true,
|
||||
modalView: "start" as const,
|
||||
setOpen: mockSetOpen,
|
||||
user: mockUser,
|
||||
segments: mockSegments,
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
};
|
||||
|
||||
describe("ShareSurveyModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders dialog when open is true", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success view when modalView is start", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
|
||||
|
||||
expect(screen.getByTestId("success-view")).toBeInTheDocument();
|
||||
expect(screen.getByText("Success View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders share view when modalView is share and survey is link type", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||
|
||||
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||
expect(screen.getByText("Share View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders app tab when survey is app type and modalView is share", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
|
||||
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("In-app survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("Display survey in your app")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success view when survey is app type and modalView is start", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="start" />);
|
||||
|
||||
expect(screen.getByTestId("success-view")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tab-container")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("sets correct width for dialog content based on survey type", () => {
|
||||
const { rerender } = render(<ShareSurveyModal {...defaultProps} survey={mockSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("dialog-content")).toHaveAttribute("data-width", "wide");
|
||||
|
||||
rerender(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("dialog-content")).toHaveAttribute("data-width", "default");
|
||||
});
|
||||
|
||||
test("generates correct tabs for link survey", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
|
||||
|
||||
expect(screen.getByTestId("success-tab-anon-links")).toHaveTextContent("Share the link");
|
||||
expect(screen.getByTestId("success-tab-qr-code")).toHaveTextContent("QR Code");
|
||||
expect(screen.getByTestId("success-tab-personal-links")).toHaveTextContent("Personal links");
|
||||
expect(screen.getByTestId("success-tab-email")).toHaveTextContent("Embed in email");
|
||||
expect(screen.getByTestId("success-tab-website-embed")).toHaveTextContent("Embed on website");
|
||||
expect(screen.getByTestId("success-tab-dynamic-popup")).toHaveTextContent("Dynamic popup");
|
||||
});
|
||||
|
||||
test("shows single-use links label when singleUse is enabled", () => {
|
||||
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
render(<ShareSurveyModal {...defaultProps} survey={singleUseSurvey} modalView="start" />);
|
||||
|
||||
expect(screen.getByTestId("success-tab-anon-links")).toHaveTextContent("Single-use links");
|
||||
});
|
||||
|
||||
test("calls setOpen when dialog is closed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ShareSurveyModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByTestId("dialog"));
|
||||
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("fetches survey URL on mount", async () => {
|
||||
const { getSurveyUrl } = await import("@/modules/analysis/utils");
|
||||
|
||||
render(<ShareSurveyModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSurveyUrl).toHaveBeenCalledWith(mockSurvey, "https://example.com", "default");
|
||||
});
|
||||
});
|
||||
|
||||
test("handles getSurveyUrl failure gracefully", async () => {
|
||||
const { getSurveyUrl } = await import("@/modules/analysis/utils");
|
||||
vi.mocked(getSurveyUrl).mockRejectedValue(new Error("Failed to fetch"));
|
||||
|
||||
// Render and verify it doesn't crash, even if nothing renders due to the error
|
||||
expect(() => {
|
||||
render(<ShareSurveyModal {...defaultProps} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("renders ShareView with correct active tab", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||
|
||||
const shareViewData = screen.getByTestId("share-view-data");
|
||||
expect(shareViewData).toHaveTextContent("Active Tab: anon-links");
|
||||
});
|
||||
|
||||
test("passes correct props to SuccessView", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
|
||||
|
||||
const successViewData = screen.getByTestId("success-view-data");
|
||||
expect(successViewData).toHaveTextContent("Survey: test-survey-id");
|
||||
expect(successViewData).toHaveTextContent("Domain: https://example.com");
|
||||
expect(successViewData).toHaveTextContent("User: test-user-id");
|
||||
});
|
||||
|
||||
test("resets to start view when modal is closed and reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||
|
||||
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||
|
||||
rerender(<ShareSurveyModal {...defaultProps} modalView="share" open={false} />);
|
||||
rerender(<ShareSurveyModal {...defaultProps} modalView="share" open={true} />);
|
||||
|
||||
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("sets correct active tab for link survey", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||
|
||||
expect(screen.getByTestId("share-view")).toHaveAttribute("data-active-id", "anon-links");
|
||||
});
|
||||
|
||||
test("renders tab container for app survey in share mode", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
|
||||
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with contacts disabled", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="share" isContactsEnabled={false} />);
|
||||
|
||||
// Just verify the ShareView renders correctly regardless of isContactsEnabled prop
|
||||
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("share-view")).toHaveAttribute("data-active-id", "anon-links");
|
||||
});
|
||||
|
||||
test("renders with formbricks cloud enabled", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="share" isFormbricksCloud={true} />);
|
||||
|
||||
// Just verify the ShareView renders correctly regardless of isFormbricksCloud prop
|
||||
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("correctly handles direct navigation to share view", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||
|
||||
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("success-view")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handler functions are passed to child components", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
|
||||
|
||||
// Verify SuccessView receives the handler functions by checking buttons exist
|
||||
expect(screen.getByTestId("go-to-share-view")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("success-tab-anon-links")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("success-tab-qr-code")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("tab switching functionality is available in ShareView", () => {
|
||||
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||
|
||||
// Verify ShareView has tab switching buttons
|
||||
expect(screen.getByTestId("tab-anon-links")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tab-qr-code")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tab-personal-links")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders different content based on survey type", () => {
|
||||
// Link survey renders ShareView
|
||||
const { rerender } = render(<ShareSurveyModal {...defaultProps} survey={mockSurvey} modalView="share" />);
|
||||
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||
|
||||
// App survey renders TabContainer with AppTab
|
||||
rerender(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
|
||||
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
|
||||
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
|
||||
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
||||
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
||||
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
|
||||
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
|
||||
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||
import { WebsiteEmbedTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab";
|
||||
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { getSurveyUrl } from "@/modules/analysis/utils";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { ShareView } from "./shareEmbedModal/share-view";
|
||||
import { SuccessView } from "./shareEmbedModal/success-view";
|
||||
|
||||
type ModalView = "start" | "share";
|
||||
|
||||
interface ShareSurveyModalProps {
|
||||
survey: TSurvey;
|
||||
publicDomain: string;
|
||||
open: boolean;
|
||||
modalView: ModalView;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
user: TUser;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export const ShareSurveyModal = ({
|
||||
survey,
|
||||
publicDomain,
|
||||
open,
|
||||
modalView,
|
||||
setOpen,
|
||||
user,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: ShareSurveyModalProps) => {
|
||||
const environmentId = survey.environmentId;
|
||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||
const [showView, setShowView] = useState<ModalView>(modalView);
|
||||
const { email } = user;
|
||||
const { t } = useTranslate();
|
||||
const linkTabs: {
|
||||
id: ShareViewType;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
componentType: React.ComponentType<any>;
|
||||
componentProps: any;
|
||||
}[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: ShareViewType.ANON_LINKS,
|
||||
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||
icon: LinkIcon,
|
||||
title: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||
description: t("environments.surveys.share.anonymous_links.description"),
|
||||
componentType: AnonymousLinksTab,
|
||||
componentProps: {
|
||||
survey,
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale: user.locale,
|
||||
surveyUrl,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: ShareViewType.PERSONAL_LINKS,
|
||||
label: t("environments.surveys.share.personal_links.nav_title"),
|
||||
icon: UserIcon,
|
||||
title: t("environments.surveys.share.personal_links.nav_title"),
|
||||
description: t("environments.surveys.share.personal_links.description"),
|
||||
componentType: PersonalLinksTab,
|
||||
componentProps: {
|
||||
environmentId,
|
||||
surveyId: survey.id,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: ShareViewType.WEBSITE_EMBED,
|
||||
label: t("environments.surveys.share.embed_on_website.nav_title"),
|
||||
icon: Code2Icon,
|
||||
title: t("environments.surveys.share.embed_on_website.nav_title"),
|
||||
description: t("environments.surveys.share.embed_on_website.description"),
|
||||
componentType: WebsiteEmbedTab,
|
||||
componentProps: { surveyUrl },
|
||||
},
|
||||
{
|
||||
id: ShareViewType.EMAIL,
|
||||
label: t("environments.surveys.share.send_email.nav_title"),
|
||||
icon: MailIcon,
|
||||
title: t("environments.surveys.share.send_email.nav_title"),
|
||||
description: t("environments.surveys.share.send_email.description"),
|
||||
componentType: EmailTab,
|
||||
componentProps: { surveyId: survey.id, email },
|
||||
},
|
||||
{
|
||||
id: ShareViewType.SOCIAL_MEDIA,
|
||||
label: t("environments.surveys.share.social_media.title"),
|
||||
icon: Share2Icon,
|
||||
title: t("environments.surveys.share.social_media.title"),
|
||||
description: t("environments.surveys.share.social_media.description"),
|
||||
componentType: SocialMediaTab,
|
||||
componentProps: { surveyUrl, surveyTitle: survey.name },
|
||||
},
|
||||
{
|
||||
id: ShareViewType.QR_CODE,
|
||||
label: t("environments.surveys.summary.qr_code"),
|
||||
icon: QrCodeIcon,
|
||||
title: t("environments.surveys.summary.qr_code"),
|
||||
description: t("environments.surveys.summary.qr_code_description"),
|
||||
componentType: QRCodeTab,
|
||||
componentProps: { surveyUrl },
|
||||
},
|
||||
{
|
||||
id: ShareViewType.DYNAMIC_POPUP,
|
||||
label: t("environments.surveys.share.dynamic_popup.nav_title"),
|
||||
icon: SquareStack,
|
||||
title: t("environments.surveys.share.dynamic_popup.nav_title"),
|
||||
description: t("environments.surveys.share.dynamic_popup.description"),
|
||||
componentType: DynamicPopupTab,
|
||||
componentProps: { environmentId, surveyId: survey.id },
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
survey,
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
user.locale,
|
||||
surveyUrl,
|
||||
environmentId,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
email,
|
||||
]
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState(
|
||||
survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setShowView(modalView);
|
||||
}
|
||||
}, [open, modalView]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setShowView("start");
|
||||
setActiveId(ShareViewType.ANON_LINKS);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewChange = (view: ModalView) => {
|
||||
setShowView(view);
|
||||
};
|
||||
|
||||
const handleEmbedViewWithTab = (tabId: ShareViewType) => {
|
||||
setShowView("share");
|
||||
setActiveId(tabId);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (showView === "start") {
|
||||
return (
|
||||
<SuccessView
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
user={user}
|
||||
tabs={linkTabs}
|
||||
handleViewChange={handleViewChange}
|
||||
handleEmbedViewWithTab={handleEmbedViewWithTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (survey.type === "link") {
|
||||
return <ShareView tabs={linkTabs} activeId={activeId} setActiveId={setActiveId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full rounded-lg bg-slate-50 p-6`}>
|
||||
<TabContainer
|
||||
title={t("environments.surveys.summary.in_app.title")}
|
||||
description={t("environments.surveys.summary.in_app.description")}>
|
||||
<AppTab />
|
||||
</TabContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogTitle />
|
||||
</VisuallyHidden>
|
||||
<DialogContent
|
||||
className="w-full bg-white p-0 lg:h-[700px]"
|
||||
width={survey.type === "link" ? "wide" : "default"}
|
||||
aria-describedby={undefined}
|
||||
unconstrained>
|
||||
{renderContent()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +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 { AppTab } from "./AppTab";
|
||||
|
||||
vi.mock("@/modules/ui/components/options-switch", () => ({
|
||||
OptionsSwitch: (props: {
|
||||
options: Array<{ value: string; label: string }>;
|
||||
handleOptionChange: (value: string) => void;
|
||||
}) => (
|
||||
<div data-testid="options-switch">
|
||||
{props.options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
data-testid={`option-${option.value}`}
|
||||
onClick={() => props.handleOptionChange(option.value)}>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab",
|
||||
() => ({
|
||||
MobileAppTab: () => <div data-testid="mobile-app-tab">MobileAppTab</div>,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab",
|
||||
() => ({
|
||||
WebAppTab: () => <div data-testid="web-app-tab">WebAppTab</div>,
|
||||
})
|
||||
);
|
||||
|
||||
describe("AppTab", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders correctly by default with WebAppTab visible", () => {
|
||||
render(<AppTab />);
|
||||
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("option-webapp")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("option-mobile")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId("web-app-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mobile-app-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches to MobileAppTab when mobile option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppTab />);
|
||||
|
||||
const mobileOptionButton = screen.getByTestId("option-mobile");
|
||||
await user.click(mobileOptionButton);
|
||||
|
||||
expect(screen.getByTestId("mobile-app-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("web-app-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MobileAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab";
|
||||
import { WebAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const AppTab = () => {
|
||||
const { t } = useTranslate();
|
||||
const [selectedTab, setSelectedTab] = useState("webapp");
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<OptionsSwitch
|
||||
options={[
|
||||
{ value: "webapp", label: t("environments.surveys.summary.web_app") },
|
||||
{ value: "mobile", label: t("environments.surveys.summary.mobile_app") },
|
||||
]}
|
||||
currentOption={selectedTab}
|
||||
handleOptionChange={(value) => setSelectedTab(value)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">{selectedTab === "webapp" ? <WebAppTab /> : <MobileAppTab />}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Code2Icon, CopyIcon, MailIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
|
||||
|
||||
interface EmailTabProps {
|
||||
surveyId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
const [showEmbed, setShowEmbed] = useState(false);
|
||||
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
|
||||
const { t } = useTranslate();
|
||||
const emailHtml = useMemo(() => {
|
||||
if (!emailHtmlPreview) return "";
|
||||
return emailHtmlPreview
|
||||
.replaceAll("?preview=true&", "?")
|
||||
.replaceAll("?preview=true&;", "?")
|
||||
.replaceAll("?preview=true", "");
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
const getData = async () => {
|
||||
const emailHtml = await getEmailHtmlAction({ surveyId });
|
||||
setEmailHtmlPreview(emailHtml?.data || "");
|
||||
};
|
||||
|
||||
getData();
|
||||
}, [surveyId]);
|
||||
|
||||
const sendPreviewEmail = async () => {
|
||||
try {
|
||||
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
|
||||
if (val?.data) {
|
||||
toast.success(t("environments.surveys.summary.email_sent"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(val);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
toast.error(t("common.not_authenticated"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
{showEmbed ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
title="Embed survey in your website"
|
||||
aria-label="Embed survey in your website"
|
||||
onClick={() => {
|
||||
toast.success(t("environments.surveys.summary.embed_code_copied_to_clipboard"));
|
||||
navigator.clipboard.writeText(emailHtml);
|
||||
}}
|
||||
className="shrink-0">
|
||||
{t("common.copy_code")}
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
title="send preview email"
|
||||
aria-label="send preview email"
|
||||
onClick={() => sendPreviewEmail()}
|
||||
className="shrink-0">
|
||||
{t("environments.surveys.summary.send_preview")}
|
||||
<MailIcon />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
title={t("environments.surveys.summary.view_embed_code_for_email")}
|
||||
aria-label={t("environments.surveys.summary.view_embed_code_for_email")}
|
||||
onClick={() => {
|
||||
setShowEmbed(!showEmbed);
|
||||
}}
|
||||
className="shrink-0">
|
||||
{showEmbed
|
||||
? t("environments.surveys.summary.hide_embed_code")
|
||||
: t("environments.surveys.summary.view_embed_code")}
|
||||
<Code2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
{showEmbed ? (
|
||||
<div className="prose prose-slate -mt-4 max-w-full">
|
||||
<CodeBlock
|
||||
customCodeClass="text-sm h-48 overflow-y-scroll"
|
||||
language="html"
|
||||
showCopyToClipboard={false}>
|
||||
{emailHtml}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-12 grow overflow-y-auto rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div className="mb-6 flex gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">To : {email || "user@mail.com"}</div>
|
||||
<div className="border-b border-slate-200 pb-2 text-sm">
|
||||
Subject : {t("environments.surveys.summary.formbricks_email_survey_preview")}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{emailHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: emailHtmlPreview }}></div>
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,154 +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 { EmbedView } from "./EmbedView";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("./AppTab", () => ({
|
||||
AppTab: () => <div data-testid="app-tab">AppTab Content</div>,
|
||||
}));
|
||||
vi.mock("./EmailTab", () => ({
|
||||
EmailTab: (props: { surveyId: string; email: string }) => (
|
||||
<div data-testid="email-tab">
|
||||
EmailTab Content for {props.surveyId} with {props.email}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./LinkTab", () => ({
|
||||
LinkTab: (props: { survey: any; surveyUrl: string }) => (
|
||||
<div data-testid="link-tab">
|
||||
LinkTab Content for {props.survey.id} at {props.surveyUrl}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./WebsiteTab", () => ({
|
||||
WebsiteTab: (props: { surveyUrl: string; environmentId: string }) => (
|
||||
<div data-testid="website-tab">
|
||||
WebsiteTab Content for {props.surveyUrl} in {props.environmentId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock @tolgee/react
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
ArrowLeftIcon: () => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>,
|
||||
MailIcon: () => <div data-testid="mail-icon">MailIcon</div>,
|
||||
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
|
||||
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
|
||||
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
|
||||
}));
|
||||
|
||||
const mockTabs = [
|
||||
{ id: "email", label: "Email", icon: () => <div data-testid="email-tab-icon" /> },
|
||||
{ id: "webpage", label: "Web Page", icon: () => <div data-testid="webpage-tab-icon" /> },
|
||||
{ id: "link", label: "Link", icon: () => <div data-testid="link-tab-icon" /> },
|
||||
{ id: "app", label: "App", icon: () => <div data-testid="app-tab-icon" /> },
|
||||
];
|
||||
|
||||
const mockSurveyLink = { id: "survey1", type: "link" };
|
||||
const mockSurveyWeb = { id: "survey2", type: "web" };
|
||||
|
||||
const defaultProps = {
|
||||
handleInitialPageButton: vi.fn(),
|
||||
tabs: mockTabs,
|
||||
activeId: "email",
|
||||
setActiveId: vi.fn(),
|
||||
environmentId: "env1",
|
||||
survey: mockSurveyLink,
|
||||
email: "test@example.com",
|
||||
surveyUrl: "http://example.com/survey1",
|
||||
publicDomain: "http://example.com",
|
||||
setSurveyUrl: vi.fn(),
|
||||
locale: "en" as any,
|
||||
disableBack: false,
|
||||
};
|
||||
|
||||
describe("EmbedView", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
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", () => {
|
||||
render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />);
|
||||
// Desktop tabs container should not be present or not have lg:flex if it's a common parent
|
||||
const desktopTabsButtons = screen.queryAllByRole("button", { name: /Email|Web Page|Link|App/i });
|
||||
// Check if any of these buttons are part of a container that is only visible on large screens
|
||||
const desktopTabContainer = desktopTabsButtons[0]?.closest("div.lg\\:flex");
|
||||
expect(desktopTabContainer).toBeNull();
|
||||
});
|
||||
|
||||
test("calls setActiveId when a tab is clicked (desktop)", async () => {
|
||||
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
|
||||
const webpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[0]; // First one is desktop
|
||||
await userEvent.click(webpageTabButton);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage");
|
||||
});
|
||||
|
||||
test("renders EmailTab when activeId is 'email'", () => {
|
||||
render(<EmbedView {...defaultProps} activeId="email" />);
|
||||
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`EmailTab Content for ${defaultProps.survey.id} with ${defaultProps.email}`)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders WebsiteTab when activeId is 'webpage'", () => {
|
||||
render(<EmbedView {...defaultProps} activeId="webpage" />);
|
||||
expect(screen.getByTestId("website-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`WebsiteTab Content for ${defaultProps.surveyUrl} in ${defaultProps.environmentId}`)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LinkTab when activeId is 'link'", () => {
|
||||
render(<EmbedView {...defaultProps} activeId="link" />);
|
||||
expect(screen.getByTestId("link-tab")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`LinkTab Content for ${defaultProps.survey.id} at ${defaultProps.surveyUrl}`)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders AppTab when activeId is 'app'", () => {
|
||||
render(<EmbedView {...defaultProps} activeId="app" />);
|
||||
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setActiveId when a responsive tab is clicked", async () => {
|
||||
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
|
||||
// Get the responsive tab button (second instance of the button with this name)
|
||||
const responsiveWebpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[1];
|
||||
await userEvent.click(responsiveWebpageTabButton);
|
||||
expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage");
|
||||
});
|
||||
|
||||
test("applies active styles to the active tab (desktop)", () => {
|
||||
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
|
||||
const emailTabButton = screen.getAllByRole("button", { name: "Email" })[0];
|
||||
expect(emailTabButton).toHaveClass("border-slate-200 bg-slate-100 font-semibold text-slate-900");
|
||||
|
||||
const webpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[0];
|
||||
expect(webpageTabButton).toHaveClass("border-transparent text-slate-500 hover:text-slate-700");
|
||||
});
|
||||
|
||||
test("applies active styles to the active tab (responsive)", () => {
|
||||
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
|
||||
const responsiveEmailTabButton = screen.getAllByRole("button", { name: "Email" })[1];
|
||||
expect(responsiveEmailTabButton).toHaveClass("bg-white text-slate-900 shadow-sm");
|
||||
|
||||
const responsiveWebpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[1];
|
||||
expect(responsiveWebpageTabButton).toHaveClass("border-transparent text-slate-700 hover:text-slate-900");
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AppTab } from "./AppTab";
|
||||
import { EmailTab } from "./EmailTab";
|
||||
import { LinkTab } from "./LinkTab";
|
||||
import { WebsiteTab } from "./WebsiteTab";
|
||||
|
||||
interface EmbedViewProps {
|
||||
handleInitialPageButton: () => void;
|
||||
tabs: Array<{ id: string; label: string; icon: any }>;
|
||||
activeId: string;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<string>>;
|
||||
environmentId: string;
|
||||
disableBack: boolean;
|
||||
survey: any;
|
||||
email: string;
|
||||
surveyUrl: string;
|
||||
publicDomain: string;
|
||||
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const EmbedView = ({
|
||||
handleInitialPageButton,
|
||||
tabs,
|
||||
disableBack,
|
||||
activeId,
|
||||
setActiveId,
|
||||
environmentId,
|
||||
survey,
|
||||
email,
|
||||
surveyUrl,
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
}: EmbedViewProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<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">
|
||||
{survey.type === "link" && (
|
||||
<div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}>
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
autoFocus={tab.id === activeId}
|
||||
className={cn(
|
||||
"flex justify-start rounded-md border px-4 py-2 text-slate-600",
|
||||
// "focus:ring-0 focus:ring-offset-0", // enable these classes to remove the focus rings on buttons
|
||||
tab.id === activeId
|
||||
? "border-slate-200 bg-slate-100 font-semibold text-slate-900"
|
||||
: "border-transparent text-slate-500 hover:text-slate-700"
|
||||
)}
|
||||
aria-current={tab.id === activeId ? "page" : undefined}>
|
||||
<tab.icon />
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</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`}>
|
||||
{activeId === "email" ? (
|
||||
<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">
|
||||
{tabs.slice(0, 2).map((tab) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2",
|
||||
tab.id === activeId
|
||||
? "bg-white text-slate-900 shadow-sm"
|
||||
: "border-transparent text-slate-700 hover:text-slate-900"
|
||||
)}>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { LinkTab } from "./LinkTab";
|
||||
|
||||
// Mock ShareSurveyLink
|
||||
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
|
||||
ShareSurveyLink: vi.fn(({ survey, surveyUrl, publicDomain, locale }) => (
|
||||
<div data-testid="share-survey-link">
|
||||
Mocked ShareSurveyLink
|
||||
<span data-testid="survey-id">{survey.id}</span>
|
||||
<span data-testid="survey-url">{surveyUrl}</span>
|
||||
<span data-testid="public-domain">{publicDomain}</span>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock useTranslate
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: mockTranslate,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock next/link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, ...props }: any) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
thankYouCard: { enabled: false },
|
||||
endings: [],
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
languages: [],
|
||||
styling: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
|
||||
const mockPublicDomain = "https://app.formbricks.com";
|
||||
const mockSetSurveyUrl = vi.fn();
|
||||
const mockLocale: TUserLocale = "en-US";
|
||||
|
||||
const docsLinksExpected = [
|
||||
{
|
||||
titleKey: "environments.surveys.summary.data_prefilling",
|
||||
descriptionKey: "environments.surveys.summary.data_prefilling_description",
|
||||
link: "https://formbricks.com/docs/link-surveys/data-prefilling",
|
||||
},
|
||||
{
|
||||
titleKey: "environments.surveys.summary.source_tracking",
|
||||
descriptionKey: "environments.surveys.summary.source_tracking_description",
|
||||
link: "https://formbricks.com/docs/link-surveys/source-tracking",
|
||||
},
|
||||
{
|
||||
titleKey: "environments.surveys.summary.create_single_use_links",
|
||||
descriptionKey: "environments.surveys.summary.create_single_use_links_description",
|
||||
link: "https://formbricks.com/docs/link-surveys/single-use-links",
|
||||
},
|
||||
];
|
||||
|
||||
describe("LinkTab", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders the main title", () => {
|
||||
render(
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.share_the_link_to_get_responses")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ShareSurveyLink with correct props", () => {
|
||||
render(
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
|
||||
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
|
||||
expect(screen.getByTestId("public-domain")).toHaveTextContent(mockPublicDomain);
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
|
||||
});
|
||||
|
||||
test("renders the promotional text for link surveys", () => {
|
||||
render(
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys 💡")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders all documentation links correctly", () => {
|
||||
render(
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
|
||||
docsLinksExpected.forEach((doc) => {
|
||||
const linkElement = screen.getByText(doc.titleKey).closest("a");
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
expect(linkElement).toHaveAttribute("href", doc.link);
|
||||
expect(linkElement).toHaveAttribute("target", "_blank");
|
||||
expect(screen.getByText(doc.descriptionKey)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling");
|
||||
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling_description");
|
||||
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking");
|
||||
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking_description");
|
||||
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.create_single_use_links");
|
||||
expect(mockTranslate).toHaveBeenCalledWith(
|
||||
"environments.surveys.summary.create_single_use_links_description"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface LinkTabProps {
|
||||
survey: TSurvey;
|
||||
surveyUrl: string;
|
||||
publicDomain: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale }: LinkTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const docsLinks = [
|
||||
{
|
||||
title: t("environments.surveys.summary.data_prefilling"),
|
||||
description: t("environments.surveys.summary.data_prefilling_description"),
|
||||
link: "https://formbricks.com/docs/link-surveys/data-prefilling",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.summary.source_tracking"),
|
||||
description: t("environments.surveys.summary.source_tracking_description"),
|
||||
link: "https://formbricks.com/docs/link-surveys/source-tracking",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.summary.create_single_use_links"),
|
||||
description: t("environments.surveys.summary.create_single_use_links_description"),
|
||||
link: "https://formbricks.com/docs/link-surveys/single-use-links",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col gap-6">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.share_the_link_to_get_responses")}
|
||||
</p>
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<p className="pt-2 font-semibold text-slate-700">
|
||||
{t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{docsLinks.map((tip) => (
|
||||
<Link
|
||||
key={tip.title}
|
||||
target="_blank"
|
||||
href={tip.link}
|
||||
className="relative w-full rounded-md border border-slate-100 bg-white px-6 py-4 text-sm text-slate-600 hover:bg-slate-50 hover:text-slate-800">
|
||||
<p className="mb-1 font-semibold">{tip.title}</p>
|
||||
<p className="text-slate-500 hover:text-slate-700">{tip.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { MobileAppTab } from "./MobileAppTab";
|
||||
|
||||
// Mock @tolgee/react
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key, // Return the key itself for easy assertion
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children }: { children: React.ReactNode }) => <div data-testid="alert">{children}</div>,
|
||||
AlertTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="alert-title">{children}</div>
|
||||
),
|
||||
AlertDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="alert-description">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, asChild, ...props }: { children: React.ReactNode; asChild?: boolean }) =>
|
||||
asChild ? <div {...props}>{children}</div> : <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
// Mock next/link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href, target, ...props }: any) => (
|
||||
<a href={href} target={target} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("MobileAppTab", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders correctly with title, description, and learn more link", () => {
|
||||
render(<MobileAppTab />);
|
||||
|
||||
// Check for Alert component
|
||||
expect(screen.getByTestId("alert")).toBeInTheDocument();
|
||||
|
||||
// Check for AlertTitle with correct Tolgee key
|
||||
const alertTitle = screen.getByTestId("alert-title");
|
||||
expect(alertTitle).toBeInTheDocument();
|
||||
expect(alertTitle).toHaveTextContent("environments.surveys.summary.quickstart_mobile_apps");
|
||||
|
||||
// Check for AlertDescription with correct Tolgee key
|
||||
const alertDescription = screen.getByTestId("alert-description");
|
||||
expect(alertDescription).toBeInTheDocument();
|
||||
expect(alertDescription).toHaveTextContent(
|
||||
"environments.surveys.summary.quickstart_mobile_apps_description"
|
||||
);
|
||||
|
||||
// Check for the "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/website-app-surveys/framework-guides"
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const MobileAppTab = () => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>{t("environments.surveys.summary.quickstart_mobile_apps")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.summary.quickstart_mobile_apps_description")}
|
||||
<Button asChild className="w-fit" size="sm" variant="link">
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides"
|
||||
target="_blank">
|
||||
{t("common.learn_more")}
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { WebAppTab } from "./WebAppTab";
|
||||
|
||||
vi.mock("@/modules/ui/components/button/Button", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
CopyIcon: () => <div data-testid="copy-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children }: { children: React.ReactNode }) => <div data-testid="alert">{children}</div>,
|
||||
AlertTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="alert-title">{children}</div>
|
||||
),
|
||||
AlertDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="alert-description">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock navigator.clipboard.writeText
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const surveyUrl = "https://app.formbricks.com/s/test-survey-id";
|
||||
const surveyId = "test-survey-id";
|
||||
|
||||
describe("WebAppTab", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly with surveyUrl and surveyId", () => {
|
||||
render(<WebAppTab />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.summary.quickstart_web_apps")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "common.learn_more" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/quickstart"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const WebAppTab = () => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>{t("environments.surveys.summary.quickstart_web_apps")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.summary.quickstart_web_apps_description")}
|
||||
<Button asChild className="w-fit" size="sm" variant="link">
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/quickstart"
|
||||
target="_blank">
|
||||
{t("common.learn_more")}
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -1,254 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { WebsiteTab } from "./WebsiteTab";
|
||||
|
||||
// Mock child components and hooks
|
||||
const mockAdvancedOptionToggle = vi.fn();
|
||||
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
|
||||
AdvancedOptionToggle: (props: any) => {
|
||||
mockAdvancedOptionToggle(props);
|
||||
return (
|
||||
<div data-testid="advanced-option-toggle">
|
||||
<span>{props.title}</span>
|
||||
<input type="checkbox" checked={props.isChecked} onChange={() => props.onToggle(!props.isChecked)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockCodeBlock = vi.fn();
|
||||
vi.mock("@/modules/ui/components/code-block", () => ({
|
||||
CodeBlock: (props: any) => {
|
||||
mockCodeBlock(props);
|
||||
return (
|
||||
<div data-testid="code-block" data-language={props.language}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const mockOptionsSwitch = vi.fn();
|
||||
vi.mock("@/modules/ui/components/options-switch", () => ({
|
||||
OptionsSwitch: (props: any) => {
|
||||
mockOptionsSwitch(props);
|
||||
return (
|
||||
<div data-testid="options-switch">
|
||||
{props.options.map((opt: { value: string; label: string }) => (
|
||||
<button key={opt.value} onClick={() => props.handleOptionChange(opt.value)}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
CopyIcon: () => <div data-testid="copy-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
|
||||
<a href={href} target={target} data-testid="next-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockWriteText = vi.fn();
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const surveyUrl = "https://app.formbricks.com/s/survey123";
|
||||
const environmentId = "env456";
|
||||
|
||||
describe("WebsiteTab", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders OptionsSwitch and StaticTab by default", () => {
|
||||
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
|
||||
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
|
||||
expect(mockOptionsSwitch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentOption: "static",
|
||||
options: [
|
||||
{ value: "static", label: "environments.surveys.summary.static_iframe" },
|
||||
{ value: "popup", label: "environments.surveys.summary.dynamic_popup" },
|
||||
],
|
||||
})
|
||||
);
|
||||
// StaticTab content checks
|
||||
expect(screen.getByText("common.copy_code")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("code-block")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.static_iframe")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.dynamic_popup")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches to PopupTab when 'Dynamic Popup' option is clicked", async () => {
|
||||
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
|
||||
const popupButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.summary.dynamic_popup",
|
||||
});
|
||||
await userEvent.click(popupButton);
|
||||
|
||||
expect(mockOptionsSwitch.mock.calls.some((call) => call[0].currentOption === "popup")).toBe(true);
|
||||
// PopupTab content checks
|
||||
expect(screen.getByText("environments.surveys.summary.embed_pop_up_survey_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.setup_instructions")).toBeInTheDocument();
|
||||
expect(screen.getByRole("list")).toBeInTheDocument(); // Check for the ol element
|
||||
|
||||
const listItems = screen.getAllByRole("listitem");
|
||||
expect(listItems[0]).toHaveTextContent(
|
||||
"common.follow_these environments.surveys.summary.setup_instructions environments.surveys.summary.to_connect_your_website_with_formbricks"
|
||||
);
|
||||
expect(listItems[1]).toHaveTextContent(
|
||||
"environments.surveys.summary.make_sure_the_survey_type_is_set_to common.website_survey"
|
||||
);
|
||||
expect(listItems[2]).toHaveTextContent(
|
||||
"environments.surveys.summary.define_when_and_where_the_survey_should_pop_up"
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: "environments.surveys.summary.setup_instructions" })
|
||||
).toHaveAttribute("href", `/environments/${environmentId}/project/website-connection`);
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.unsupported_video_tag_warning").closest("video")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("StaticTab", () => {
|
||||
const formattedBaseCode = `<div style="position: relative; height:80dvh; overflow:auto;"> \n <iframe \n src="${surveyUrl}" \n frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">\n </iframe>\n</div>`;
|
||||
const normalizedBaseCode = `<div style="position: relative; height:80dvh; overflow:auto;"> <iframe src="${surveyUrl}" frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;"> </iframe> </div>`;
|
||||
|
||||
const formattedEmbedCode = `<div style="position: relative; height:80dvh; overflow:auto;"> \n <iframe \n src="${surveyUrl}?embed=true" \n frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">\n </iframe>\n</div>`;
|
||||
const normalizedEmbedCode = `<div style="position: relative; height:80dvh; overflow:auto;"> <iframe src="${surveyUrl}?embed=true" frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;"> </iframe> </div>`;
|
||||
|
||||
test("renders correctly with initial iframe code and embed mode toggle", () => {
|
||||
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />); // Defaults to StaticTab
|
||||
|
||||
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedBaseCode);
|
||||
expect(mockCodeBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ children: formattedBaseCode, language: "html" })
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument();
|
||||
expect(mockAdvancedOptionToggle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isChecked: false,
|
||||
title: "environments.surveys.summary.embed_mode",
|
||||
description: "environments.surveys.summary.embed_mode_description",
|
||||
})
|
||||
);
|
||||
expect(screen.getByText("environments.surveys.summary.embed_mode")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("copies iframe code to clipboard when 'Copy Code' is clicked", async () => {
|
||||
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
|
||||
const copyButton = screen.getByRole("button", { name: "Embed survey in your website" });
|
||||
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(formattedBaseCode);
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
"environments.surveys.summary.embed_code_copied_to_clipboard"
|
||||
);
|
||||
expect(screen.getByText("common.copy_code")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("updates iframe code when 'Embed Mode' is toggled", async () => {
|
||||
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
|
||||
const embedToggle = screen
|
||||
.getByTestId("advanced-option-toggle")
|
||||
.querySelector('input[type="checkbox"]');
|
||||
expect(embedToggle).not.toBeNull();
|
||||
|
||||
await userEvent.click(embedToggle!);
|
||||
|
||||
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedEmbedCode);
|
||||
expect(mockCodeBlock.mock.calls.find((call) => call[0].children === formattedEmbedCode)).toBeTruthy();
|
||||
expect(mockAdvancedOptionToggle.mock.calls.some((call) => call[0].isChecked === true)).toBe(true);
|
||||
|
||||
// Toggle back
|
||||
await userEvent.click(embedToggle!);
|
||||
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedBaseCode);
|
||||
expect(mockCodeBlock.mock.calls.find((call) => call[0].children === formattedBaseCode)).toBeTruthy();
|
||||
expect(mockAdvancedOptionToggle.mock.calls.some((call) => call[0].isChecked === false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PopupTab", () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure PopupTab is active
|
||||
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
|
||||
const popupButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.summary.dynamic_popup",
|
||||
});
|
||||
await userEvent.click(popupButton);
|
||||
});
|
||||
|
||||
test("renders title and instructions", () => {
|
||||
expect(screen.getByText("environments.surveys.summary.embed_pop_up_survey_title")).toBeInTheDocument();
|
||||
|
||||
const listItems = screen.getAllByRole("listitem");
|
||||
expect(listItems).toHaveLength(3);
|
||||
expect(listItems[0]).toHaveTextContent(
|
||||
"common.follow_these environments.surveys.summary.setup_instructions environments.surveys.summary.to_connect_your_website_with_formbricks"
|
||||
);
|
||||
expect(listItems[1]).toHaveTextContent(
|
||||
"environments.surveys.summary.make_sure_the_survey_type_is_set_to common.website_survey"
|
||||
);
|
||||
expect(listItems[2]).toHaveTextContent(
|
||||
"environments.surveys.summary.define_when_and_where_the_survey_should_pop_up"
|
||||
);
|
||||
|
||||
// Specific checks for elements or distinct text content
|
||||
expect(screen.getByText("environments.surveys.summary.setup_instructions")).toBeInTheDocument(); // Checks the link text
|
||||
expect(screen.getByText("common.website_survey")).toBeInTheDocument(); // Checks the bold text
|
||||
// The text for the last list item is its sole content, so getByText works here.
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.define_when_and_where_the_survey_should_pop_up")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the setup instructions link with correct href", () => {
|
||||
const link = screen.getByRole("link", { name: "environments.surveys.summary.setup_instructions" });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", `/environments/${environmentId}/project/website-connection`);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
test("renders the video", () => {
|
||||
const videoElement = screen
|
||||
.getByText("environments.surveys.summary.unsupported_video_tag_warning")
|
||||
.closest("video");
|
||||
expect(videoElement).toBeInTheDocument();
|
||||
expect(videoElement).toHaveAttribute("autoPlay");
|
||||
expect(videoElement).toHaveAttribute("loop");
|
||||
const sourceElement = videoElement?.querySelector("source");
|
||||
expect(sourceElement).toHaveAttribute("src", "/video/tooltips/change-survey-type.mp4");
|
||||
expect(sourceElement).toHaveAttribute("type", "video/mp4");
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.unsupported_video_tag_warning")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export const WebsiteTab = ({ surveyUrl, environmentId }) => {
|
||||
const [selectedTab, setSelectedTab] = useState("static");
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<OptionsSwitch
|
||||
options={[
|
||||
{ value: "static", label: t("environments.surveys.summary.static_iframe") },
|
||||
{ value: "popup", label: t("environments.surveys.summary.dynamic_popup") },
|
||||
]}
|
||||
currentOption={selectedTab}
|
||||
handleOptionChange={(value) => setSelectedTab(value)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{selectedTab === "static" ? (
|
||||
<StaticTab surveyUrl={surveyUrl} />
|
||||
) : (
|
||||
<PopupTab environmentId={environmentId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StaticTab = ({ surveyUrl }) => {
|
||||
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
|
||||
<iframe
|
||||
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
|
||||
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
|
||||
</iframe>
|
||||
</div>`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<div className="flex justify-between">
|
||||
<div></div>
|
||||
<Button
|
||||
title="Embed survey in your website"
|
||||
aria-label="Embed survey in your website"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(iframeCode);
|
||||
toast.success(t("environments.surveys.summary.embed_code_copied_to_clipboard"));
|
||||
}}>
|
||||
{t("common.copy_code")}
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="prose prose-slate max-w-full">
|
||||
<CodeBlock
|
||||
customCodeClass="text-sm h-48 overflow-y-scroll text-sm"
|
||||
language="html"
|
||||
showCopyToClipboard={false}>
|
||||
{iframeCode}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div className="mt-2 rounded-md border bg-white p-4">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="enableEmbedMode"
|
||||
isChecked={embedModeEnabled}
|
||||
onToggle={setEmbedModeEnabled}
|
||||
title={t("environments.surveys.summary.embed_mode")}
|
||||
description={t("environments.surveys.summary.embed_mode_description")}
|
||||
childBorder={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PopupTab = ({ environmentId }) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.embed_pop_up_survey_title")}
|
||||
</p>
|
||||
<ol className="mt-4 list-decimal space-y-2 pl-5 text-sm text-slate-700">
|
||||
<li>
|
||||
{t("common.follow_these")}{" "}
|
||||
<Link
|
||||
href={`/environments/${environmentId}/project/website-connection`}
|
||||
target="_blank"
|
||||
className="decoration-brand-dark font-medium underline underline-offset-2">
|
||||
{t("environments.surveys.summary.setup_instructions")}
|
||||
</Link>{" "}
|
||||
{t("environments.surveys.summary.to_connect_your_website_with_formbricks")}
|
||||
</li>
|
||||
<li>
|
||||
{t("environments.surveys.summary.make_sure_the_survey_type_is_set_to")}{" "}
|
||||
<b>{t("common.website_survey")}</b>
|
||||
</li>
|
||||
<li>{t("environments.surveys.summary.define_when_and_where_the_survey_should_pop_up")}</li>
|
||||
</ol>
|
||||
<div className="mt-4">
|
||||
<video autoPlay loop muted className="w-full rounded-xl border border-slate-200">
|
||||
<source src="/video/tooltips/change-survey-type.mp4" type="video/mp4" />
|
||||
{t("environments.surveys.summary.unsupported_video_tag_warning")}
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,381 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AnonymousLinksTab } from "./anonymous-links-tab";
|
||||
|
||||
// Mock actions
|
||||
vi.mock("../../actions", () => ({
|
||||
updateSingleUseLinksAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
generateSingleUseIdsAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
|
||||
ShareSurveyLink: ({ surveyUrl, publicDomain }: any) => (
|
||||
<div data-testid="share-survey-link">
|
||||
<p>Survey URL: {surveyUrl}</p>
|
||||
<p>Public Domain: {publicDomain}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
|
||||
AdvancedOptionToggle: ({ children, htmlId, isChecked, onToggle, title }: any) => (
|
||||
<div data-testid={`toggle-${htmlId}`} data-checked={isChecked}>
|
||||
<button data-testid={`toggle-button-${htmlId}`} onClick={() => onToggle(!isChecked)}>
|
||||
{title}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children, variant, size }: any) => (
|
||||
<div data-testid={`alert-${variant}`} data-size={size}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
|
||||
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, disabled, variant }: any) => (
|
||||
<button onClick={onClick} disabled={disabled} data-variant={variant}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
Input: ({ value, onChange, type, max, min, className }: any) => (
|
||||
<input
|
||||
type={type}
|
||||
max={max}
|
||||
min={min}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data-testid="number-input"
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container",
|
||||
() => ({
|
||||
TabContainer: ({ children, title }: any) => (
|
||||
<div data-testid="tab-container">
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal",
|
||||
() => ({
|
||||
DisableLinkModal: ({ open, type, onDisable }: any) => (
|
||||
<div data-testid="disable-link-modal" data-open={open} data-type={type}>
|
||||
<button onClick={() => onDisable()}>Confirm</button>
|
||||
<button>Close</button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links",
|
||||
() => ({
|
||||
DocumentationLinks: ({ links }: any) => (
|
||||
<div data-testid="documentation-links">
|
||||
{links.map((link: any, index: number) => (
|
||||
<a key={index} href={link.href}>
|
||||
{link.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock translations
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Next.js router
|
||||
const mockRefresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: mockRefresh,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock URL and Blob for download functionality
|
||||
global.URL.createObjectURL = vi.fn(() => "mock-url");
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
global.Blob = vi.fn(() => ({}) as any);
|
||||
|
||||
describe("AnonymousLinksTab", () => {
|
||||
const mockSurvey = {
|
||||
id: "test-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
type: "link" as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
createdBy: null,
|
||||
status: "draft" as const,
|
||||
questions: [],
|
||||
thankYouCard: { enabled: false },
|
||||
welcomeCard: { enabled: false },
|
||||
hiddenFields: { enabled: false },
|
||||
singleUse: {
|
||||
enabled: false,
|
||||
isEncrypted: false,
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const surveyWithSingleUse = {
|
||||
...mockSurvey,
|
||||
singleUse: {
|
||||
enabled: true,
|
||||
isEncrypted: false,
|
||||
},
|
||||
} as TSurvey;
|
||||
|
||||
const surveyWithEncryption = {
|
||||
...mockSurvey,
|
||||
singleUse: {
|
||||
enabled: true,
|
||||
isEncrypted: true,
|
||||
},
|
||||
} as TSurvey;
|
||||
|
||||
const defaultProps = {
|
||||
survey: mockSurvey,
|
||||
surveyUrl: "https://example.com/survey",
|
||||
publicDomain: "https://example.com",
|
||||
setSurveyUrl: vi.fn(),
|
||||
locale: "en-US" as TUserLocale,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { updateSingleUseLinksAction } = await import("../../actions");
|
||||
const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions");
|
||||
|
||||
vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: mockSurvey });
|
||||
vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: ["link1", "link2"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders with single-use link enabled when survey has singleUse enabled", () => {
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
expect(screen.getByTestId("toggle-multi-use-link-switch")).toHaveAttribute("data-checked", "false");
|
||||
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
|
||||
});
|
||||
|
||||
test("handles multi-use toggle when single-use is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { updateSingleUseLinksAction } = await import("../../actions");
|
||||
|
||||
render(<AnonymousLinksTab {...defaultProps} />);
|
||||
|
||||
// When multi-use is enabled and we click it, it should show a modal to turn it off
|
||||
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
|
||||
await user.click(multiUseToggle);
|
||||
|
||||
// Should show confirmation modal
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use");
|
||||
|
||||
// Confirm the modal action
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
isSingleUse: true,
|
||||
isSingleUseEncryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows confirmation modal when toggling from single-use to multi-use", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
|
||||
await user.click(multiUseToggle);
|
||||
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "single-use");
|
||||
});
|
||||
|
||||
test("shows confirmation modal when toggling from multi-use to single-use", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AnonymousLinksTab {...defaultProps} />);
|
||||
|
||||
const singleUseToggle = screen.getByTestId("toggle-button-single-use-link-switch");
|
||||
await user.click(singleUseToggle);
|
||||
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use");
|
||||
});
|
||||
|
||||
test("handles single-use encryption toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { updateSingleUseLinksAction } = await import("../../actions");
|
||||
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
const encryptionToggle = screen.getByTestId("toggle-button-single-use-encryption-switch");
|
||||
await user.click(encryptionToggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
isSingleUse: true,
|
||||
isSingleUseEncryption: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("shows encryption info alert when encryption is disabled", () => {
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
const alerts = screen.getAllByTestId("alert-info");
|
||||
const encryptionAlert = alerts.find(
|
||||
(alert) =>
|
||||
alert.querySelector('[data-testid="alert-title"]')?.textContent ===
|
||||
"environments.surveys.share.anonymous_links.custom_single_use_id_title"
|
||||
);
|
||||
|
||||
expect(encryptionAlert).toBeInTheDocument();
|
||||
expect(encryptionAlert?.querySelector('[data-testid="alert-title"]')).toHaveTextContent(
|
||||
"environments.surveys.share.anonymous_links.custom_single_use_id_title"
|
||||
);
|
||||
});
|
||||
|
||||
test("shows link generation section when encryption is enabled", () => {
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
|
||||
|
||||
expect(screen.getByTestId("number-input")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.generate_and_download_links")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles number of links input change", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
|
||||
|
||||
const input = screen.getByTestId("number-input");
|
||||
await user.clear(input);
|
||||
await user.type(input, "5");
|
||||
|
||||
expect(input).toHaveValue(5);
|
||||
});
|
||||
|
||||
test("handles link generation error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions");
|
||||
vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: undefined });
|
||||
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
|
||||
|
||||
const generateButton = screen.getByText(
|
||||
"environments.surveys.share.anonymous_links.generate_and_download_links"
|
||||
);
|
||||
await user.click(generateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"environments.surveys.share.anonymous_links.generate_links_error"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("handles action error with generic message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { updateSingleUseLinksAction } = await import("../../actions");
|
||||
vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: undefined });
|
||||
|
||||
render(<AnonymousLinksTab {...defaultProps} />);
|
||||
|
||||
// Click multi-use toggle to show modal
|
||||
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
|
||||
await user.click(multiUseToggle);
|
||||
|
||||
// Confirm the modal action
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
|
||||
});
|
||||
});
|
||||
|
||||
test("confirms modal action when disable link modal is confirmed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { updateSingleUseLinksAction } = await import("../../actions");
|
||||
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
|
||||
await user.click(multiUseToggle);
|
||||
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
isSingleUse: false,
|
||||
isSingleUseEncryption: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("renders documentation links", () => {
|
||||
render(<AnonymousLinksTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("documentation-links")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.single_use_links")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.data_prefilling")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { updateSingleUseLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
|
||||
import { DisableLinkModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal";
|
||||
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface AnonymousLinksTabProps {
|
||||
survey: TSurvey;
|
||||
surveyUrl: string;
|
||||
publicDomain: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const AnonymousLinksTab = ({
|
||||
survey,
|
||||
surveyUrl,
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
}: AnonymousLinksTabProps) => {
|
||||
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
|
||||
const [isMultiUseLink, setIsMultiUseLink] = useState(!survey.singleUse?.enabled);
|
||||
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
|
||||
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
|
||||
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
|
||||
|
||||
const [disableLinkModal, setDisableLinkModal] = useState<{
|
||||
open: boolean;
|
||||
type: "multi-use" | "single-use";
|
||||
pendingAction: () => Promise<void> | void;
|
||||
} | null>(null);
|
||||
|
||||
const resetState = () => {
|
||||
const { singleUse } = survey;
|
||||
const { enabled, isEncrypted } = singleUse ?? {};
|
||||
|
||||
setIsMultiUseLink(!enabled);
|
||||
setIsSingleUseLink(enabled ?? false);
|
||||
setSingleUseEncryption(isEncrypted ?? false);
|
||||
};
|
||||
|
||||
const updateSingleUseSettings = async (
|
||||
isSingleUse: boolean,
|
||||
isSingleUseEncryption: boolean
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const updatedSurveyResponse = await updateSingleUseLinksAction({
|
||||
surveyId: survey.id,
|
||||
environmentId: survey.environmentId,
|
||||
isSingleUse,
|
||||
isSingleUseEncryption,
|
||||
});
|
||||
|
||||
if (updatedSurveyResponse?.data) {
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
resetState();
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
resetState();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMultiUseToggle = async (newValue: boolean) => {
|
||||
if (newValue) {
|
||||
// Turning multi-use on - show confirmation modal if single-use is currently enabled
|
||||
if (isSingleUseLink) {
|
||||
setDisableLinkModal({
|
||||
open: true,
|
||||
type: "single-use",
|
||||
pendingAction: async () => {
|
||||
setIsMultiUseLink(true);
|
||||
setIsSingleUseLink(false);
|
||||
setSingleUseEncryption(false);
|
||||
await updateSingleUseSettings(false, false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Single-use is already off, just enable multi-use
|
||||
setIsMultiUseLink(true);
|
||||
setIsSingleUseLink(false);
|
||||
setSingleUseEncryption(false);
|
||||
await updateSingleUseSettings(false, false);
|
||||
}
|
||||
} else {
|
||||
// Turning multi-use off - need confirmation and turn single-use on
|
||||
setDisableLinkModal({
|
||||
open: true,
|
||||
type: "multi-use",
|
||||
pendingAction: async () => {
|
||||
setIsMultiUseLink(false);
|
||||
setIsSingleUseLink(true);
|
||||
setSingleUseEncryption(true);
|
||||
await updateSingleUseSettings(true, true);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleUseToggle = async (newValue: boolean) => {
|
||||
if (newValue) {
|
||||
// Turning single-use on - turn multi-use off
|
||||
setDisableLinkModal({
|
||||
open: true,
|
||||
type: "multi-use",
|
||||
pendingAction: async () => {
|
||||
setIsMultiUseLink(false);
|
||||
setIsSingleUseLink(true);
|
||||
setSingleUseEncryption(true);
|
||||
await updateSingleUseSettings(true, true);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Turning single-use off - show confirmation modal and then turn multi-use on
|
||||
setDisableLinkModal({
|
||||
open: true,
|
||||
type: "single-use",
|
||||
pendingAction: async () => {
|
||||
setIsMultiUseLink(true);
|
||||
setIsSingleUseLink(false);
|
||||
setSingleUseEncryption(false);
|
||||
await updateSingleUseSettings(false, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleUseEncryptionToggle = async (newValue: boolean) => {
|
||||
setSingleUseEncryption(newValue);
|
||||
await updateSingleUseSettings(true, newValue);
|
||||
};
|
||||
|
||||
const handleNumberOfLinksChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
if (inputValue === "") {
|
||||
setNumberOfLinks("");
|
||||
return;
|
||||
}
|
||||
|
||||
const value = Number(inputValue);
|
||||
|
||||
if (!isNaN(value)) {
|
||||
setNumberOfLinks(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateLinks = async (count: number) => {
|
||||
try {
|
||||
const response = await generateSingleUseIdsAction({
|
||||
surveyId: survey.id,
|
||||
isEncrypted: singleUseEncryption,
|
||||
count,
|
||||
});
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
const singleUseIds = response.data;
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
|
||||
|
||||
// Create content with just the links
|
||||
const csvContent = surveyLinks.join("\n");
|
||||
|
||||
// Create and download the file
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `single-use-links-${survey.id}.csv`);
|
||||
link.style.visibility = "hidden";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col justify-between space-y-4">
|
||||
<div className="flex w-full grow flex-col gap-6">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="multi-use-link-switch"
|
||||
isChecked={isMultiUseLink}
|
||||
onToggle={handleMultiUseToggle}
|
||||
title={t("environments.surveys.share.anonymous_links.multi_use_link")}
|
||||
description={t("environments.surveys.share.anonymous_links.multi_use_link_description")}
|
||||
customContainerClass="pl-1 pr-0 py-0"
|
||||
childBorder>
|
||||
<div className="flex w-full flex-col gap-4 overflow-hidden bg-white p-4">
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
<Alert variant="info" size="default">
|
||||
<AlertTitle>
|
||||
{t("environments.surveys.share.anonymous_links.multi_use_powers_other_channels_title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"environments.surveys.share.anonymous_links.multi_use_powers_other_channels_description"
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
htmlId="single-use-link-switch"
|
||||
isChecked={isSingleUseLink}
|
||||
onToggle={handleSingleUseToggle}
|
||||
title={t("environments.surveys.share.anonymous_links.single_use_link")}
|
||||
description={t("environments.surveys.share.anonymous_links.single_use_link_description")}
|
||||
customContainerClass="pl-1 pr-0 py-0"
|
||||
childBorder>
|
||||
<div className="flex w-full flex-col gap-4 bg-white p-4">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="single-use-encryption-switch"
|
||||
isChecked={singleUseEncryption}
|
||||
onToggle={handleSingleUseEncryptionToggle}
|
||||
title={t("environments.surveys.share.anonymous_links.url_encryption_label")}
|
||||
description={t("environments.surveys.share.anonymous_links.url_encryption_description")}
|
||||
customContainerClass="pl-1 pr-0 py-0"
|
||||
/>
|
||||
|
||||
{!singleUseEncryption ? (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<Alert variant="info" size="default">
|
||||
<AlertTitle>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid w-full grid-cols-6 items-center gap-2">
|
||||
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
|
||||
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
|
||||
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
}}
|
||||
className="col-span-1 gap-1 text-sm">
|
||||
{t("common.copy")}
|
||||
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{singleUseEncryption && (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<h3 className="text-sm font-medium text-slate-900">
|
||||
{t("environments.surveys.share.anonymous_links.number_of_links_label")}
|
||||
</h3>
|
||||
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="w-32">
|
||||
<Input
|
||||
type="number"
|
||||
max={5000}
|
||||
min={1}
|
||||
className="bg-white focus:border focus:border-slate-900"
|
||||
value={numberOfLinks}
|
||||
onChange={handleNumberOfLinksChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleGenerateLinks(Number(numberOfLinks) || 1)}
|
||||
disabled={Number(numberOfLinks) < 1 || Number(numberOfLinks) > 5000}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CirclePlayIcon className="h-3.5 w-3.5 shrink-0 text-slate-50" />
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-slate-50">
|
||||
{t("environments.surveys.share.anonymous_links.generate_and_download_links")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
</div>
|
||||
|
||||
<DocumentationLinks
|
||||
links={[
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.single_use_links"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/single-use-links",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.data_prefilling"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.source_tracking"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/source-tracking",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{disableLinkModal && (
|
||||
<DisableLinkModal
|
||||
open={disableLinkModal.open}
|
||||
onOpenChange={() => setDisableLinkModal(null)}
|
||||
type={disableLinkModal.type}
|
||||
onDisable={() => {
|
||||
disableLinkModal.pendingAction();
|
||||
setDisableLinkModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,383 @@
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { SurveyContextWrapper } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass, TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TBaseFilter, TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
|
||||
import { AppTab } from "./app-tab";
|
||||
|
||||
// Mock Next.js Link component
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, ...props }: any) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock DocumentationLinksSection
|
||||
vi.mock("./documentation-links-section", () => ({
|
||||
DocumentationLinksSection: ({ title, links }: { title: string; links: any[] }) => (
|
||||
<div data-testid="documentation-links">
|
||||
<h4>{title}</h4>
|
||||
{links.map((link) => (
|
||||
<div key={link.href} data-testid="documentation-link">
|
||||
<a href={link.href}>{link.title}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock segment
|
||||
const mockSegment: TSegment = {
|
||||
id: "test-segment-id",
|
||||
title: "Test Segment",
|
||||
description: "Test segment description",
|
||||
environmentId: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isPrivate: false,
|
||||
filters: [
|
||||
{
|
||||
id: "test-filter-id",
|
||||
connector: "and",
|
||||
resource: "contact",
|
||||
attributeKey: "test-attribute-key",
|
||||
attributeType: "string",
|
||||
condition: "equals",
|
||||
value: "test",
|
||||
} as unknown as TBaseFilter,
|
||||
],
|
||||
surveys: ["test-survey-id"],
|
||||
};
|
||||
|
||||
// Mock action class
|
||||
const mockActionClass: TActionClass = {
|
||||
id: "test-action-id",
|
||||
name: "Test Action",
|
||||
type: "code",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "test-env-id",
|
||||
description: "Test action description",
|
||||
noCodeConfig: null,
|
||||
key: "test-action-key",
|
||||
};
|
||||
|
||||
const mockNoCodeActionClass: TActionClass = {
|
||||
id: "test-no-code-action-id",
|
||||
name: "Test No Code Action",
|
||||
type: "noCode",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "test-env-id",
|
||||
description: "Test no code action description",
|
||||
noCodeConfig: {
|
||||
type: "click",
|
||||
elementSelector: {
|
||||
cssSelector: ".test-button",
|
||||
innerHtml: "Click me",
|
||||
},
|
||||
} as TActionClassNoCodeConfig,
|
||||
key: "test-no-code-action-key",
|
||||
};
|
||||
|
||||
// Mock environment data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "test-project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
// Mock project data
|
||||
const mockProject = {
|
||||
id: "test-project-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "test-org-id",
|
||||
recontactDays: 7,
|
||||
config: {
|
||||
channel: "app",
|
||||
industry: "saas",
|
||||
},
|
||||
linkSurveyBranding: true,
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: { light: "#ffffff", dark: "#000000" },
|
||||
questionColor: { light: "#000000", dark: "#ffffff" },
|
||||
inputColor: { light: "#000000", dark: "#ffffff" },
|
||||
inputBorderColor: { light: "#cccccc", dark: "#444444" },
|
||||
cardBackgroundColor: { light: "#ffffff", dark: "#000000" },
|
||||
cardBorderColor: { light: "#cccccc", dark: "#444444" },
|
||||
highlightBorderColor: { light: "#007bff", dark: "#0056b3" },
|
||||
isDarkModeEnabled: false,
|
||||
isLogoHidden: false,
|
||||
hideProgressBar: false,
|
||||
roundness: 8,
|
||||
cardArrangement: { linkSurveys: "casual", appSurveys: "casual" },
|
||||
},
|
||||
inAppSurveyBranding: true,
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
logo: { url: "test-logo.png", bgColor: "#ffffff" },
|
||||
} as TProject;
|
||||
|
||||
// Mock survey data
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "test-survey-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
type: "app",
|
||||
environmentId: "test-env-id",
|
||||
status: "inProgress",
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [{ actionClass: mockActionClass }],
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurveyWelcomeCard,
|
||||
questions: [],
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false },
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
singleUse: { enabled: false, isEncrypted: false },
|
||||
projectOverwrites: null,
|
||||
surveyClosedMessage: null,
|
||||
delay: 0,
|
||||
isVerifyEmailEnabled: false,
|
||||
inlineTriggers: {},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
describe("AppTab", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const renderWithProviders = (appSetupCompleted = true, surveyOverrides = {}, projectOverrides = {}) => {
|
||||
const environmentWithSetup = {
|
||||
...mockEnvironment,
|
||||
appSetupCompleted,
|
||||
};
|
||||
|
||||
const surveyWithOverrides = {
|
||||
...mockSurvey,
|
||||
...surveyOverrides,
|
||||
};
|
||||
|
||||
const projectWithOverrides = {
|
||||
...mockProject,
|
||||
...projectOverrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<EnvironmentContextWrapper environment={environmentWithSetup} project={projectWithOverrides}>
|
||||
<SurveyContextWrapper survey={surveyWithOverrides}>
|
||||
<AppTab />
|
||||
</SurveyContextWrapper>
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
test("renders setup completed content when app setup is completed", () => {
|
||||
renderWithProviders(true);
|
||||
expect(screen.getByText("environments.surveys.summary.in_app.connection_title")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.connection_description")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders setup required content when app setup is not completed", () => {
|
||||
renderWithProviders(false);
|
||||
expect(screen.getByText("environments.surveys.summary.in_app.no_connection_title")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.no_connection_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("common.connect_formbricks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays correct wait time when survey has recontact days", () => {
|
||||
renderWithProviders(true, { recontactDays: 5 });
|
||||
expect(
|
||||
screen.getByText("5 environments.surveys.summary.in_app.display_criteria.time_based_days")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays correct wait time when survey has 1 recontact day", () => {
|
||||
renderWithProviders(true, { recontactDays: 1 });
|
||||
expect(
|
||||
screen.getByText("1 environments.surveys.summary.in_app.display_criteria.time_based_day")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays correct wait time when survey has 0 recontact days", () => {
|
||||
renderWithProviders(true, { recontactDays: 0 });
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays project recontact days when survey has no recontact days", () => {
|
||||
renderWithProviders(true, { recontactDays: null }, { recontactDays: 3 });
|
||||
expect(
|
||||
screen.getByText("3 environments.surveys.summary.in_app.display_criteria.time_based_days")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays always when project has 0 recontact days", () => {
|
||||
renderWithProviders(true, { recontactDays: null }, { recontactDays: 0 });
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays always when both survey and project have null recontact days", () => {
|
||||
renderWithProviders(true, { recontactDays: null }, { recontactDays: null });
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays correct display option for displayOnce", () => {
|
||||
renderWithProviders(true, { displayOption: "displayOnce" });
|
||||
expect(screen.getByText("environments.surveys.edit.show_only_once")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays correct display option for displayMultiple", () => {
|
||||
renderWithProviders(true, { displayOption: "displayMultiple" });
|
||||
expect(screen.getByText("environments.surveys.edit.until_they_submit_a_response")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays correct display option for respondMultiple", () => {
|
||||
renderWithProviders(true, { displayOption: "respondMultiple" });
|
||||
expect(
|
||||
screen.getByText("environments.surveys.edit.keep_showing_while_conditions_match")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays correct display option for displaySome", () => {
|
||||
renderWithProviders(true, { displayOption: "displaySome" });
|
||||
expect(screen.getByText("environments.surveys.edit.show_multiple_times")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays everyone when survey has no segment", () => {
|
||||
renderWithProviders(true, { segment: null });
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.display_criteria.everyone")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays targeted when survey has segment with filters", () => {
|
||||
renderWithProviders(true, {
|
||||
segment: mockSegment,
|
||||
});
|
||||
expect(screen.getByText("Test Segment")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays segment title when survey has public segment with filters", () => {
|
||||
const publicSegment = { ...mockSegment, isPrivate: false, title: "Public Segment" };
|
||||
renderWithProviders(true, {
|
||||
segment: publicSegment,
|
||||
});
|
||||
expect(screen.getByText("Public Segment")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays targeted when survey has private segment with filters", () => {
|
||||
const privateSegment = { ...mockSegment, isPrivate: true };
|
||||
renderWithProviders(true, {
|
||||
segment: privateSegment,
|
||||
});
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.display_criteria.targeted")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays everyone when survey has segment with no filters", () => {
|
||||
const emptySegment = { ...mockSegment, filters: [] };
|
||||
renderWithProviders(true, {
|
||||
segment: emptySegment,
|
||||
});
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.display_criteria.everyone")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays code trigger description correctly", () => {
|
||||
renderWithProviders(true, { triggers: [{ actionClass: mockActionClass }] });
|
||||
expect(screen.getByText("Test Action")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("(environments.surveys.summary.in_app.display_criteria.code_trigger)")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays no-code trigger description correctly", () => {
|
||||
renderWithProviders(true, { triggers: [{ actionClass: mockNoCodeActionClass }] });
|
||||
expect(screen.getByText("Test No Code Action")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"(environments.surveys.summary.in_app.display_criteria.no_code_trigger, environments.actions.click)"
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays randomizer when displayPercentage is set", () => {
|
||||
renderWithProviders(true, { displayPercentage: 25 });
|
||||
expect(
|
||||
screen.getAllByText(/environments\.surveys\.summary\.in_app\.display_criteria\.randomizer/)[0]
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not display randomizer when displayPercentage is null", () => {
|
||||
renderWithProviders(true, { displayPercentage: null });
|
||||
expect(screen.queryByText("Show to")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not display randomizer when displayPercentage is 0", () => {
|
||||
renderWithProviders(true, { displayPercentage: 0 });
|
||||
expect(screen.queryByText("Show to")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders documentation links section", () => {
|
||||
renderWithProviders(true);
|
||||
expect(screen.getByTestId("documentation-links")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.in_app.documentation_title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders all display criteria items", () => {
|
||||
renderWithProviders(true);
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_description")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.display_criteria.audience_description")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.display_criteria.trigger_description")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.in_app.display_criteria.recontact_description")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { H4, InlineSmall, Small } from "@/modules/ui/components/typography";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
CodeXmlIcon,
|
||||
MousePointerClickIcon,
|
||||
PercentIcon,
|
||||
Repeat1Icon,
|
||||
TimerResetIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { DocumentationLinksSection } from "./documentation-links-section";
|
||||
|
||||
const createDocumentationLinks = (t: ReturnType<typeof useTranslate>["t"]) => [
|
||||
{
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#html",
|
||||
title: t("environments.surveys.summary.in_app.html_embed"),
|
||||
},
|
||||
{
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-js",
|
||||
title: t("environments.surveys.summary.in_app.javascript_sdk"),
|
||||
},
|
||||
{
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#swift",
|
||||
title: t("environments.surveys.summary.in_app.ios_sdk"),
|
||||
},
|
||||
{
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#android",
|
||||
title: t("environments.surveys.summary.in_app.kotlin_sdk"),
|
||||
},
|
||||
{
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-native",
|
||||
title: t("environments.surveys.summary.in_app.react_native_sdk"),
|
||||
},
|
||||
];
|
||||
|
||||
const createNoCodeConfigType = (t: ReturnType<typeof useTranslate>["t"]) => ({
|
||||
click: t("environments.actions.click"),
|
||||
pageView: t("environments.actions.page_view"),
|
||||
exitIntent: t("environments.actions.exit_intent"),
|
||||
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
|
||||
});
|
||||
|
||||
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslate>["t"]) => {
|
||||
if (days === 0) {
|
||||
return t("environments.surveys.summary.in_app.display_criteria.time_based_always");
|
||||
} else if (days === 1) {
|
||||
return `${days} ${t("environments.surveys.summary.in_app.display_criteria.time_based_day")}`;
|
||||
} else {
|
||||
return `${days} ${t("environments.surveys.summary.in_app.display_criteria.time_based_days")}`;
|
||||
}
|
||||
};
|
||||
|
||||
interface DisplayCriteriaItemProps {
|
||||
icon: ReactNode;
|
||||
title: ReactNode;
|
||||
titleSuffix?: ReactNode;
|
||||
description: ReactNode;
|
||||
}
|
||||
|
||||
const DisplayCriteriaItem = ({ icon, title, titleSuffix, description }: DisplayCriteriaItemProps) => {
|
||||
return (
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<div className="flex items-center justify-center">{icon}</div>
|
||||
<div className="flex items-center">
|
||||
<Small>
|
||||
{title} {titleSuffix && <InlineSmall>{titleSuffix}</InlineSmall>}
|
||||
</Small>
|
||||
</div>
|
||||
<div />
|
||||
<div className="flex items-start">
|
||||
<Small color="muted" margin="headerDescription">
|
||||
{description}
|
||||
</Small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppTab = () => {
|
||||
const { t } = useTranslate();
|
||||
const { environment, project } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
|
||||
const documentationLinks = useMemo(() => createDocumentationLinks(t), [t]);
|
||||
const noCodeConfigType = useMemo(() => createNoCodeConfigType(t), [t]);
|
||||
|
||||
const waitTime = () => {
|
||||
if (survey.recontactDays !== null) {
|
||||
return formatRecontactDaysString(survey.recontactDays, t);
|
||||
}
|
||||
if (project.recontactDays !== null) {
|
||||
return formatRecontactDaysString(project.recontactDays, t);
|
||||
}
|
||||
return t("environments.surveys.summary.in_app.display_criteria.time_based_always");
|
||||
};
|
||||
|
||||
const displayOption = () => {
|
||||
if (survey.displayOption === "displayOnce") {
|
||||
return t("environments.surveys.edit.show_only_once");
|
||||
} else if (survey.displayOption === "displayMultiple") {
|
||||
return t("environments.surveys.edit.until_they_submit_a_response");
|
||||
} else if (survey.displayOption === "respondMultiple") {
|
||||
return t("environments.surveys.edit.keep_showing_while_conditions_match");
|
||||
} else if (survey.displayOption === "displaySome") {
|
||||
return t("environments.surveys.edit.show_multiple_times");
|
||||
}
|
||||
|
||||
// Default fallback for undefined or unexpected displayOption values
|
||||
return t("environments.surveys.edit.show_only_once");
|
||||
};
|
||||
|
||||
const getTriggerDescription = (
|
||||
actionClass: TActionClass,
|
||||
noCodeConfigTypeParam: ReturnType<typeof createNoCodeConfigType>
|
||||
) => {
|
||||
if (actionClass.type === "code") {
|
||||
return `(${t("environments.surveys.summary.in_app.display_criteria.code_trigger")})`;
|
||||
} else {
|
||||
const configType = actionClass.noCodeConfig?.type;
|
||||
let configTypeLabel = "unknown";
|
||||
|
||||
if (configType && configType in noCodeConfigTypeParam) {
|
||||
configTypeLabel = noCodeConfigTypeParam[configType];
|
||||
} else if (configType) {
|
||||
configTypeLabel = configType;
|
||||
}
|
||||
|
||||
return `(${t("environments.surveys.summary.in_app.display_criteria.no_code_trigger")}, ${configTypeLabel})`;
|
||||
}
|
||||
};
|
||||
|
||||
const getSegmentTitle = (segment: TSegment | null) => {
|
||||
if (segment?.filters?.length && segment.filters.length > 0) {
|
||||
return segment.isPrivate
|
||||
? t("environments.surveys.summary.in_app.display_criteria.targeted")
|
||||
: segment.title;
|
||||
}
|
||||
return t("environments.surveys.summary.in_app.display_criteria.everyone");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between space-y-6 pb-4">
|
||||
<div className="flex flex-col space-y-6">
|
||||
<Alert variant={environment.appSetupCompleted ? "success" : "warning"} size="default">
|
||||
<AlertTitle>
|
||||
{environment.appSetupCompleted
|
||||
? t("environments.surveys.summary.in_app.connection_title")
|
||||
: t("environments.surveys.summary.in_app.no_connection_title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{environment.appSetupCompleted
|
||||
? t("environments.surveys.summary.in_app.connection_description")
|
||||
: t("environments.surveys.summary.in_app.no_connection_description")}
|
||||
</AlertDescription>
|
||||
{!environment.appSetupCompleted && (
|
||||
<AlertButton asChild>
|
||||
<Link href={`/environments/${environment.id}/project/app-connection`}>
|
||||
{t("common.connect_formbricks")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col space-y-3">
|
||||
<H4>{t("environments.surveys.summary.in_app.display_criteria")}</H4>
|
||||
<div
|
||||
className={
|
||||
"flex w-full flex-col space-y-4 rounded-xl border border-slate-200 bg-white p-3 text-left shadow-sm"
|
||||
}>
|
||||
<DisplayCriteriaItem
|
||||
icon={<TimerResetIcon className="h-4 w-4" />}
|
||||
title={waitTime()}
|
||||
titleSuffix={
|
||||
survey.recontactDays !== null
|
||||
? `(${t("environments.surveys.summary.in_app.display_criteria.overwritten")})`
|
||||
: undefined
|
||||
}
|
||||
description={t("environments.surveys.summary.in_app.display_criteria.time_based_description")}
|
||||
/>
|
||||
<DisplayCriteriaItem
|
||||
icon={<UsersIcon className="h-4 w-4" />}
|
||||
title={getSegmentTitle(survey.segment)}
|
||||
description={t("environments.surveys.summary.in_app.display_criteria.audience_description")}
|
||||
/>
|
||||
{survey.triggers.map((trigger) => (
|
||||
<DisplayCriteriaItem
|
||||
key={trigger.actionClass.id}
|
||||
icon={
|
||||
trigger.actionClass.type === "code" ? (
|
||||
<CodeXmlIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<MousePointerClickIcon className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
title={trigger.actionClass.name}
|
||||
titleSuffix={getTriggerDescription(trigger.actionClass, noCodeConfigType)}
|
||||
description={t("environments.surveys.summary.in_app.display_criteria.trigger_description")}
|
||||
/>
|
||||
))}
|
||||
{survey.displayPercentage !== null && survey.displayPercentage > 0 && (
|
||||
<DisplayCriteriaItem
|
||||
icon={<PercentIcon className="h-4 w-4" />}
|
||||
title={t("environments.surveys.summary.in_app.display_criteria.randomizer", {
|
||||
percentage: survey.displayPercentage,
|
||||
})}
|
||||
description={t(
|
||||
"environments.surveys.summary.in_app.display_criteria.randomizer_description",
|
||||
{
|
||||
percentage: survey.displayPercentage,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<DisplayCriteriaItem
|
||||
icon={<Repeat1Icon className="h-4 w-4" />}
|
||||
title={displayOption()}
|
||||
description={t("environments.surveys.summary.in_app.display_criteria.recontact_description")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentationLinksSection
|
||||
title={t("environments.surveys.summary.in_app.documentation_title")}
|
||||
links={documentationLinks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user