mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 21:59:28 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdfbed26f5 |
@@ -1,216 +0,0 @@
|
|||||||
---
|
|
||||||
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)
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
|
||||||
|
|
||||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
|
||||||
|
|
||||||
---
|
|
||||||
title: "FEATURE NAME"
|
|
||||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
|
||||||
icon: "link"
|
|
||||||
---
|
|
||||||
|
|
||||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
|
||||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
|
|
||||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
|
|
||||||
- If a feature is part of the Enterprise Edition, use this note:
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
FEATURE NAME is part of the @Enterprise Edition.
|
|
||||||
</Note>
|
|
||||||
+2
-4
@@ -80,8 +80,8 @@ S3_ENDPOINT_URL=
|
|||||||
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
||||||
S3_FORCE_PATH_STYLE=0
|
S3_FORCE_PATH_STYLE=0
|
||||||
|
|
||||||
# Set this URL to add a public domain for all your client facing routes(default is WEBAPP_URL)
|
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
|
||||||
# PUBLIC_URL=https://survey.example.com
|
# SURVEY_URL=https://survey.example.com
|
||||||
|
|
||||||
#####################
|
#####################
|
||||||
# Disable Features #
|
# Disable Features #
|
||||||
@@ -210,8 +210,6 @@ UNKEY_ROOT_KEY=
|
|||||||
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
|
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
|
||||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||||
# SENTRY_AUTH_TOKEN=
|
# SENTRY_AUTH_TOKEN=
|
||||||
# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard
|
|
||||||
# SENTRY_ENVIRONMENT=
|
|
||||||
|
|
||||||
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
||||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
name: Feature request
|
name: Feature request
|
||||||
description: "Suggest an idea for this project \U0001F680"
|
description: "Suggest an idea for this project \U0001F680"
|
||||||
type: feature
|
type: feature
|
||||||
projects: "formbricks/21"
|
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: problem-description
|
id: problem-description
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
name: 'Upload Sentry Sourcemaps'
|
|
||||||
description: 'Extract sourcemaps from Docker image and upload to Sentry'
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
docker_image:
|
|
||||||
description: 'Docker image to extract sourcemaps from'
|
|
||||||
required: true
|
|
||||||
release_version:
|
|
||||||
description: 'Sentry release version (e.g., v1.2.3)'
|
|
||||||
required: true
|
|
||||||
sentry_auth_token:
|
|
||||||
description: 'Sentry authentication token'
|
|
||||||
required: true
|
|
||||||
|
|
||||||
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"
|
|
||||||
@@ -32,25 +32,3 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||||
ENVIRONMENT: "prod"
|
ENVIRONMENT: "prod"
|
||||||
|
|
||||||
upload-sentry-sourcemaps:
|
|
||||||
name: Upload Sentry Sourcemaps
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
needs:
|
|
||||||
- docker-build
|
|
||||||
- deploy-formbricks-cloud
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4.2.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Upload Sentry Sourcemaps
|
|
||||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
|
||||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
|
||||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
name: Upload Sentry Sourcemaps (Manual)
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
docker_image:
|
|
||||||
description: "Docker image to extract sourcemaps from"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
release_version:
|
|
||||||
description: "Release version (e.g., v1.2.3)"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
tag_version:
|
|
||||||
description: "Docker image tag (leave empty to use release_version)"
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
upload-sourcemaps:
|
|
||||||
name: Upload Sourcemaps to Sentry
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4.2.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set Docker Image
|
|
||||||
run: |
|
|
||||||
if [ -n "${{ inputs.tag_version }}" ]; then
|
|
||||||
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload Sourcemaps to Sentry
|
|
||||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
|
||||||
with:
|
|
||||||
docker_image: ${{ env.DOCKER_IMAGE }}
|
|
||||||
release_version: ${{ inputs.release_version }}
|
|
||||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
@@ -73,4 +73,3 @@ infra/terraform/.terraform/
|
|||||||
/.idea/
|
/.idea/
|
||||||
/*.iml
|
/*.iml
|
||||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||||
.cursorrules
|
|
||||||
|
|||||||
+41
-14
@@ -25,9 +25,21 @@ RUN corepack prepare pnpm@9.15.9 --activate
|
|||||||
# Install necessary build tools and compilers
|
# Install necessary build tools and compilers
|
||||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||||
|
|
||||||
# Copy the secrets handling script
|
# BuildKit secret handling without hardcoded fallback values
|
||||||
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
|
# This approach relies entirely on secrets passed from GitHub Actions
|
||||||
RUN chmod +x /tmp/read-secrets.sh
|
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
|
||||||
|
|
||||||
# Increase Node.js memory limit as a regular build argument
|
# Increase Node.js memory limit as a regular build argument
|
||||||
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||||
@@ -50,9 +62,6 @@ RUN touch apps/web/.env
|
|||||||
# Install the dependencies
|
# Install the dependencies
|
||||||
RUN pnpm install --ignore-scripts
|
RUN pnpm install --ignore-scripts
|
||||||
|
|
||||||
# Build the database package first
|
|
||||||
RUN pnpm build --filter=@formbricks/database
|
|
||||||
|
|
||||||
# Build the project using our secret reader script
|
# Build the project using our secret reader script
|
||||||
# This mounts the secrets only during this build step without storing them in layers
|
# This mounts the secrets only during this build step without storing them in layers
|
||||||
RUN --mount=type=secret,id=database_url \
|
RUN --mount=type=secret,id=database_url \
|
||||||
@@ -97,8 +106,20 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
|||||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||||
|
|
||||||
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
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/node_modules/@prisma/client ./node_modules/@prisma/client
|
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||||
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
||||||
@@ -121,14 +142,12 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
|||||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||||
RUN chmod -R 755 ./node_modules/zod
|
RUN chmod -R 755 ./node_modules/zod
|
||||||
|
|
||||||
|
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
|
||||||
RUN npm install -g prisma
|
RUN npm install -g prisma
|
||||||
|
|
||||||
# Create a startup script to handle the conditional logic
|
|
||||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
|
||||||
RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
ENV NODE_ENV="production"
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Prepare volume for uploads
|
# Prepare volume for uploads
|
||||||
@@ -139,4 +158,12 @@ VOLUME /home/nextjs/apps/web/uploads/
|
|||||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||||
VOLUME /home/nextjs/apps/web/saml-connection
|
VOLUME /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
CMD ["/home/nextjs/start.sh"]
|
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) && \
|
||||||
|
(cd apps/web && exec node server.js)
|
||||||
+4
-4
@@ -27,7 +27,7 @@ describe("ConnectWithFormbricks", () => {
|
|||||||
render(
|
render(
|
||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
environment={environment}
|
environment={environment}
|
||||||
publicDomain={webAppUrl}
|
webAppUrl={webAppUrl}
|
||||||
widgetSetupCompleted={false}
|
widgetSetupCompleted={false}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
@@ -40,7 +40,7 @@ describe("ConnectWithFormbricks", () => {
|
|||||||
render(
|
render(
|
||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
environment={environment}
|
environment={environment}
|
||||||
publicDomain={webAppUrl}
|
webAppUrl={webAppUrl}
|
||||||
widgetSetupCompleted={true}
|
widgetSetupCompleted={true}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
@@ -53,7 +53,7 @@ describe("ConnectWithFormbricks", () => {
|
|||||||
render(
|
render(
|
||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
environment={environment}
|
environment={environment}
|
||||||
publicDomain={webAppUrl}
|
webAppUrl={webAppUrl}
|
||||||
widgetSetupCompleted={true}
|
widgetSetupCompleted={true}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
@@ -67,7 +67,7 @@ describe("ConnectWithFormbricks", () => {
|
|||||||
render(
|
render(
|
||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
environment={environment}
|
environment={environment}
|
||||||
publicDomain={webAppUrl}
|
webAppUrl={webAppUrl}
|
||||||
widgetSetupCompleted={false}
|
widgetSetupCompleted={false}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+3
-3
@@ -12,14 +12,14 @@ import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
|||||||
|
|
||||||
interface ConnectWithFormbricksProps {
|
interface ConnectWithFormbricksProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
publicDomain: string;
|
webAppUrl: string;
|
||||||
widgetSetupCompleted: boolean;
|
widgetSetupCompleted: boolean;
|
||||||
channel: TProjectConfigChannel;
|
channel: TProjectConfigChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConnectWithFormbricks = ({
|
export const ConnectWithFormbricks = ({
|
||||||
environment,
|
environment,
|
||||||
publicDomain,
|
webAppUrl,
|
||||||
widgetSetupCompleted,
|
widgetSetupCompleted,
|
||||||
channel,
|
channel,
|
||||||
}: ConnectWithFormbricksProps) => {
|
}: ConnectWithFormbricksProps) => {
|
||||||
@@ -49,7 +49,7 @@ export const ConnectWithFormbricks = ({
|
|||||||
<div className="flex w-1/2 flex-col space-y-4">
|
<div className="flex w-1/2 flex-col space-y-4">
|
||||||
<OnboardingSetupInstructions
|
<OnboardingSetupInstructions
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
publicDomain={publicDomain}
|
webAppUrl={webAppUrl}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
widgetSetupCompleted={widgetSetupCompleted}
|
widgetSetupCompleted={widgetSetupCompleted}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ describe("OnboardingSetupInstructions", () => {
|
|||||||
// Provide some default props for testing
|
// Provide some default props for testing
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
environmentId: "env-123",
|
environmentId: "env-123",
|
||||||
publicDomain: "https://example.com",
|
webAppUrl: "https://example.com",
|
||||||
channel: "app" as const, // Assuming channel is either "app" or "website"
|
channel: "app" as const, // Assuming channel is either "app" or "website"
|
||||||
widgetSetupCompleted: false,
|
widgetSetupCompleted: false,
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-6
@@ -18,14 +18,14 @@ const tabs = [
|
|||||||
|
|
||||||
interface OnboardingSetupInstructionsProps {
|
interface OnboardingSetupInstructionsProps {
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
publicDomain: string;
|
webAppUrl: string;
|
||||||
channel: TProjectConfigChannel;
|
channel: TProjectConfigChannel;
|
||||||
widgetSetupCompleted: boolean;
|
widgetSetupCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OnboardingSetupInstructions = ({
|
export const OnboardingSetupInstructions = ({
|
||||||
environmentId,
|
environmentId,
|
||||||
publicDomain,
|
webAppUrl,
|
||||||
channel,
|
channel,
|
||||||
widgetSetupCompleted,
|
widgetSetupCompleted,
|
||||||
}: OnboardingSetupInstructionsProps) => {
|
}: OnboardingSetupInstructionsProps) => {
|
||||||
@@ -34,7 +34,7 @@ export const OnboardingSetupInstructions = ({
|
|||||||
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
|
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
!function(){
|
!function(){
|
||||||
var appUrl = "${publicDomain}";
|
var appUrl = "${webAppUrl}";
|
||||||
var environmentId = "${environmentId}";
|
var environmentId = "${environmentId}";
|
||||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||||
</script>
|
</script>
|
||||||
@@ -44,7 +44,7 @@ export const OnboardingSetupInstructions = ({
|
|||||||
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
|
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
!function(){
|
!function(){
|
||||||
var appUrl = "${publicDomain}";
|
var appUrl = "${webAppUrl}";
|
||||||
var environmentId = "${environmentId}";
|
var environmentId = "${environmentId}";
|
||||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||||
</script>
|
</script>
|
||||||
@@ -57,7 +57,7 @@ export const OnboardingSetupInstructions = ({
|
|||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
formbricks.setup({
|
formbricks.setup({
|
||||||
environmentId: "${environmentId}",
|
environmentId: "${environmentId}",
|
||||||
appUrl: "${publicDomain}",
|
appUrl: "${webAppUrl}",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export const OnboardingSetupInstructions = ({
|
|||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
formbricks.setup({
|
formbricks.setup({
|
||||||
environmentId: "${environmentId}",
|
environmentId: "${environmentId}",
|
||||||
appUrl: "${publicDomain}",
|
appUrl: "${webAppUrl}",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||||
|
import { WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
@@ -30,8 +30,6 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
|
|
||||||
const channel = project.config.channel || null;
|
const channel = project.config.channel || null;
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
||||||
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
|
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
|
||||||
@@ -41,7 +39,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
environment={environment}
|
environment={environment}
|
||||||
publicDomain={publicDomain}
|
webAppUrl={WEBAPP_URL}
|
||||||
widgetSetupCompleted={environment.appSetupCompleted}
|
widgetSetupCompleted={environment.appSetupCompleted}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_DEVELOPMENT: true,
|
IS_DEVELOPMENT: true,
|
||||||
E2E_TESTING: false,
|
E2E_TESTING: false,
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
WEBAPP_URL: "http://localhost:3000",
|
||||||
PUBLIC_URL: "http://localhost:3000/survey",
|
SURVEY_URL: "http://localhost:3000/survey",
|
||||||
ENCRYPTION_KEY: "mock-encryption-key",
|
ENCRYPTION_KEY: "mock-encryption-key",
|
||||||
CRON_SECRET: "mock-cron-secret",
|
CRON_SECRET: "mock-cron-secret",
|
||||||
DEFAULT_BRAND_COLOR: "#64748b",
|
DEFAULT_BRAND_COLOR: "#64748b",
|
||||||
|
|||||||
-1
@@ -94,7 +94,6 @@ describe("LandingSidebar component", () => {
|
|||||||
organizationId: "o1",
|
organizationId: "o1",
|
||||||
redirect: true,
|
redirect: true,
|
||||||
callbackUrl: "/auth/login",
|
callbackUrl: "/auth/login",
|
||||||
clearEnvironmentId: true,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
-1
@@ -130,7 +130,6 @@ export const LandingSidebar = ({
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
redirect: true,
|
redirect: true,
|
||||||
callbackUrl: "/auth/login",
|
callbackUrl: "/auth/login",
|
||||||
clearEnvironmentId: true,
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_DEVELOPMENT: true,
|
IS_DEVELOPMENT: true,
|
||||||
E2E_TESTING: false,
|
E2E_TESTING: false,
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
WEBAPP_URL: "http://localhost:3000",
|
||||||
PUBLIC_URL: "http://localhost:3000/survey",
|
SURVEY_URL: "http://localhost:3000/survey",
|
||||||
ENCRYPTION_KEY: "mock-encryption-key",
|
ENCRYPTION_KEY: "mock-encryption-key",
|
||||||
CRON_SECRET: "mock-cron-secret",
|
CRON_SECRET: "mock-cron-secret",
|
||||||
DEFAULT_BRAND_COLOR: "#64748b",
|
DEFAULT_BRAND_COLOR: "#64748b",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_DEVELOPMENT: true,
|
IS_DEVELOPMENT: true,
|
||||||
E2E_TESTING: false,
|
E2E_TESTING: false,
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
WEBAPP_URL: "http://localhost:3000",
|
||||||
|
SURVEY_URL: "http://localhost:3000/survey",
|
||||||
ENCRYPTION_KEY: "mock-encryption-key",
|
ENCRYPTION_KEY: "mock-encryption-key",
|
||||||
CRON_SECRET: "mock-cron-secret",
|
CRON_SECRET: "mock-cron-secret",
|
||||||
DEFAULT_BRAND_COLOR: "#64748b",
|
DEFAULT_BRAND_COLOR: "#64748b",
|
||||||
|
|||||||
-6
@@ -30,12 +30,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
REDIS_URL: "redis://localhost:6379",
|
REDIS_URL: "redis://localhost:6379",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
|
||||||
env: {
|
|
||||||
PUBLIC_URL: "https://public-domain.com",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Contact Page Re-export", () => {
|
describe("Contact Page Re-export", () => {
|
||||||
test("should re-export SingleContactPage", () => {
|
test("should re-export SingleContactPage", () => {
|
||||||
expect(Page).toBe(SingleContactPage);
|
expect(Page).toBe(SingleContactPage);
|
||||||
|
|||||||
+8
-7
@@ -11,21 +11,22 @@ export const ActionClassDataRow = ({
|
|||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="m-2 grid grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
<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-start py-3 pl-6 text-sm">
|
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||||
<div className="flex w-full items-center gap-4">
|
<div className="flex items-center">
|
||||||
<div className="mt-1 h-5 w-5 flex-shrink-0 text-slate-500">
|
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
|
||||||
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="ml-4 text-left">
|
||||||
<div className="break-words font-medium text-slate-900">{actionClass.name}</div>
|
<div className="font-medium text-slate-900">{actionClass.name}</div>
|
||||||
<div className="break-words text-xs text-slate-400">{actionClass.description}</div>
|
<div className="text-xs text-slate-400">{actionClass.description}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-center"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -220,8 +220,6 @@ describe("MainNavigation", () => {
|
|||||||
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
|
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
|
||||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||||
|
|
||||||
// Set up localStorage spy on the mocked localStorage
|
|
||||||
|
|
||||||
render(<MainNavigation {...defaultProps} />);
|
render(<MainNavigation {...defaultProps} />);
|
||||||
|
|
||||||
// Find the avatar and get its parent div which acts as the trigger
|
// Find the avatar and get its parent div which acts as the trigger
|
||||||
@@ -248,9 +246,7 @@ describe("MainNavigation", () => {
|
|||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
redirect: false,
|
redirect: false,
|
||||||
callbackUrl: "/auth/login",
|
callbackUrl: "/auth/login",
|
||||||
clearEnvironmentId: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -396,7 +396,6 @@ export const MainNavigation = ({
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
callbackUrl: "/auth/login",
|
callbackUrl: "/auth/login",
|
||||||
clearEnvironmentId: true,
|
|
||||||
});
|
});
|
||||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
||||||
}}
|
}}
|
||||||
|
|||||||
-6
@@ -29,12 +29,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
|
||||||
env: {
|
|
||||||
PUBLIC_URL: "https://example.com",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("AppConnectionPage Re-export", () => {
|
describe("AppConnectionPage Re-export", () => {
|
||||||
test("should re-export AppConnectionPage correctly", () => {
|
test("should re-export AppConnectionPage correctly", () => {
|
||||||
expect(AppConnectionPage).toBe(OriginalAppConnectionPage);
|
expect(AppConnectionPage).toBe(OriginalAppConnectionPage);
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
|
||||||
env: {
|
|
||||||
PUBLIC_URL: "https://public-domain.com",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("GeneralSettingsPage re-export", () => {
|
describe("GeneralSettingsPage re-export", () => {
|
||||||
test("should re-export GeneralSettingsPage component", () => {
|
test("should re-export GeneralSettingsPage component", () => {
|
||||||
expect(Page).toBe(GeneralSettingsPage);
|
expect(Page).toBe(GeneralSettingsPage);
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
|
||||||
env: {
|
|
||||||
PUBLIC_URL: "https://public-domain.com",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("ProjectLookSettingsPage re-export", () => {
|
describe("ProjectLookSettingsPage re-export", () => {
|
||||||
test("should re-export ProjectLookSettingsPage component", () => {
|
test("should re-export ProjectLookSettingsPage component", () => {
|
||||||
expect(Page).toBe(ProjectLookSettingsPage);
|
expect(Page).toBe(ProjectLookSettingsPage);
|
||||||
|
|||||||
+1
-201
@@ -20,7 +20,7 @@ vi.mock("@/modules/ui/components/switch", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../actions", () => ({
|
vi.mock("../actions", () => ({
|
||||||
updateNotificationSettingsAction: vi.fn(() => Promise.resolve({ data: true })),
|
updateNotificationSettingsAction: vi.fn(() => Promise.resolve()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const surveyId = "survey1";
|
const surveyId = "survey1";
|
||||||
@@ -246,204 +246,4 @@ describe("NotificationSwitch", () => {
|
|||||||
});
|
});
|
||||||
expect(updateNotificationSettingsAction).not.toHaveBeenCalled();
|
expect(updateNotificationSettingsAction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows error toast when updateNotificationSettingsAction fails for 'alert' type", async () => {
|
|
||||||
const mockErrorResponse = { serverError: "Failed to update notification settings" };
|
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
|
||||||
|
|
||||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
|
||||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
|
||||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await user.click(switchInput);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
|
||||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
|
||||||
});
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Failed to update notification settings", {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
expect(toast.success).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast when updateNotificationSettingsAction fails for 'weeklySummary' type", async () => {
|
|
||||||
const mockErrorResponse = { serverError: "Database connection failed" };
|
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
|
||||||
|
|
||||||
const initialSettings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
|
|
||||||
renderSwitch({
|
|
||||||
surveyOrProjectOrOrganizationId: projectId,
|
|
||||||
notificationSettings: initialSettings,
|
|
||||||
notificationType: "weeklySummary",
|
|
||||||
});
|
|
||||||
const switchInput = screen.getByLabelText("toggle notification settings for weeklySummary");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await user.click(switchInput);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
|
||||||
notificationSettings: { ...initialSettings, weeklySummary: { [projectId]: false } },
|
|
||||||
});
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Database connection failed", {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
expect(toast.success).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
|
|
||||||
const mockErrorResponse = { serverError: "Permission denied" };
|
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
|
||||||
|
|
||||||
const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
|
|
||||||
renderSwitch({
|
|
||||||
surveyOrProjectOrOrganizationId: organizationId,
|
|
||||||
notificationSettings: initialSettings,
|
|
||||||
notificationType: "unsubscribedOrganizationIds",
|
|
||||||
});
|
|
||||||
const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await user.click(switchInput);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
|
||||||
notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [organizationId] },
|
|
||||||
});
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Permission denied", {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
expect(toast.success).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast when updateNotificationSettingsAction returns null", async () => {
|
|
||||||
const mockErrorResponse = { serverError: "An error occurred" };
|
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
|
||||||
|
|
||||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
|
||||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
|
||||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await user.click(switchInput);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
|
||||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
|
||||||
});
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
expect(toast.success).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast when updateNotificationSettingsAction returns undefined", async () => {
|
|
||||||
const mockErrorResponse = { serverError: "An error occurred" };
|
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
|
||||||
|
|
||||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
|
||||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
|
||||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await user.click(switchInput);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
|
||||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
|
||||||
});
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
expect(toast.success).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast when updateNotificationSettingsAction returns response without data property", async () => {
|
|
||||||
const mockErrorResponse = { validationErrors: { _errors: ["Invalid input"] } };
|
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
|
||||||
|
|
||||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
|
||||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
|
||||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await user.click(switchInput);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
|
||||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
|
||||||
});
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Invalid input", {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
expect(toast.success).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast when updateNotificationSettingsAction throws an exception", async () => {
|
|
||||||
const mockErrorResponse = { serverError: "Network error" };
|
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
|
||||||
|
|
||||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
|
||||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
|
||||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await user.click(switchInput);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
|
||||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
|
||||||
});
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Network error", {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
expect(toast.success).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switch remains enabled after error occurs", async () => {
|
|
||||||
const mockErrorResponse = { serverError: "Failed to update" };
|
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
|
||||||
|
|
||||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
|
||||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
|
||||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await user.click(switchInput);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Failed to update", {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
expect(switchInput).toBeEnabled(); // Switch should be re-enabled after error
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast with validation errors for specific fields", async () => {
|
|
||||||
const mockErrorResponse = {
|
|
||||||
validationErrors: {
|
|
||||||
notificationSettings: {
|
|
||||||
_errors: ["Invalid notification settings"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
|
||||||
|
|
||||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
|
||||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
|
||||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await user.click(switchInput);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
|
||||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
|
||||||
});
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("notificationSettingsInvalid notification settings", {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
expect(toast.success).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-17
@@ -1,9 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { Switch } from "@/modules/ui/components/switch";
|
import { Switch } from "@/modules/ui/components/switch";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||||
@@ -26,7 +24,6 @@ export const NotificationSwitch = ({
|
|||||||
}: NotificationSwitchProps) => {
|
}: NotificationSwitchProps) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const router = useRouter();
|
|
||||||
const isChecked =
|
const isChecked =
|
||||||
notificationType === "unsubscribedOrganizationIds"
|
notificationType === "unsubscribedOrganizationIds"
|
||||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
||||||
@@ -53,20 +50,7 @@ export const NotificationSwitch = ({
|
|||||||
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
|
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
await updateNotificationSettingsAction({ notificationSettings: updatedNotificationSettings });
|
||||||
notificationSettings: updatedNotificationSettings,
|
|
||||||
});
|
|
||||||
if (updatedNotificationSettingsActionResponse?.data) {
|
|
||||||
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
const errorMessage = getFormattedErrorMessage(updatedNotificationSettingsActionResponse);
|
|
||||||
toast.error(errorMessage, {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,6 +104,9 @@ export const NotificationSwitch = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onCheckedChange={async () => {
|
onCheckedChange={async () => {
|
||||||
await handleSwitchChange();
|
await handleSwitchChange();
|
||||||
|
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
|
||||||
|
id: "notification-switch",
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-19
@@ -13,7 +13,7 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co
|
|||||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
import { sendVerificationNewEmail } from "@/modules/email";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import {
|
import {
|
||||||
@@ -162,21 +162,3 @@ export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatar
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const resetPasswordAction = authenticatedActionClient.action(
|
|
||||||
withAuditLogging(
|
|
||||||
"passwordReset",
|
|
||||||
"user",
|
|
||||||
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
|
||||||
if (ctx.user.identityProvider !== "email") {
|
|
||||||
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendForgotPasswordEmail(ctx.user);
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|||||||
+4
-93
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
import { updateUserAction } from "../actions";
|
||||||
import { EditProfileDetailsForm } from "./EditProfileDetailsForm";
|
import { EditProfileDetailsForm } from "./EditProfileDetailsForm";
|
||||||
|
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
@@ -24,8 +24,6 @@ const mockUser = {
|
|||||||
objective: "other",
|
objective: "other",
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
|
|
||||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
|
||||||
|
|
||||||
// Mock window.location.reload
|
// Mock window.location.reload
|
||||||
const originalLocation = window.location;
|
const originalLocation = window.location;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -37,11 +35,6 @@ beforeEach(() => {
|
|||||||
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
|
||||||
updateUserAction: vi.fn(),
|
updateUserAction: vi.fn(),
|
||||||
resetPasswordAction: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/auth/forgot-password/actions", () => ({
|
|
||||||
forgotPasswordAction: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -57,13 +50,7 @@ describe("EditProfileDetailsForm", () => {
|
|||||||
test("renders with initial user data and updates successfully", async () => {
|
test("renders with initial user data and updates successfully", async () => {
|
||||||
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
|
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
|
||||||
|
|
||||||
render(
|
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />);
|
||||||
<EditProfileDetailsForm
|
|
||||||
user={mockUser}
|
|
||||||
emailVerificationDisabled={true}
|
|
||||||
isPasswordResetEnabled={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||||
expect(nameInput).toHaveValue(mockUser.name);
|
expect(nameInput).toHaveValue(mockUser.name);
|
||||||
@@ -104,13 +91,7 @@ describe("EditProfileDetailsForm", () => {
|
|||||||
const errorMessage = "Update failed";
|
const errorMessage = "Update failed";
|
||||||
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
|
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
|
||||||
|
|
||||||
render(
|
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||||
<EditProfileDetailsForm
|
|
||||||
user={mockUser}
|
|
||||||
emailVerificationDisabled={false}
|
|
||||||
isPasswordResetEnabled={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||||
await userEvent.clear(nameInput);
|
await userEvent.clear(nameInput);
|
||||||
@@ -128,13 +109,7 @@ describe("EditProfileDetailsForm", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("update button is disabled initially and enables on change", async () => {
|
test("update button is disabled initially and enables on change", async () => {
|
||||||
render(
|
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||||
<EditProfileDetailsForm
|
|
||||||
user={mockUser}
|
|
||||||
emailVerificationDisabled={false}
|
|
||||||
isPasswordResetEnabled={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const updateButton = screen.getByText("common.update");
|
const updateButton = screen.getByText("common.update");
|
||||||
expect(updateButton).toBeDisabled();
|
expect(updateButton).toBeDisabled();
|
||||||
|
|
||||||
@@ -142,68 +117,4 @@ describe("EditProfileDetailsForm", () => {
|
|||||||
await userEvent.type(nameInput, " updated");
|
await userEvent.type(nameInput, " updated");
|
||||||
expect(updateButton).toBeEnabled();
|
expect(updateButton).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reset password button works", async () => {
|
|
||||||
vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } });
|
|
||||||
|
|
||||||
render(
|
|
||||||
<EditProfileDetailsForm
|
|
||||||
user={mockUser}
|
|
||||||
emailVerificationDisabled={false}
|
|
||||||
isPasswordResetEnabled={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
|
||||||
await userEvent.click(resetButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(resetPasswordAction).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reset password button handles error correctly", async () => {
|
|
||||||
const errorMessage = "Reset failed";
|
|
||||||
vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage });
|
|
||||||
|
|
||||||
render(
|
|
||||||
<EditProfileDetailsForm
|
|
||||||
user={mockUser}
|
|
||||||
emailVerificationDisabled={false}
|
|
||||||
isPasswordResetEnabled={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
|
||||||
await userEvent.click(resetButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(resetPasswordAction).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.error).toHaveBeenCalledWith(errorMessage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reset password button shows loading state", async () => {
|
|
||||||
vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
|
|
||||||
|
|
||||||
render(
|
|
||||||
<EditProfileDetailsForm
|
|
||||||
user={mockUser}
|
|
||||||
emailVerificationDisabled={false}
|
|
||||||
isPasswordResetEnabled={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
|
||||||
await userEvent.click(resetButton);
|
|
||||||
|
|
||||||
expect(resetButton).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-55
@@ -14,7 +14,6 @@ import {
|
|||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||||
import { Input } from "@/modules/ui/components/input";
|
import { Input } from "@/modules/ui/components/input";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { ChevronDownIcon } from "lucide-react";
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
@@ -23,7 +22,7 @@ import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
import { updateUserAction } from "../actions";
|
||||||
|
|
||||||
// Schema & types
|
// Schema & types
|
||||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
|
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
|
||||||
@@ -31,17 +30,13 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email:
|
|||||||
});
|
});
|
||||||
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
|
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
|
||||||
|
|
||||||
interface IEditProfileDetailsFormProps {
|
|
||||||
user: TUser;
|
|
||||||
isPasswordResetEnabled?: boolean;
|
|
||||||
emailVerificationDisabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EditProfileDetailsForm = ({
|
export const EditProfileDetailsForm = ({
|
||||||
user,
|
user,
|
||||||
isPasswordResetEnabled,
|
|
||||||
emailVerificationDisabled,
|
emailVerificationDisabled,
|
||||||
}: IEditProfileDetailsFormProps) => {
|
}: {
|
||||||
|
user: TUser;
|
||||||
|
emailVerificationDisabled: boolean;
|
||||||
|
}) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const form = useForm<TEditProfileNameForm>({
|
const form = useForm<TEditProfileNameForm>({
|
||||||
@@ -55,8 +50,6 @@ export const EditProfileDetailsForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { isSubmitting, isDirty } = form.formState;
|
const { isSubmitting, isDirty } = form.formState;
|
||||||
|
|
||||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
@@ -97,7 +90,6 @@ export const EditProfileDetailsForm = ({
|
|||||||
redirectUrl: "/email-change-without-verification-success",
|
redirectUrl: "/email-change-without-verification-success",
|
||||||
redirect: true,
|
redirect: true,
|
||||||
callbackUrl: "/email-change-without-verification-success",
|
callbackUrl: "/email-change-without-verification-success",
|
||||||
clearEnvironmentId: true,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -129,28 +121,6 @@ export const EditProfileDetailsForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetPassword = async () => {
|
|
||||||
setIsResettingPassword(true);
|
|
||||||
|
|
||||||
const result = await resetPasswordAction();
|
|
||||||
if (result?.data) {
|
|
||||||
toast.success(t("auth.forgot-password.email-sent.heading"));
|
|
||||||
|
|
||||||
await signOutWithAudit({
|
|
||||||
reason: "password_reset",
|
|
||||||
redirectUrl: "/auth/login",
|
|
||||||
redirect: true,
|
|
||||||
callbackUrl: "/auth/login",
|
|
||||||
clearEnvironmentId: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const errorMessage = getFormattedErrorMessage(result);
|
|
||||||
toast.error(t(errorMessage));
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsResettingPassword(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
@@ -235,26 +205,6 @@ export const EditProfileDetailsForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isPasswordResetEnabled && (
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
<Label htmlFor="reset-password">{t("auth.forgot-password.reset_password")}</Label>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
|
||||||
{t("auth.forgot-password.reset_password_description")}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<Input type="email" id="reset-password" defaultValue={user.email} disabled />
|
|
||||||
<Button
|
|
||||||
onClick={handleResetPassword}
|
|
||||||
loading={isResettingPassword}
|
|
||||||
disabled={isResettingPassword}
|
|
||||||
size="default"
|
|
||||||
variant="secondary">
|
|
||||||
{t("auth.forgot-password.reset_password")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
|
|||||||
+1
-2
@@ -12,8 +12,7 @@ import Page from "./page";
|
|||||||
|
|
||||||
// Mock services and utils
|
// Mock services and utils
|
||||||
vi.mock("@/lib/constants", () => ({
|
vi.mock("@/lib/constants", () => ({
|
||||||
IS_FORMBRICKS_CLOUD: 1,
|
IS_FORMBRICKS_CLOUD: true,
|
||||||
PASSWORD_RESET_DISABLED: 1,
|
|
||||||
EMAIL_VERIFICATION_DISABLED: true,
|
EMAIL_VERIFICATION_DISABLED: true,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/organization/service", () => ({
|
vi.mock("@/lib/organization/service", () => ({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -32,8 +32,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
throw new Error(t("common.user_not_found"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("common.account_settings")}>
|
<PageHeader pageTitle={t("common.account_settings")}>
|
||||||
@@ -44,11 +42,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("environments.settings.profile.personal_information")}
|
title={t("environments.settings.profile.personal_information")}
|
||||||
description={t("environments.settings.profile.update_personal_info")}>
|
description={t("environments.settings.profile.update_personal_info")}>
|
||||||
<EditProfileDetailsForm
|
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
|
||||||
user={user}
|
|
||||||
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
|
|
||||||
isPasswordResetEnabled={isPasswordResetEnabled}
|
|
||||||
/>
|
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("common.avatar")}
|
title={t("common.avatar")}
|
||||||
|
|||||||
-6
@@ -34,12 +34,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
|
||||||
env: {
|
|
||||||
PUBLIC_URL: "https://public-domain.com",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("TeamsPage re-export", () => {
|
describe("TeamsPage re-export", () => {
|
||||||
test("should re-export TeamsPage component", () => {
|
test("should re-export TeamsPage component", () => {
|
||||||
expect(Page).toBe(TeamsPage);
|
expect(Page).toBe(TeamsPage);
|
||||||
|
|||||||
-6
@@ -49,12 +49,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
|
||||||
env: {
|
|
||||||
PUBLIC_URL: "https://public-domain.com",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
|
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
|
||||||
vi.mock("@/app/lib/surveys/surveys");
|
vi.mock("@/app/lib/surveys/surveys");
|
||||||
|
|||||||
+3
-3
@@ -20,7 +20,7 @@ interface ResponsePageProps {
|
|||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
surveyId: string;
|
surveyId: string;
|
||||||
publicDomain: string;
|
webAppUrl: string;
|
||||||
user?: TUser;
|
user?: TUser;
|
||||||
environmentTags: TTag[];
|
environmentTags: TTag[];
|
||||||
responsesPerPage: number;
|
responsesPerPage: number;
|
||||||
@@ -32,7 +32,7 @@ export const ResponsePage = ({
|
|||||||
environment,
|
environment,
|
||||||
survey,
|
survey,
|
||||||
surveyId,
|
surveyId,
|
||||||
publicDomain,
|
webAppUrl,
|
||||||
user,
|
user,
|
||||||
environmentTags,
|
environmentTags,
|
||||||
responsesPerPage,
|
responsesPerPage,
|
||||||
@@ -155,7 +155,7 @@ export const ResponsePage = ({
|
|||||||
<>
|
<>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<CustomFilter survey={surveyMemoized} />
|
<CustomFilter survey={surveyMemoized} />
|
||||||
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
|
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} />}
|
||||||
</div>
|
</div>
|
||||||
<ResponseDataView
|
<ResponseDataView
|
||||||
survey={survey}
|
survey={survey}
|
||||||
|
|||||||
+7
-7
@@ -3,7 +3,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
|||||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||||
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
|
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||||
@@ -65,8 +65,8 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/getPublicUrl", () => ({
|
vi.mock("@/lib/getSurveyUrl", () => ({
|
||||||
getPublicDomain: vi.fn(),
|
getSurveyDomain: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/response/service", () => ({
|
vi.mock("@/lib/response/service", () => ({
|
||||||
@@ -160,7 +160,7 @@ const mockEnvironment = {
|
|||||||
|
|
||||||
const mockTags: TTag[] = [{ id: "tag1", name: "Tag 1", environmentId: mockEnvironmentId } as unknown as TTag];
|
const mockTags: TTag[] = [{ id: "tag1", name: "Tag 1", environmentId: mockEnvironmentId } as unknown as TTag];
|
||||||
const mockLocale: TUserLocale = "en-US";
|
const mockLocale: TUserLocale = "en-US";
|
||||||
const mockPublicDomain = "http://customdomain.com";
|
const mockSurveyDomain = "http://customdomain.com";
|
||||||
|
|
||||||
const mockParams = {
|
const mockParams = {
|
||||||
environmentId: mockEnvironmentId,
|
environmentId: mockEnvironmentId,
|
||||||
@@ -179,7 +179,7 @@ describe("ResponsesPage", () => {
|
|||||||
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
|
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
|
||||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||||
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -205,7 +205,7 @@ describe("ResponsesPage", () => {
|
|||||||
survey: mockSurvey,
|
survey: mockSurvey,
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
publicDomain: mockPublicDomain,
|
surveyDomain: mockSurveyDomain,
|
||||||
}),
|
}),
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
@@ -224,7 +224,7 @@ describe("ResponsesPage", () => {
|
|||||||
environment: mockEnvironment,
|
environment: mockEnvironment,
|
||||||
survey: mockSurvey,
|
survey: mockSurvey,
|
||||||
surveyId: mockSurveyId,
|
surveyId: mockSurveyId,
|
||||||
publicDomain: mockPublicDomain,
|
webAppUrl: "http://localhost:3000",
|
||||||
environmentTags: mockTags,
|
environmentTags: mockTags,
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
responsesPerPage: 10,
|
responsesPerPage: 10,
|
||||||
|
|||||||
+5
-5
@@ -1,8 +1,8 @@
|
|||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||||
@@ -37,7 +37,7 @@ const Page = async (props) => {
|
|||||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||||
|
|
||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
const publicDomain = getPublicDomain();
|
const surveyDomain = getSurveyDomain();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
@@ -49,7 +49,7 @@ const Page = async (props) => {
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
user={user}
|
user={user}
|
||||||
publicDomain={publicDomain}
|
surveyDomain={surveyDomain}
|
||||||
responseCount={responseCount}
|
responseCount={responseCount}
|
||||||
/>
|
/>
|
||||||
}>
|
}>
|
||||||
@@ -59,7 +59,7 @@ const Page = async (props) => {
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
surveyId={params.surveyId}
|
surveyId={params.surveyId}
|
||||||
publicDomain={publicDomain}
|
webAppUrl={WEBAPP_URL}
|
||||||
environmentTags={tags}
|
environmentTags={tags}
|
||||||
user={user}
|
user={user}
|
||||||
responsesPerPage={RESPONSES_PER_PAGE}
|
responsesPerPage={RESPONSES_PER_PAGE}
|
||||||
|
|||||||
+9
-21
@@ -149,7 +149,7 @@ describe("ShareEmbedSurvey", () => {
|
|||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
survey: mockSurveyWeb,
|
survey: mockSurveyWeb,
|
||||||
publicDomain: "https://public-domain.com",
|
surveyDomain: "test.com",
|
||||||
open: true,
|
open: true,
|
||||||
modalView: "start" as "start" | "embed" | "panel",
|
modalView: "start" as "start" | "embed" | "panel",
|
||||||
setOpen: mockSetOpen,
|
setOpen: mockSetOpen,
|
||||||
@@ -158,7 +158,7 @@ describe("ShareEmbedSurvey", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockEmbedViewComponent.mockImplementation(
|
mockEmbedViewComponent.mockImplementation(
|
||||||
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
|
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, surveyDomain, locale }) => (
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
|
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
|
||||||
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
|
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
|
||||||
@@ -166,7 +166,7 @@ describe("ShareEmbedSurvey", () => {
|
|||||||
<div data-testid="embedview-survey-id">{survey.id}</div>
|
<div data-testid="embedview-survey-id">{survey.id}</div>
|
||||||
<div data-testid="embedview-email">{email}</div>
|
<div data-testid="embedview-email">{email}</div>
|
||||||
<div data-testid="embedview-surveyUrl">{surveyUrl}</div>
|
<div data-testid="embedview-surveyUrl">{surveyUrl}</div>
|
||||||
<div data-testid="embedview-publicDomain">{publicDomain}</div>
|
<div data-testid="embedview-surveyDomain">{surveyDomain}</div>
|
||||||
<div data-testid="embedview-locale">{locale}</div>
|
<div data-testid="embedview-locale">{locale}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -176,8 +176,8 @@ describe("ShareEmbedSurvey", () => {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
|
test("renders initial 'start' view correctly when open and modalView is 'start'", () => {
|
||||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} />);
|
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||||
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
|
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
|
||||||
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
|
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
|
||||||
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
|
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
|
||||||
@@ -188,18 +188,6 @@ describe("ShareEmbedSurvey", () => {
|
|||||||
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
|
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders initial 'start' view correctly when open and modalView is 'start' for app survey", () => {
|
|
||||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} />);
|
|
||||||
// For app surveys, ShareSurveyLink should not be rendered
|
|
||||||
expect(screen.queryByText("ShareSurveyLinkMock")).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
|
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
|
||||||
render(<ShareEmbedSurvey {...defaultProps} />);
|
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||||
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
|
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
|
||||||
@@ -217,7 +205,7 @@ describe("ShareEmbedSurvey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
|
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
|
||||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
|
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
|
||||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -231,7 +219,7 @@ describe("ShareEmbedSurvey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
|
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
|
||||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="panel" />);
|
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
|
||||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||||
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -269,8 +257,8 @@ describe("ShareEmbedSurvey", () => {
|
|||||||
};
|
};
|
||||||
expect(embedViewProps.tabs.length).toBe(3);
|
expect(embedViewProps.tabs.length).toBe(3);
|
||||||
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
|
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
|
||||||
expect(embedViewProps.tabs[0].id).toBe("link");
|
expect(embedViewProps.tabs[0].id).toBe("email");
|
||||||
expect(embedViewProps.activeId).toBe("link");
|
expect(embedViewProps.activeId).toBe("email");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("correctly configures for 'web' survey type in embed view", () => {
|
test("correctly configures for 'web' survey type in embed view", () => {
|
||||||
|
|||||||
+28
-30
@@ -24,7 +24,7 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
|
|||||||
|
|
||||||
interface ShareEmbedSurveyProps {
|
interface ShareEmbedSurveyProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
publicDomain: string;
|
surveyDomain: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
modalView: "start" | "embed" | "panel";
|
modalView: "start" | "embed" | "panel";
|
||||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@@ -33,7 +33,7 @@ interface ShareEmbedSurveyProps {
|
|||||||
|
|
||||||
export const ShareEmbedSurvey = ({
|
export const ShareEmbedSurvey = ({
|
||||||
survey,
|
survey,
|
||||||
publicDomain,
|
surveyDomain,
|
||||||
open,
|
open,
|
||||||
modalView,
|
modalView,
|
||||||
setOpen,
|
setOpen,
|
||||||
@@ -47,14 +47,13 @@ export const ShareEmbedSurvey = ({
|
|||||||
const tabs = useMemo(
|
const tabs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
|
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
|
||||||
|
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
|
||||||
{
|
{
|
||||||
id: "link",
|
id: "link",
|
||||||
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
|
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
|
||||||
icon: LinkIcon,
|
icon: LinkIcon,
|
||||||
},
|
},
|
||||||
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
|
|
||||||
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
|
|
||||||
|
|
||||||
{ id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon },
|
{ id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon },
|
||||||
].filter((tab) => !(survey.type === "link" && tab.id === "app")),
|
].filter((tab) => !(survey.type === "link" && tab.id === "app")),
|
||||||
[t, isSingleUseLinkSurvey, survey.type]
|
[t, isSingleUseLinkSurvey, survey.type]
|
||||||
@@ -67,16 +66,16 @@ export const ShareEmbedSurvey = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSurveyUrl = async () => {
|
const fetchSurveyUrl = async () => {
|
||||||
try {
|
try {
|
||||||
const url = await getSurveyUrl(survey, publicDomain, "default");
|
const url = await getSurveyUrl(survey, surveyDomain, "default");
|
||||||
setSurveyUrl(url);
|
setSurveyUrl(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch survey URL:", error);
|
console.error("Failed to fetch survey URL:", error);
|
||||||
// Fallback to a default URL if fetching fails
|
// Fallback to a default URL if fetching fails
|
||||||
setSurveyUrl(`${publicDomain}/s/${survey.id}`);
|
setSurveyUrl(`${surveyDomain}/s/${survey.id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchSurveyUrl();
|
fetchSurveyUrl();
|
||||||
}, [survey, publicDomain]);
|
}, [survey, surveyDomain]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (survey.type !== "link") {
|
if (survey.type !== "link") {
|
||||||
@@ -107,28 +106,27 @@ export const ShareEmbedSurvey = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
|
<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" ? (
|
{showView === "start" ? (
|
||||||
<div className="flex h-full max-w-full flex-col overflow-hidden">
|
<div className="h-full max-w-full overflow-hidden">
|
||||||
{survey.type === "link" && (
|
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
|
||||||
<div className="flex h-2/5 w-full flex-col items-center justify-center space-y-6 p-8 text-center">
|
<DialogTitle>
|
||||||
<DialogTitle>
|
<p className="pt-2 text-xl font-semibold text-slate-800">
|
||||||
<p className="pt-2 text-xl font-semibold text-slate-800">
|
{t("environments.surveys.summary.your_survey_is_public")} 🎉
|
||||||
{t("environments.surveys.summary.your_survey_is_public")} 🎉
|
</p>
|
||||||
</p>
|
</DialogTitle>
|
||||||
</DialogTitle>
|
<DialogDescription className="hidden" />
|
||||||
<DialogDescription className="hidden" />
|
<ShareSurveyLink
|
||||||
<ShareSurveyLink
|
survey={survey}
|
||||||
survey={survey}
|
surveyUrl={surveyUrl}
|
||||||
surveyUrl={surveyUrl}
|
surveyDomain={surveyDomain}
|
||||||
publicDomain={publicDomain}
|
setSurveyUrl={setSurveyUrl}
|
||||||
setSurveyUrl={setSurveyUrl}
|
locale={user.locale}
|
||||||
locale={user.locale}
|
/>
|
||||||
/>
|
</div>
|
||||||
</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="flex h-full flex-col items-center justify-center gap-4 rounded-b-lg bg-slate-50 px-8">
|
|
||||||
<p className="text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -176,7 +174,7 @@ export const ShareEmbedSurvey = ({
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
email={email}
|
email={email}
|
||||||
surveyUrl={surveyUrl}
|
surveyUrl={surveyUrl}
|
||||||
publicDomain={publicDomain}
|
surveyDomain={surveyDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
locale={user.locale}
|
locale={user.locale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+6
-8
@@ -104,15 +104,13 @@ describe("SummaryDropOffs", () => {
|
|||||||
|
|
||||||
// Check drop-off counts and percentages
|
// Check drop-off counts and percentages
|
||||||
expect(screen.getByText("20")).toBeInTheDocument();
|
expect(screen.getByText("20")).toBeInTheDocument();
|
||||||
expect(screen.getByText("15")).toBeInTheDocument();
|
expect(screen.getByText("(20%)")).toBeInTheDocument();
|
||||||
expect(screen.getByText("10")).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Check percentage values
|
expect(screen.getByText("15")).toBeInTheDocument();
|
||||||
const percentageElements = screen.getAllByText(/\d+%/);
|
expect(screen.getByText("(19%)")).toBeInTheDocument(); // 18.75% rounded to 19%
|
||||||
expect(percentageElements).toHaveLength(3);
|
|
||||||
expect(percentageElements[0]).toHaveTextContent("20%");
|
expect(screen.getByText("10")).toBeInTheDocument();
|
||||||
expect(percentageElements[1]).toHaveTextContent("19%");
|
expect(screen.getByText("(15%)")).toBeInTheDocument(); // 15.38% rounded to 15%
|
||||||
expect(percentageElements[2]).toHaveTextContent("15%");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders empty state when dropOff array is empty", () => {
|
test("renders empty state when dropOff array is empty", () => {
|
||||||
|
|||||||
+13
-19
@@ -23,9 +23,9 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div className="">
|
<div className="">
|
||||||
<div className="grid min-h-10 grid-cols-6 items-center rounded-t-xl border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
|
<div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
|
||||||
<div className="col-span-3 px-4 md:px-6">{t("common.questions")}</div>
|
<div className="col-span-3 pl-4 md:pl-6">{t("common.questions")}</div>
|
||||||
<div className="flex justify-end px-4 md:px-6">
|
<div className="flex justify-center">
|
||||||
<TooltipProvider delayDuration={50}>
|
<TooltipProvider delayDuration={50}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
@@ -37,16 +37,14 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 text-right md:px-6">{t("environments.surveys.summary.impressions")}</div>
|
<div className="px-4 text-center md:px-6">{t("environments.surveys.summary.impressions")}</div>
|
||||||
<div className="px-4 text-right md:mr-1 md:pl-6 md:pr-6">
|
<div className="pr-6 text-center md:pl-6">{t("environments.surveys.summary.drop_offs")}</div>
|
||||||
{t("environments.surveys.summary.drop_offs")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{dropOff.map((quesDropOff) => (
|
{dropOff.map((quesDropOff) => (
|
||||||
<div
|
<div
|
||||||
key={quesDropOff.questionId}
|
key={quesDropOff.questionId}
|
||||||
className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm">
|
className="grid grid-cols-6 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||||
<div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
|
<div className="col-span-3 flex gap-3 pl-4 md:pl-6">
|
||||||
{getIcon(quesDropOff.questionType)}
|
{getIcon(quesDropOff.questionType)}
|
||||||
<p>
|
<p>
|
||||||
{formatTextWithSlashes(
|
{formatTextWithSlashes(
|
||||||
@@ -59,21 +57,17 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
|||||||
"default"
|
"default"
|
||||||
)["default"],
|
)["default"],
|
||||||
"@",
|
"@",
|
||||||
["text-sm"]
|
["text-lg"]
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
|
<div className="whitespace-pre-wrap text-center font-semibold">
|
||||||
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
|
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
|
||||||
</div>
|
</div>
|
||||||
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
|
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
|
||||||
{quesDropOff.impressions}
|
<div className="pl-6 text-center md:px-6">
|
||||||
</div>
|
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
|
||||||
<div className="px-4 py-2 text-right md:px-6">
|
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
|
||||||
<span className="mr-1 inline-block w-fit rounded-xl bg-slate-100 px-2 py-1 text-left text-xs">
|
|
||||||
{Math.round(quesDropOff.dropOffPercentage)}%
|
|
||||||
</span>
|
|
||||||
<span className="mr-1 font-mono font-medium">{quesDropOff.dropOffCount}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
@@ -118,13 +117,13 @@ export const SummaryMetadata = ({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<Button variant="secondary" className="h-6 w-6">
|
<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">
|
||||||
{showDropOffs ? (
|
{showDropOffs ? (
|
||||||
<ChevronUpIcon className="h-4 w-4" />
|
<ChevronUpIcon className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDownIcon className="h-4 w-4" />
|
<ChevronDownIcon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -36,7 +36,7 @@ interface SummaryPageProps {
|
|||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
surveyId: string;
|
surveyId: string;
|
||||||
publicDomain: string;
|
webAppUrl: string;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
initialSurveySummary?: TSurveySummary;
|
initialSurveySummary?: TSurveySummary;
|
||||||
@@ -46,7 +46,7 @@ export const SummaryPage = ({
|
|||||||
environment,
|
environment,
|
||||||
survey,
|
survey,
|
||||||
surveyId,
|
surveyId,
|
||||||
publicDomain,
|
webAppUrl,
|
||||||
locale,
|
locale,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
initialSurveySummary,
|
initialSurveySummary,
|
||||||
@@ -133,7 +133,7 @@ export const SummaryPage = ({
|
|||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<CustomFilter survey={surveyMemoized} />
|
<CustomFilter survey={surveyMemoized} />
|
||||||
{!isReadOnly && !isSharingPage && (
|
{!isReadOnly && !isSharingPage && (
|
||||||
<ResultsShareButton survey={surveyMemoized} publicDomain={publicDomain} />
|
<ResultsShareButton survey={surveyMemoized} webAppUrl={webAppUrl} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScrollToTop containerId="mainContent" />
|
<ScrollToTop containerId="mainContent" />
|
||||||
|
|||||||
+245
-255
@@ -21,8 +21,6 @@ vi.mock("@/modules/ee/audit-logs/lib/utils", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockPublicDomain = "https://public-domain.com";
|
|
||||||
|
|
||||||
// Mock constants
|
// Mock constants
|
||||||
vi.mock("@/lib/constants", () => ({
|
vi.mock("@/lib/constants", () => ({
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
IS_FORMBRICKS_CLOUD: false,
|
||||||
@@ -51,12 +49,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
REDIS_URL: "mock-url",
|
REDIS_URL: "mock-url",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
|
||||||
env: {
|
|
||||||
PUBLIC_URL: "https://public-domain.com",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Create a spy for refreshSingleUseId so we can override it in tests
|
// Create a spy for refreshSingleUseId so we can override it in tests
|
||||||
const refreshSingleUseIdSpy = vi.fn(() => Promise.resolve("newSingleUseId"));
|
const refreshSingleUseIdSpy = vi.fn(() => Promise.resolve("newSingleUseId"));
|
||||||
|
|
||||||
@@ -69,18 +61,18 @@ vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
|||||||
|
|
||||||
const mockSearchParams = new URLSearchParams();
|
const mockSearchParams = new URLSearchParams();
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
const mockReplace = vi.fn();
|
|
||||||
|
|
||||||
// Mock next/navigation
|
// Mock next/navigation
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
useRouter: () => ({ push: mockPush }),
|
||||||
useSearchParams: () => mockSearchParams,
|
useSearchParams: () => mockSearchParams,
|
||||||
usePathname: () => "/current-path",
|
usePathname: () => "/current",
|
||||||
|
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock copySurveyLink to return a predictable string
|
// Mock copySurveyLink to return a predictable string
|
||||||
vi.mock("@/modules/survey/lib/client-utils", () => ({
|
vi.mock("@/modules/survey/lib/client-utils", () => ({
|
||||||
copySurveyLink: vi.fn((url: string, suId: string) => `${url}?suId=${suId}`),
|
copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the copy survey action
|
// Mock the copy survey action
|
||||||
@@ -111,10 +103,6 @@ vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
|||||||
getResponseCountBySurveySharingKeyAction: vi.fn(() => Promise.resolve({ data: 5 })),
|
getResponseCountBySurveySharingKeyAction: vi.fn(() => Promise.resolve({ data: 5 })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/getPublicUrl", () => ({
|
|
||||||
getPublicDomain: vi.fn(() => mockPublicDomain),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.spyOn(toast, "success");
|
vi.spyOn(toast, "success");
|
||||||
vi.spyOn(toast, "error");
|
vi.spyOn(toast, "error");
|
||||||
|
|
||||||
@@ -131,281 +119,283 @@ const dummySurvey = {
|
|||||||
id: "survey123",
|
id: "survey123",
|
||||||
type: "link",
|
type: "link",
|
||||||
environmentId: "env123",
|
environmentId: "env123",
|
||||||
status: "inProgress",
|
status: "active",
|
||||||
resultShareKey: null,
|
|
||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
const dummyAppSurvey = {
|
|
||||||
id: "survey123",
|
|
||||||
type: "app",
|
|
||||||
environmentId: "env123",
|
|
||||||
status: "inProgress",
|
|
||||||
} as unknown as TSurvey;
|
|
||||||
|
|
||||||
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
|
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
|
||||||
const dummyUser = { id: "user123", name: "Test User" } as TUser;
|
const dummyUser = { id: "user123", name: "Test User" } as TUser;
|
||||||
|
const surveyDomain = "https://surveys.test.formbricks.com";
|
||||||
|
|
||||||
describe("SurveyAnalysisCTA", () => {
|
describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calls copySurveyLink and clipboard.writeText on success", async () => {
|
||||||
|
render(
|
||||||
|
<SurveyAnalysisCTA
|
||||||
|
survey={dummySurvey}
|
||||||
|
environment={dummyEnvironment}
|
||||||
|
isReadOnly={false}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
|
user={dummyUser}
|
||||||
|
responseCount={5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
|
||||||
|
fireEvent.click(copyButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
|
||||||
|
expect(writeTextMock).toHaveBeenCalledWith(
|
||||||
|
"https://surveys.test.formbricks.com/s/survey123?id=newSingleUseId"
|
||||||
|
);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error toast on failure", async () => {
|
||||||
|
refreshSingleUseIdSpy.mockImplementationOnce(() => Promise.reject(new Error("fail")));
|
||||||
|
render(
|
||||||
|
<SurveyAnalysisCTA
|
||||||
|
survey={dummySurvey}
|
||||||
|
environment={dummyEnvironment}
|
||||||
|
isReadOnly={false}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
|
user={dummyUser}
|
||||||
|
responseCount={5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
|
||||||
|
fireEvent.click(copyButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
|
||||||
|
expect(writeTextMock).not.toHaveBeenCalled();
|
||||||
|
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_copy_link");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// New tests for squarePenIcon and edit functionality
|
||||||
|
describe("SurveyAnalysisCTA - Edit functionality", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
mockSearchParams.delete("share"); // reset params
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Edit functionality", () => {
|
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
|
||||||
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
|
render(
|
||||||
render(
|
<SurveyAnalysisCTA
|
||||||
<SurveyAnalysisCTA
|
survey={dummySurvey}
|
||||||
survey={dummySurvey}
|
environment={dummyEnvironment}
|
||||||
environment={dummyEnvironment}
|
isReadOnly={false}
|
||||||
isReadOnly={false}
|
surveyDomain={surveyDomain}
|
||||||
publicDomain={mockPublicDomain}
|
user={dummyUser}
|
||||||
user={dummyUser}
|
responseCount={5}
|
||||||
responseCount={5}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// Find the edit button
|
// Find the edit button
|
||||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||||
await fireEvent.click(editButton);
|
await fireEvent.click(editButton);
|
||||||
|
|
||||||
// Check if dialog is shown
|
// Check if dialog is shown
|
||||||
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
|
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
|
||||||
expect(dialogTitle).toBeInTheDocument();
|
expect(dialogTitle).toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
test("navigates directly to edit page when response count = 0", async () => {
|
|
||||||
render(
|
|
||||||
<SurveyAnalysisCTA
|
|
||||||
survey={dummySurvey}
|
|
||||||
environment={dummyEnvironment}
|
|
||||||
isReadOnly={false}
|
|
||||||
publicDomain={mockPublicDomain}
|
|
||||||
user={dummyUser}
|
|
||||||
responseCount={0}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find the edit button
|
|
||||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
|
||||||
await fireEvent.click(editButton);
|
|
||||||
|
|
||||||
// Should navigate directly to edit page
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(
|
|
||||||
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("doesn't show edit button when isReadOnly is true", () => {
|
|
||||||
render(
|
|
||||||
<SurveyAnalysisCTA
|
|
||||||
survey={dummySurvey}
|
|
||||||
environment={dummyEnvironment}
|
|
||||||
isReadOnly={true}
|
|
||||||
publicDomain={mockPublicDomain}
|
|
||||||
user={dummyUser}
|
|
||||||
responseCount={5}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const editButton = screen.queryByRole("button", { name: "common.edit" });
|
|
||||||
expect(editButton).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Duplicate functionality", () => {
|
test("navigates directly to edit page when response count = 0", async () => {
|
||||||
test("duplicates survey and redirects on primary button click", async () => {
|
render(
|
||||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValue({
|
<SurveyAnalysisCTA
|
||||||
data: { id: "newSurvey456" },
|
survey={dummySurvey}
|
||||||
});
|
environment={dummyEnvironment}
|
||||||
|
isReadOnly={false}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
|
user={dummyUser}
|
||||||
|
responseCount={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
render(
|
// Find the edit button
|
||||||
<SurveyAnalysisCTA
|
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||||
survey={dummySurvey}
|
await fireEvent.click(editButton);
|
||||||
environment={dummyEnvironment}
|
|
||||||
isReadOnly={false}
|
|
||||||
publicDomain={mockPublicDomain}
|
|
||||||
user={dummyUser}
|
|
||||||
responseCount={5}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
// Should navigate directly to edit page
|
||||||
fireEvent.click(editButton);
|
expect(mockPush).toHaveBeenCalledWith(
|
||||||
|
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
|
||||||
const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
|
);
|
||||||
fireEvent.click(primaryButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
|
|
||||||
environmentId: "env123",
|
|
||||||
surveyId: "survey123",
|
|
||||||
targetEnvironmentId: "env123",
|
|
||||||
});
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/environments/env123/surveys/newSurvey456/edit");
|
|
||||||
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast on duplication failure", async () => {
|
|
||||||
const error = { error: "Duplication failed" };
|
|
||||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValue(error);
|
|
||||||
render(
|
|
||||||
<SurveyAnalysisCTA
|
|
||||||
survey={dummySurvey}
|
|
||||||
environment={dummyEnvironment}
|
|
||||||
isReadOnly={false}
|
|
||||||
publicDomain={mockPublicDomain}
|
|
||||||
user={dummyUser}
|
|
||||||
responseCount={5}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
|
||||||
fireEvent.click(editButton);
|
|
||||||
|
|
||||||
const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
|
|
||||||
fireEvent.click(primaryButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Duplication failed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Share button and modal", () => {
|
test("doesn't show edit button when isReadOnly is true", () => {
|
||||||
test("opens share modal when 'Share survey' button is clicked", async () => {
|
render(
|
||||||
render(
|
<SurveyAnalysisCTA
|
||||||
<SurveyAnalysisCTA
|
survey={dummySurvey}
|
||||||
survey={dummySurvey}
|
environment={dummyEnvironment}
|
||||||
environment={dummyEnvironment}
|
isReadOnly={true}
|
||||||
isReadOnly={false}
|
surveyDomain={surveyDomain}
|
||||||
publicDomain={mockPublicDomain}
|
user={dummyUser}
|
||||||
user={dummyUser}
|
responseCount={5}
|
||||||
responseCount={5}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const shareButton = screen.getByText("environments.surveys.summary.share_survey");
|
// Try to find the edit button (it shouldn't exist)
|
||||||
fireEvent.click(shareButton);
|
const editButton = screen.queryByRole("button", { name: "common.edit" });
|
||||||
|
expect(editButton).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The share button opens the embed modal, not a URL
|
// Updated test description to mention EditPublicSurveyAlertDialog
|
||||||
// We can verify this by checking that the ShareEmbedSurvey component is rendered
|
describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertDialog", () => {
|
||||||
// with the embed modal open
|
afterEach(() => {
|
||||||
expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument();
|
cleanup();
|
||||||
});
|
|
||||||
|
|
||||||
test("renders ShareEmbedSurvey component when share modal is open", async () => {
|
|
||||||
mockSearchParams.set("share", "true");
|
|
||||||
render(
|
|
||||||
<SurveyAnalysisCTA
|
|
||||||
survey={dummySurvey}
|
|
||||||
environment={dummyEnvironment}
|
|
||||||
isReadOnly={false}
|
|
||||||
publicDomain={mockPublicDomain}
|
|
||||||
user={dummyUser}
|
|
||||||
responseCount={5}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assuming ShareEmbedSurvey renders a dialog with a specific title when open
|
|
||||||
const dialog = await screen.findByRole("dialog");
|
|
||||||
expect(dialog).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("General UI and visibility", () => {
|
test("duplicates survey successfully and navigates to edit page", async () => {
|
||||||
test("shows public results badge when resultShareKey is present", () => {
|
// Mock the API response
|
||||||
const surveyWithShareKey = { ...dummySurvey, resultShareKey: "someKey" } as TSurvey;
|
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
|
||||||
render(
|
data: { id: "duplicated-survey-456" },
|
||||||
<SurveyAnalysisCTA
|
|
||||||
survey={surveyWithShareKey}
|
|
||||||
environment={dummyEnvironment}
|
|
||||||
isReadOnly={false}
|
|
||||||
publicDomain={mockPublicDomain}
|
|
||||||
user={dummyUser}
|
|
||||||
responseCount={5}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("environments.surveys.summary.results_are_public")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows SurveyStatusDropdown for non-draft surveys", () => {
|
render(
|
||||||
render(
|
<SurveyAnalysisCTA
|
||||||
<SurveyAnalysisCTA
|
survey={dummySurvey}
|
||||||
survey={dummySurvey}
|
environment={dummyEnvironment}
|
||||||
environment={dummyEnvironment}
|
isReadOnly={false}
|
||||||
isReadOnly={false}
|
surveyDomain={surveyDomain}
|
||||||
publicDomain={mockPublicDomain}
|
user={dummyUser}
|
||||||
user={dummyUser}
|
responseCount={5}
|
||||||
responseCount={5}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
// 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not show SurveyStatusDropdown for draft surveys", () => {
|
// Verify success toast was shown
|
||||||
const draftSurvey = { ...dummySurvey, status: "draft" } as TSurvey;
|
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
|
||||||
render(
|
|
||||||
<SurveyAnalysisCTA
|
// Verify navigation to edit page
|
||||||
survey={draftSurvey}
|
expect(mockPush).toHaveBeenCalledWith(
|
||||||
environment={dummyEnvironment}
|
`/environments/${dummyEnvironment.id}/surveys/duplicated-survey-456/edit`
|
||||||
isReadOnly={false}
|
);
|
||||||
publicDomain={mockPublicDomain}
|
});
|
||||||
user={dummyUser}
|
|
||||||
responseCount={5}
|
test("shows error toast when duplication fails with error object", async () => {
|
||||||
/>
|
// Mock API failure with error object
|
||||||
);
|
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
|
||||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
error: "Test error message",
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hides status dropdown and edit actions when isReadOnly is true", () => {
|
render(
|
||||||
render(
|
<SurveyAnalysisCTA
|
||||||
<SurveyAnalysisCTA
|
survey={dummySurvey}
|
||||||
survey={dummySurvey}
|
environment={dummyEnvironment}
|
||||||
environment={dummyEnvironment}
|
isReadOnly={false}
|
||||||
isReadOnly={true}
|
surveyDomain={surveyDomain}
|
||||||
publicDomain={mockPublicDomain}
|
user={dummyUser}
|
||||||
user={dummyUser}
|
responseCount={5}
|
||||||
responseCount={5}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
// Open dialog
|
||||||
expect(screen.queryByRole("button", { name: "common.edit" })).not.toBeInTheDocument();
|
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}
|
||||||
|
surveyDomain={surveyDomain}
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows preview button for link surveys", () => {
|
mockCopySurveyToOtherEnvironmentAction.mockImplementation(() => promise);
|
||||||
render(
|
|
||||||
<SurveyAnalysisCTA
|
render(
|
||||||
survey={dummySurvey}
|
<SurveyAnalysisCTA
|
||||||
environment={dummyEnvironment}
|
survey={dummySurvey}
|
||||||
isReadOnly={false}
|
environment={dummyEnvironment}
|
||||||
publicDomain={mockPublicDomain}
|
isReadOnly={false}
|
||||||
user={dummyUser}
|
surveyDomain={surveyDomain}
|
||||||
responseCount={5}
|
user={dummyUser}
|
||||||
/>
|
responseCount={5}
|
||||||
);
|
/>
|
||||||
expect(screen.getByRole("button", { name: "common.preview" })).toBeInTheDocument();
|
);
|
||||||
|
|
||||||
|
// Open dialog
|
||||||
|
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||||
|
await fireEvent.click(editButton);
|
||||||
|
|
||||||
|
// Click duplicate
|
||||||
|
const duplicateButton = screen.getByRole("button", {
|
||||||
|
name: "environments.surveys.edit.caution_edit_duplicate",
|
||||||
|
});
|
||||||
|
await fireEvent.click(duplicateButton);
|
||||||
|
|
||||||
|
// Button should now be in loading state
|
||||||
|
// expect(duplicateButton).toHaveAttribute("data-state", "loading");
|
||||||
|
|
||||||
|
// Resolve the promise
|
||||||
|
resolvePromise!({
|
||||||
|
data: { id: "duplicated-survey-456" },
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hides preview button for app surveys", () => {
|
// Wait for the promise to resolve
|
||||||
render(
|
await waitFor(() => {
|
||||||
<SurveyAnalysisCTA
|
expect(mockPush).toHaveBeenCalled();
|
||||||
survey={dummyAppSurvey}
|
|
||||||
environment={dummyEnvironment}
|
|
||||||
isReadOnly={false}
|
|
||||||
publicDomain={mockPublicDomain}
|
|
||||||
user={dummyUser}
|
|
||||||
responseCount={5}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(screen.queryByRole("button", { name: "common.preview" })).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+49
-17
@@ -5,12 +5,13 @@ import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys
|
|||||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||||
|
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||||
|
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
|
||||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||||
import { Badge } from "@/modules/ui/components/badge";
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
|
import { BellRing, Code2Icon, Eye, LinkIcon, SquarePenIcon, UsersRound } from "lucide-react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
@@ -23,7 +24,7 @@ interface SurveyAnalysisCTAProps {
|
|||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
user: TUser;
|
user: TUser;
|
||||||
publicDomain: string;
|
surveyDomain: string;
|
||||||
responseCount: number;
|
responseCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
environment,
|
environment,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
user,
|
user,
|
||||||
publicDomain,
|
surveyDomain,
|
||||||
responseCount,
|
responseCount,
|
||||||
}: SurveyAnalysisCTAProps) => {
|
}: SurveyAnalysisCTAProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
@@ -55,7 +56,8 @@ export const SurveyAnalysisCTA = ({
|
|||||||
dropdown: false,
|
dropdown: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
|
const surveyUrl = useMemo(() => `${surveyDomain}/s/${survey.id}`, [survey.id, surveyDomain]);
|
||||||
|
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||||
|
|
||||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||||
|
|
||||||
@@ -77,6 +79,22 @@ export const SurveyAnalysisCTA = ({
|
|||||||
setModalState((prev) => ({ ...prev, share: open }));
|
setModalState((prev) => ({ ...prev, share: open }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
refreshSingleUseId()
|
||||||
|
.then((newId) => {
|
||||||
|
const linkToCopy = copySurveyLink(surveyUrl, newId);
|
||||||
|
return navigator.clipboard.writeText(linkToCopy);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(t("common.copied_to_clipboard"));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
setModalState((prev) => ({ ...prev, dropdown: false }));
|
||||||
|
};
|
||||||
|
|
||||||
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
||||||
@@ -116,6 +134,24 @@ export const SurveyAnalysisCTA = ({
|
|||||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||||
|
|
||||||
const iconActions = [
|
const iconActions = [
|
||||||
|
{
|
||||||
|
icon: Eye,
|
||||||
|
tooltip: t("common.preview"),
|
||||||
|
onClick: () => window.open(getPreviewUrl(), "_blank"),
|
||||||
|
isVisible: survey.type === "link",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: LinkIcon,
|
||||||
|
tooltip: t("common.copy_link"),
|
||||||
|
onClick: handleCopyLink,
|
||||||
|
isVisible: survey.type === "link",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Code2Icon,
|
||||||
|
tooltip: t("common.embed"),
|
||||||
|
onClick: () => handleModalState("embed")(true),
|
||||||
|
isVisible: !isReadOnly,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: BellRing,
|
icon: BellRing,
|
||||||
tooltip: t("environments.surveys.summary.configure_alerts"),
|
tooltip: t("environments.surveys.summary.configure_alerts"),
|
||||||
@@ -123,10 +159,13 @@ export const SurveyAnalysisCTA = ({
|
|||||||
isVisible: !isReadOnly,
|
isVisible: !isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Eye,
|
icon: UsersRound,
|
||||||
tooltip: t("common.preview"),
|
tooltip: t("environments.surveys.summary.send_to_panel"),
|
||||||
onClick: () => window.open(getPreviewUrl(), "_blank"),
|
onClick: () => {
|
||||||
isVisible: survey.type === "link",
|
handleModalState("panel")(true);
|
||||||
|
setModalState((prev) => ({ ...prev, dropdown: false }));
|
||||||
|
},
|
||||||
|
isVisible: !isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: SquarePenIcon,
|
icon: SquarePenIcon,
|
||||||
@@ -156,13 +195,6 @@ export const SurveyAnalysisCTA = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<IconBar actions={iconActions} />
|
<IconBar actions={iconActions} />
|
||||||
<Button
|
|
||||||
className="h-10"
|
|
||||||
onClick={() => {
|
|
||||||
setModalState((prev) => ({ ...prev, embed: true }));
|
|
||||||
}}>
|
|
||||||
{t("environments.surveys.summary.share_survey")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
@@ -170,7 +202,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
<ShareEmbedSurvey
|
<ShareEmbedSurvey
|
||||||
key={key}
|
key={key}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
publicDomain={publicDomain}
|
surveyDomain={surveyDomain}
|
||||||
open={modalState[key as keyof ModalState]}
|
open={modalState[key as keyof ModalState]}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
user={user}
|
user={user}
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ const defaultProps = {
|
|||||||
survey: mockSurveyLink,
|
survey: mockSurveyLink,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
surveyUrl: "http://example.com/survey1",
|
surveyUrl: "http://example.com/survey1",
|
||||||
publicDomain: "http://example.com",
|
surveyDomain: "http://example.com",
|
||||||
setSurveyUrl: vi.fn(),
|
setSurveyUrl: vi.fn(),
|
||||||
locale: "en" as any,
|
locale: "en" as any,
|
||||||
disableBack: false,
|
disableBack: false,
|
||||||
|
|||||||
+3
-3
@@ -20,7 +20,7 @@ interface EmbedViewProps {
|
|||||||
survey: any;
|
survey: any;
|
||||||
email: string;
|
email: string;
|
||||||
surveyUrl: string;
|
surveyUrl: string;
|
||||||
publicDomain: string;
|
surveyDomain: string;
|
||||||
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
|
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ export const EmbedView = ({
|
|||||||
survey,
|
survey,
|
||||||
email,
|
email,
|
||||||
surveyUrl,
|
surveyUrl,
|
||||||
publicDomain,
|
surveyDomain,
|
||||||
setSurveyUrl,
|
setSurveyUrl,
|
||||||
locale,
|
locale,
|
||||||
}: EmbedViewProps) => {
|
}: EmbedViewProps) => {
|
||||||
@@ -83,7 +83,7 @@ export const EmbedView = ({
|
|||||||
<LinkTab
|
<LinkTab
|
||||||
survey={survey}
|
survey={survey}
|
||||||
surveyUrl={surveyUrl}
|
surveyUrl={surveyUrl}
|
||||||
publicDomain={publicDomain}
|
surveyDomain={surveyDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+8
-8
@@ -6,12 +6,12 @@ import { LinkTab } from "./LinkTab";
|
|||||||
|
|
||||||
// Mock ShareSurveyLink
|
// Mock ShareSurveyLink
|
||||||
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
|
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
|
||||||
ShareSurveyLink: vi.fn(({ survey, surveyUrl, publicDomain, locale }) => (
|
ShareSurveyLink: vi.fn(({ survey, surveyUrl, surveyDomain, locale }) => (
|
||||||
<div data-testid="share-survey-link">
|
<div data-testid="share-survey-link">
|
||||||
Mocked ShareSurveyLink
|
Mocked ShareSurveyLink
|
||||||
<span data-testid="survey-id">{survey.id}</span>
|
<span data-testid="survey-id">{survey.id}</span>
|
||||||
<span data-testid="survey-url">{surveyUrl}</span>
|
<span data-testid="survey-url">{surveyUrl}</span>
|
||||||
<span data-testid="public-domain">{publicDomain}</span>
|
<span data-testid="survey-domain">{surveyDomain}</span>
|
||||||
<span data-testid="locale">{locale}</span>
|
<span data-testid="locale">{locale}</span>
|
||||||
</div>
|
</div>
|
||||||
)),
|
)),
|
||||||
@@ -49,7 +49,7 @@ const mockSurvey: TSurvey = {
|
|||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
|
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
|
||||||
const mockPublicDomain = "https://app.formbricks.com";
|
const mockSurveyDomain = "https://app.formbricks.com";
|
||||||
const mockSetSurveyUrl = vi.fn();
|
const mockSetSurveyUrl = vi.fn();
|
||||||
const mockLocale: TUserLocale = "en-US";
|
const mockLocale: TUserLocale = "en-US";
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ describe("LinkTab", () => {
|
|||||||
<LinkTab
|
<LinkTab
|
||||||
survey={mockSurvey}
|
survey={mockSurvey}
|
||||||
surveyUrl={mockSurveyUrl}
|
surveyUrl={mockSurveyUrl}
|
||||||
publicDomain={mockPublicDomain}
|
surveyDomain={mockSurveyDomain}
|
||||||
setSurveyUrl={mockSetSurveyUrl}
|
setSurveyUrl={mockSetSurveyUrl}
|
||||||
locale={mockLocale}
|
locale={mockLocale}
|
||||||
/>
|
/>
|
||||||
@@ -97,7 +97,7 @@ describe("LinkTab", () => {
|
|||||||
<LinkTab
|
<LinkTab
|
||||||
survey={mockSurvey}
|
survey={mockSurvey}
|
||||||
surveyUrl={mockSurveyUrl}
|
surveyUrl={mockSurveyUrl}
|
||||||
publicDomain={mockPublicDomain}
|
surveyDomain={mockSurveyDomain}
|
||||||
setSurveyUrl={mockSetSurveyUrl}
|
setSurveyUrl={mockSetSurveyUrl}
|
||||||
locale={mockLocale}
|
locale={mockLocale}
|
||||||
/>
|
/>
|
||||||
@@ -105,7 +105,7 @@ describe("LinkTab", () => {
|
|||||||
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
|
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
|
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
|
||||||
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
|
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
|
||||||
expect(screen.getByTestId("public-domain")).toHaveTextContent(mockPublicDomain);
|
expect(screen.getByTestId("survey-domain")).toHaveTextContent(mockSurveyDomain);
|
||||||
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
|
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ describe("LinkTab", () => {
|
|||||||
<LinkTab
|
<LinkTab
|
||||||
survey={mockSurvey}
|
survey={mockSurvey}
|
||||||
surveyUrl={mockSurveyUrl}
|
surveyUrl={mockSurveyUrl}
|
||||||
publicDomain={mockPublicDomain}
|
surveyDomain={mockSurveyDomain}
|
||||||
setSurveyUrl={mockSetSurveyUrl}
|
setSurveyUrl={mockSetSurveyUrl}
|
||||||
locale={mockLocale}
|
locale={mockLocale}
|
||||||
/>
|
/>
|
||||||
@@ -129,7 +129,7 @@ describe("LinkTab", () => {
|
|||||||
<LinkTab
|
<LinkTab
|
||||||
survey={mockSurvey}
|
survey={mockSurvey}
|
||||||
surveyUrl={mockSurveyUrl}
|
surveyUrl={mockSurveyUrl}
|
||||||
publicDomain={mockPublicDomain}
|
surveyDomain={mockSurveyDomain}
|
||||||
setSurveyUrl={mockSetSurveyUrl}
|
setSurveyUrl={mockSetSurveyUrl}
|
||||||
locale={mockLocale}
|
locale={mockLocale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+3
-3
@@ -9,12 +9,12 @@ import { TUserLocale } from "@formbricks/types/user";
|
|||||||
interface LinkTabProps {
|
interface LinkTabProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
surveyUrl: string;
|
surveyUrl: string;
|
||||||
publicDomain: string;
|
surveyDomain: string;
|
||||||
setSurveyUrl: (url: string) => void;
|
setSurveyUrl: (url: string) => void;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale }: LinkTabProps) => {
|
export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const docsLinks = [
|
const docsLinks = [
|
||||||
@@ -44,7 +44,7 @@ export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale
|
|||||||
<ShareSurveyLink
|
<ShareSurveyLink
|
||||||
survey={survey}
|
survey={survey}
|
||||||
surveyUrl={surveyUrl}
|
surveyUrl={surveyUrl}
|
||||||
publicDomain={publicDomain}
|
surveyDomain={surveyDomain}
|
||||||
setSurveyUrl={setSurveyUrl}
|
setSurveyUrl={setSurveyUrl}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+7
-15
@@ -1,8 +1,9 @@
|
|||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getStyling } from "@/lib/utils/styling";
|
import { getStyling } from "@/lib/utils/styling";
|
||||||
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
||||||
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
@@ -34,16 +35,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
vi.mock("@/lib/getSurveyUrl");
|
||||||
env: {
|
|
||||||
PUBLIC_URL: "https://public-domain.com",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/getPublicUrl", () => ({
|
|
||||||
getPublicDomain: vi.fn().mockReturnValue("https://public-domain.com"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/project/service");
|
vi.mock("@/lib/project/service");
|
||||||
vi.mock("@/lib/survey/service");
|
vi.mock("@/lib/survey/service");
|
||||||
vi.mock("@/lib/utils/styling");
|
vi.mock("@/lib/utils/styling");
|
||||||
@@ -129,7 +121,7 @@ const mockComputedStyling = {
|
|||||||
thankYouCardIconBgColor: "#DDDDDD",
|
thankYouCardIconBgColor: "#DDDDDD",
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const mockPublicDomain = "https://app.formbricks.com";
|
const mockSurveyDomain = "https://app.formbricks.com";
|
||||||
const mockRawHtml = `${doctype}<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
|
const mockRawHtml = `${doctype}<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
|
||||||
const mockCleanedHtml = `<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
|
const mockCleanedHtml = `<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
|
||||||
|
|
||||||
@@ -144,7 +136,7 @@ describe("getEmailTemplateHtml", () => {
|
|||||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
|
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
|
||||||
vi.mocked(getStyling).mockReturnValue(mockComputedStyling);
|
vi.mocked(getStyling).mockReturnValue(mockComputedStyling);
|
||||||
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
|
||||||
vi.mocked(getPreviewEmailTemplateHtml).mockResolvedValue(mockRawHtml);
|
vi.mocked(getPreviewEmailTemplateHtml).mockResolvedValue(mockRawHtml);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,8 +147,8 @@ describe("getEmailTemplateHtml", () => {
|
|||||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockSurvey.environmentId);
|
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockSurvey.environmentId);
|
||||||
expect(getStyling).toHaveBeenCalledWith(mockProject, mockSurvey);
|
expect(getStyling).toHaveBeenCalledWith(mockProject, mockSurvey);
|
||||||
expect(getPublicDomain).toHaveBeenCalledTimes(1);
|
expect(getSurveyDomain).toHaveBeenCalledTimes(1);
|
||||||
const expectedSurveyUrl = `${mockPublicDomain}/s/${mockSurvey.id}`;
|
const expectedSurveyUrl = `${mockSurveyDomain}/s/${mockSurvey.id}`;
|
||||||
expect(getPreviewEmailTemplateHtml).toHaveBeenCalledWith(
|
expect(getPreviewEmailTemplateHtml).toHaveBeenCalledWith(
|
||||||
mockSurvey,
|
mockSurvey,
|
||||||
expectedSurveyUrl,
|
expectedSurveyUrl,
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getStyling } from "@/lib/utils/styling";
|
import { getStyling } from "@/lib/utils/styling";
|
||||||
@@ -17,7 +17,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styling = getStyling(project, survey);
|
const styling = getStyling(project, survey);
|
||||||
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
|
const surveyUrl = getSurveyDomain() + "/s/" + survey.id;
|
||||||
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
||||||
const doctype =
|
const doctype =
|
||||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
||||||
|
|||||||
+8
-7
@@ -3,8 +3,8 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
|||||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||||
import SurveyPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page";
|
import SurveyPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page";
|
||||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -38,6 +38,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
|
WEBAPP_URL: "http://localhost:3000",
|
||||||
RESPONSES_PER_PAGE: 10,
|
RESPONSES_PER_PAGE: 10,
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
}));
|
}));
|
||||||
@@ -63,8 +64,8 @@ vi.mock(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mock("@/lib/getPublicUrl", () => ({
|
vi.mock("@/lib/getSurveyUrl", () => ({
|
||||||
getPublicDomain: vi.fn(),
|
getSurveyDomain: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/response/service", () => ({
|
vi.mock("@/lib/response/service", () => ({
|
||||||
@@ -210,7 +211,7 @@ describe("SurveyPage", () => {
|
|||||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||||
vi.mocked(getPublicDomain).mockReturnValue("http://localhost:3000");
|
vi.mocked(getSurveyDomain).mockReturnValue("test.domain.com");
|
||||||
vi.mocked(getSurveySummary).mockResolvedValue(mockSurveySummary);
|
vi.mocked(getSurveySummary).mockResolvedValue(mockSurveySummary);
|
||||||
vi.mocked(notFound).mockClear();
|
vi.mocked(notFound).mockClear();
|
||||||
});
|
});
|
||||||
@@ -234,7 +235,7 @@ describe("SurveyPage", () => {
|
|||||||
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
|
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||||
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
|
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
|
||||||
expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId);
|
expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId);
|
||||||
expect(vi.mocked(getPublicDomain)).toHaveBeenCalled();
|
expect(vi.mocked(getSurveyDomain)).toHaveBeenCalled();
|
||||||
|
|
||||||
expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual(
|
expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -249,7 +250,7 @@ describe("SurveyPage", () => {
|
|||||||
environment: mockEnvironment,
|
environment: mockEnvironment,
|
||||||
survey: mockSurvey,
|
survey: mockSurvey,
|
||||||
surveyId: mockSurveyId,
|
surveyId: mockSurveyId,
|
||||||
publicDomain: "http://localhost:3000",
|
webAppUrl: WEBAPP_URL,
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
locale: mockUser.locale ?? DEFAULT_LOCALE,
|
locale: mockUser.locale ?? DEFAULT_LOCALE,
|
||||||
initialSurveySummary: mockSurveySummary,
|
initialSurveySummary: mockSurveySummary,
|
||||||
|
|||||||
+5
-5
@@ -2,8 +2,8 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
|||||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
@@ -40,7 +40,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
const surveyDomain = getSurveyDomain();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
@@ -52,7 +52,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
user={user}
|
user={user}
|
||||||
publicDomain={publicDomain}
|
surveyDomain={surveyDomain}
|
||||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||||
/>
|
/>
|
||||||
}>
|
}>
|
||||||
@@ -62,7 +62,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
surveyId={params.surveyId}
|
surveyId={params.surveyId}
|
||||||
publicDomain={publicDomain}
|
webAppUrl={WEBAPP_URL}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
locale={user.locale ?? DEFAULT_LOCALE}
|
locale={user.locale ?? DEFAULT_LOCALE}
|
||||||
initialSurveySummary={initialSurveySummary}
|
initialSurveySummary={initialSurveySummary}
|
||||||
|
|||||||
+6
-10
@@ -138,7 +138,7 @@ describe("ResultsShareButton", () => {
|
|||||||
|
|
||||||
test("renders initial state and fetches sharing key (no existing key)", async () => {
|
test("renders initial state and fetches sharing key (no existing key)", async () => {
|
||||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("dropdown-menu-trigger")).toBeInTheDocument();
|
expect(screen.getByTestId("dropdown-menu-trigger")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
|
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
|
||||||
@@ -150,7 +150,7 @@ describe("ResultsShareButton", () => {
|
|||||||
|
|
||||||
test("handles copy private link to clipboard", async () => {
|
test("handles copy private link to clipboard", async () => {
|
||||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
|
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
|
||||||
const copyLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
const copyLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||||
@@ -166,9 +166,7 @@ describe("ResultsShareButton", () => {
|
|||||||
test("handles copy public link to clipboard", async () => {
|
test("handles copy public link to clipboard", async () => {
|
||||||
const shareKey = "publicShareKey";
|
const shareKey = "publicShareKey";
|
||||||
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
|
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
|
||||||
render(
|
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
|
||||||
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
|
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
|
||||||
const copyPublicLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
const copyPublicLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||||
@@ -186,7 +184,7 @@ describe("ResultsShareButton", () => {
|
|||||||
test("handles publish to web successfully", async () => {
|
test("handles publish to web successfully", async () => {
|
||||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||||
mockGenerateResultShareUrlAction.mockResolvedValue({ data: "newShareKey" });
|
mockGenerateResultShareUrlAction.mockResolvedValue({ data: "newShareKey" });
|
||||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||||
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||||
@@ -212,9 +210,7 @@ describe("ResultsShareButton", () => {
|
|||||||
const shareKey = "toUnpublishKey";
|
const shareKey = "toUnpublishKey";
|
||||||
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
|
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
|
||||||
mockDeleteResultShareUrlAction.mockResolvedValue({ data: { id: mockSurvey.id } });
|
mockDeleteResultShareUrlAction.mockResolvedValue({ data: { id: mockSurvey.id } });
|
||||||
render(
|
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
|
||||||
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||||
const unpublishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
const unpublishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||||
@@ -238,7 +234,7 @@ describe("ResultsShareButton", () => {
|
|||||||
|
|
||||||
test("opens and closes ShareSurveyResults modal", async () => {
|
test("opens and closes ShareSurveyResults modal", async () => {
|
||||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||||
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||||
|
|||||||
+5
-5
@@ -21,10 +21,10 @@ import { ShareSurveyResults } from "../(analysis)/summary/components/ShareSurvey
|
|||||||
|
|
||||||
interface ResultsShareButtonProps {
|
interface ResultsShareButtonProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
publicDomain: string;
|
webAppUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonProps) => {
|
export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const [showResultsLinkModal, setShowResultsLinkModal] = useState(false);
|
const [showResultsLinkModal, setShowResultsLinkModal] = useState(false);
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonP
|
|||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
const resultShareKeyResponse = await generateResultShareUrlAction({ surveyId: survey.id });
|
const resultShareKeyResponse = await generateResultShareUrlAction({ surveyId: survey.id });
|
||||||
if (resultShareKeyResponse?.data) {
|
if (resultShareKeyResponse?.data) {
|
||||||
setSurveyUrl(publicDomain + "/share/" + resultShareKeyResponse.data);
|
setSurveyUrl(webAppUrl + "/share/" + resultShareKeyResponse.data);
|
||||||
setShowPublishModal(true);
|
setShowPublishModal(true);
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = getFormattedErrorMessage(resultShareKeyResponse);
|
const errorMessage = getFormattedErrorMessage(resultShareKeyResponse);
|
||||||
@@ -58,13 +58,13 @@ export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonP
|
|||||||
const fetchSharingKey = async () => {
|
const fetchSharingKey = async () => {
|
||||||
const resultShareUrlResponse = await getResultShareUrlAction({ surveyId: survey.id });
|
const resultShareUrlResponse = await getResultShareUrlAction({ surveyId: survey.id });
|
||||||
if (resultShareUrlResponse?.data) {
|
if (resultShareUrlResponse?.data) {
|
||||||
setSurveyUrl(publicDomain + "/share/" + resultShareUrlResponse.data);
|
setSurveyUrl(webAppUrl + "/share/" + resultShareUrlResponse.data);
|
||||||
setShowPublishModal(true);
|
setShowPublishModal(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchSharingKey();
|
fetchSharingKey();
|
||||||
}, [survey.id, publicDomain]);
|
}, [survey.id, webAppUrl]);
|
||||||
|
|
||||||
const copyUrlToClipboard = () => {
|
const copyUrlToClipboard = () => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
|||||||
@@ -14,64 +14,41 @@ describe("ClientEnvironmentRedirect", () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should redirect to the first environment ID when no last environment exists", () => {
|
test("should redirect to the provided environment ID when no last environment exists", () => {
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||||
|
|
||||||
// Mock localStorage
|
// Mock localStorage
|
||||||
const localStorageMock = {
|
const localStorageMock = {
|
||||||
getItem: vi.fn().mockReturnValue(null),
|
getItem: vi.fn().mockReturnValue(null),
|
||||||
removeItem: vi.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.defineProperty(window, "localStorage", {
|
Object.defineProperty(window, "localStorage", {
|
||||||
value: localStorageMock,
|
value: localStorageMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<ClientEnvironmentRedirect userEnvironments={["test-env-id"]} />);
|
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id");
|
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should redirect to the last environment ID when it exists in localStorage and is valid", () => {
|
test("should redirect to the last environment ID when it exists in localStorage", () => {
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||||
|
|
||||||
// Mock localStorage with a last environment ID
|
// Mock localStorage with a last environment ID
|
||||||
const localStorageMock = {
|
const localStorageMock = {
|
||||||
getItem: vi.fn().mockReturnValue("last-env-id"),
|
getItem: vi.fn().mockReturnValue("last-env-id"),
|
||||||
removeItem: vi.fn(),
|
|
||||||
};
|
};
|
||||||
Object.defineProperty(window, "localStorage", {
|
Object.defineProperty(window, "localStorage", {
|
||||||
value: localStorageMock,
|
value: localStorageMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<ClientEnvironmentRedirect userEnvironments={["last-env-id", "other-env-id"]} />);
|
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
|
||||||
|
|
||||||
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||||
expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id");
|
expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should clear invalid environment ID and redirect to default when stored ID is not in user environments", () => {
|
|
||||||
const mockPush = vi.fn();
|
|
||||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
|
||||||
|
|
||||||
// Mock localStorage with an invalid environment ID
|
|
||||||
const localStorageMock = {
|
|
||||||
getItem: vi.fn().mockReturnValue("invalid-env-id"),
|
|
||||||
removeItem: vi.fn(),
|
|
||||||
};
|
|
||||||
Object.defineProperty(window, "localStorage", {
|
|
||||||
value: localStorageMock,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<ClientEnvironmentRedirect userEnvironments={["valid-env-1", "valid-env-2"]} />);
|
|
||||||
|
|
||||||
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
|
||||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/environments/valid-env-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should update redirect when environment ID prop changes", () => {
|
test("should update redirect when environment ID prop changes", () => {
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||||
@@ -79,20 +56,19 @@ describe("ClientEnvironmentRedirect", () => {
|
|||||||
// Mock localStorage
|
// Mock localStorage
|
||||||
const localStorageMock = {
|
const localStorageMock = {
|
||||||
getItem: vi.fn().mockReturnValue(null),
|
getItem: vi.fn().mockReturnValue(null),
|
||||||
removeItem: vi.fn(),
|
|
||||||
};
|
};
|
||||||
Object.defineProperty(window, "localStorage", {
|
Object.defineProperty(window, "localStorage", {
|
||||||
value: localStorageMock,
|
value: localStorageMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { rerender } = render(<ClientEnvironmentRedirect userEnvironments={["initial-env-id"]} />);
|
const { rerender } = render(<ClientEnvironmentRedirect environmentId="initial-env-id" />);
|
||||||
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
|
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
|
||||||
|
|
||||||
// Clear mock calls
|
// Clear mock calls
|
||||||
mockPush.mockClear();
|
mockPush.mockClear();
|
||||||
|
|
||||||
// Rerender with new environment ID
|
// Rerender with new environment ID
|
||||||
rerender(<ClientEnvironmentRedirect userEnvironments={["new-env-id"]} />);
|
rerender(<ClientEnvironmentRedirect environmentId="new-env-id" />);
|
||||||
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
|
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,23 +5,22 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
interface ClientEnvironmentRedirectProps {
|
interface ClientEnvironmentRedirectProps {
|
||||||
userEnvironments: string[];
|
environmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClientEnvironmentRedirect = ({ userEnvironments }: ClientEnvironmentRedirectProps) => {
|
const ClientEnvironmentRedirect = ({ environmentId }: ClientEnvironmentRedirectProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lastEnvironmentId = localStorage.getItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
const lastEnvironmentId = localStorage.getItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||||
|
|
||||||
if (lastEnvironmentId && userEnvironments.includes(lastEnvironmentId)) {
|
if (lastEnvironmentId) {
|
||||||
|
// Redirect to the last environment the user was in
|
||||||
router.push(`/environments/${lastEnvironmentId}`);
|
router.push(`/environments/${lastEnvironmentId}`);
|
||||||
} else {
|
} else {
|
||||||
// If the last environmentId is not valid, remove it from localStorage and redirect to the provided environmentId
|
router.push(`/environments/${environmentId}`);
|
||||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
|
||||||
router.push(`/environments/${userEnvironments[0]}`);
|
|
||||||
}
|
}
|
||||||
}, [userEnvironments, router]);
|
}, [environmentId, router]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { CRON_SECRET } from "@/lib/constants";
|
import { CRON_SECRET } from "@/lib/constants";
|
||||||
import { env } from "@/lib/env";
|
|
||||||
import { captureTelemetry } from "@/lib/telemetry";
|
import { captureTelemetry } from "@/lib/telemetry";
|
||||||
import packageJson from "@/package.json";
|
import packageJson from "@/package.json";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
@@ -14,10 +13,6 @@ export const POST = async () => {
|
|||||||
return responses.notAuthenticatedResponse();
|
return responses.notAuthenticatedResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.TELEMETRY_DISABLED === "1") {
|
|
||||||
return responses.successResponse({}, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [surveyCount, responseCount, userCount] = await Promise.all([
|
const [surveyCount, responseCount, userCount] = await Promise.all([
|
||||||
prisma.survey.count(),
|
prisma.survey.count(),
|
||||||
prisma.response.count(),
|
prisma.response.count(),
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ describe("getEnvironmentState", () => {
|
|||||||
|
|
||||||
expect(withCache).toHaveBeenCalledWith(expect.any(Function), {
|
expect(withCache).toHaveBeenCalledWith(expect.any(Function), {
|
||||||
key: `fb:env:${environmentId}:state`,
|
key: `fb:env:${environmentId}:state`,
|
||||||
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
|
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ export const getEnvironmentState = async (
|
|||||||
{
|
{
|
||||||
// Use enterprise-grade cache key pattern
|
// Use enterprise-grade cache key pattern
|
||||||
key: createCacheKey.environment.state(environmentId),
|
key: createCacheKey.environment.state(environmentId),
|
||||||
// This is a temporary fix for the invalidation issues, will be changed later with a proper solution
|
// 30 minutes TTL ensures fresh data for hourly SDK checks
|
||||||
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
|
// Balances performance with freshness requirements
|
||||||
|
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { getDisplay } from "@/lib/display/service";
|
|
||||||
import { validateFileUploads } from "@/lib/fileValidation";
|
import { validateFileUploads } from "@/lib/fileValidation";
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
@@ -98,14 +97,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
return responses.badRequestResponse("Invalid file upload response");
|
return responses.badRequestResponse("Invalid file upload response");
|
||||||
}
|
}
|
||||||
|
|
||||||
// check display
|
|
||||||
if (responseInputData.displayId) {
|
|
||||||
const display = await getDisplay(responseInputData.displayId);
|
|
||||||
if (!display) {
|
|
||||||
return responses.notFoundResponse("Display", responseInputData.displayId, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: TResponse;
|
let response: TResponse;
|
||||||
try {
|
try {
|
||||||
const meta: TResponseInput["meta"] = {
|
const meta: TResponseInput["meta"] = {
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ export const POST = withApiLogging(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
|
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
|
||||||
|
const environmentId = actionClassInput.environmentId;
|
||||||
|
|
||||||
|
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||||
|
return {
|
||||||
|
response: responses.unauthorizedResponse(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!inputValidation.success) {
|
if (!inputValidation.success) {
|
||||||
return {
|
return {
|
||||||
response: responses.badRequestResponse(
|
response: responses.badRequestResponse(
|
||||||
@@ -62,14 +70,6 @@ export const POST = withApiLogging(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const environmentId = inputValidation.data.environmentId;
|
|
||||||
|
|
||||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
|
||||||
return {
|
|
||||||
response: responses.unauthorizedResponse(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
|
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
|
||||||
auditLog.targetId = actionClass.id;
|
auditLog.targetId = actionClass.id;
|
||||||
auditLog.newObject = actionClass;
|
auditLog.newObject = actionClass;
|
||||||
|
|||||||
@@ -186,18 +186,6 @@ describe("Response Lib Tests", () => {
|
|||||||
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
|
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle RelatedRecordDoesNotExist error with specific message", async () => {
|
|
||||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Related record does not exist", {
|
|
||||||
code: "P2025", // PrismaErrorType.RelatedRecordDoesNotExist
|
|
||||||
clientVersion: "2.0",
|
|
||||||
});
|
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
|
||||||
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
|
|
||||||
|
|
||||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
|
|
||||||
await expect(createResponse(mockResponseInput)).rejects.toThrow("Display ID does not exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle generic errors", async () => {
|
test("should handle generic errors", async () => {
|
||||||
const genericError = new Error("Something went wrong");
|
const genericError = new Error("Something went wrong");
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { validateInputs } from "@/lib/utils/validate";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { cache as reactCache } from "react";
|
import { cache as reactCache } from "react";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||||
@@ -177,9 +176,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
if (error.code === PrismaErrorType.RelatedRecordDoesNotExist) {
|
|
||||||
throw new DatabaseError("Display ID does not exist");
|
|
||||||
}
|
|
||||||
throw new DatabaseError(error.message);
|
throw new DatabaseError(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,10 +149,6 @@ export const POST = withApiLogging(
|
|||||||
return {
|
return {
|
||||||
response: responses.badRequestResponse(error.message),
|
response: responses.badRequestResponse(error.message),
|
||||||
};
|
};
|
||||||
} else if (error instanceof DatabaseError) {
|
|
||||||
return {
|
|
||||||
response: responses.badRequestResponse(error.message),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
|
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
|
||||||
return {
|
return {
|
||||||
@@ -162,7 +158,7 @@ export const POST = withApiLogging(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
return {
|
return {
|
||||||
response: responses.badRequestResponse("An unexpected error occurred while creating the response"),
|
response: responses.badRequestResponse(error.message),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
import { checkForRequiredFields } from "./utils";
|
||||||
|
import { describe, test, expect } from "vitest";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { Session } from "next-auth";
|
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { describe, expect, test } from "vitest";
|
import { Session } from "next-auth";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||||
import { checkForRequiredFields } from "./utils";
|
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||||
import { checkAuth } from "./utils";
|
import { checkAuth } from "./utils";
|
||||||
|
|
||||||
// Create mock response objects
|
// Create mock response objects
|
||||||
@@ -16,197 +16,189 @@ const mockNotAuthenticatedResponse = new Response("Not authenticated", { status:
|
|||||||
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
|
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
vi.mock("@/app/api/v1/auth", () => ({
|
vi.mock("@/app/api/v1/auth", () => ({
|
||||||
authenticateRequest: vi.fn(),
|
authenticateRequest: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/environment/auth", () => ({
|
vi.mock("@/lib/environment/auth", () => ({
|
||||||
hasUserEnvironmentAccess: vi.fn(),
|
hasUserEnvironmentAccess: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({
|
vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({
|
||||||
hasPermission: vi.fn(),
|
hasPermission: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/lib/api/response", () => ({
|
vi.mock("@/app/lib/api/response", () => ({
|
||||||
responses: {
|
responses: {
|
||||||
badRequestResponse: vi.fn(() => mockBadRequestResponse),
|
badRequestResponse: vi.fn(() => mockBadRequestResponse),
|
||||||
notAuthenticatedResponse: vi.fn(() => mockNotAuthenticatedResponse),
|
notAuthenticatedResponse: vi.fn(() => mockNotAuthenticatedResponse),
|
||||||
unauthorizedResponse: vi.fn(() => mockUnauthorizedResponse),
|
unauthorizedResponse: vi.fn(() => mockUnauthorizedResponse),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("checkForRequiredFields", () => {
|
describe("checkForRequiredFields", () => {
|
||||||
test("should return undefined when all required fields are present", () => {
|
test("should return undefined when all required fields are present", () => {
|
||||||
const result = checkForRequiredFields("env-123", "image/png", "test-file.png");
|
const result = checkForRequiredFields("env-123", "image/png", "test-file.png");
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return bad request response when environmentId is missing", () => {
|
test("should return bad request response when environmentId is missing", () => {
|
||||||
const result = checkForRequiredFields("", "image/png", "test-file.png");
|
const result = checkForRequiredFields("", "image/png", "test-file.png");
|
||||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
|
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
|
||||||
expect(result).toBe(mockBadRequestResponse);
|
expect(result).toBe(mockBadRequestResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return bad request response when fileType is missing", () => {
|
test("should return bad request response when fileType is missing", () => {
|
||||||
const result = checkForRequiredFields("env-123", "", "test-file.png");
|
const result = checkForRequiredFields("env-123", "", "test-file.png");
|
||||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
|
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
|
||||||
expect(result).toBe(mockBadRequestResponse);
|
expect(result).toBe(mockBadRequestResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return bad request response when encodedFileName is missing", () => {
|
test("should return bad request response when encodedFileName is missing", () => {
|
||||||
const result = checkForRequiredFields("env-123", "image/png", "");
|
const result = checkForRequiredFields("env-123", "image/png", "");
|
||||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
|
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
|
||||||
expect(result).toBe(mockBadRequestResponse);
|
expect(result).toBe(mockBadRequestResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return bad request response when environmentId is undefined", () => {
|
test("should return bad request response when environmentId is undefined", () => {
|
||||||
const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png");
|
const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png");
|
||||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
|
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
|
||||||
expect(result).toBe(mockBadRequestResponse);
|
expect(result).toBe(mockBadRequestResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return bad request response when fileType is undefined", () => {
|
test("should return bad request response when fileType is undefined", () => {
|
||||||
const result = checkForRequiredFields("env-123", undefined as any, "test-file.png");
|
const result = checkForRequiredFields("env-123", undefined as any, "test-file.png");
|
||||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
|
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
|
||||||
expect(result).toBe(mockBadRequestResponse);
|
expect(result).toBe(mockBadRequestResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return bad request response when encodedFileName is undefined", () => {
|
test("should return bad request response when encodedFileName is undefined", () => {
|
||||||
const result = checkForRequiredFields("env-123", "image/png", undefined as any);
|
const result = checkForRequiredFields("env-123", "image/png", undefined as any);
|
||||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
|
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
|
||||||
expect(result).toBe(mockBadRequestResponse);
|
expect(result).toBe(mockBadRequestResponse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("checkAuth", () => {
|
describe("checkAuth", () => {
|
||||||
const environmentId = "env-123";
|
const environmentId = "env-123";
|
||||||
const mockRequest = new NextRequest("http://localhost:3000/api/test");
|
const mockRequest = new NextRequest("http://localhost:3000/api/test");
|
||||||
|
|
||||||
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
|
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
|
||||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||||
|
|
||||||
const result = await checkAuth(null, environmentId, mockRequest);
|
const result = await checkAuth(null, environmentId, mockRequest);
|
||||||
|
|
||||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||||
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
|
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
|
||||||
expect(result).toBe(mockNotAuthenticatedResponse);
|
expect(result).toBe(mockNotAuthenticatedResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
|
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
|
||||||
const mockAuthentication: TAuthenticationApiKey = {
|
const mockAuthentication: TAuthenticationApiKey = {
|
||||||
type: "apiKey",
|
type: "apiKey",
|
||||||
environmentPermissions: [
|
environmentPermissions: [
|
||||||
{
|
{
|
||||||
environmentId: "env-123",
|
environmentId: "env-123",
|
||||||
permission: "read",
|
permission: "read",
|
||||||
environmentType: "development",
|
environmentType: "development",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
projectName: "Project 1",
|
projectName: "Project 1",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hashedApiKey: "hashed-key",
|
hashedApiKey: "hashed-key",
|
||||||
apiKeyId: "api-key-id",
|
apiKeyId: "api-key-id",
|
||||||
organizationId: "org-id",
|
organizationId: "org-id",
|
||||||
organizationAccess: {
|
organizationAccess: {
|
||||||
accessControl: {},
|
accessControl: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||||
vi.mocked(hasPermission).mockReturnValue(false);
|
vi.mocked(hasPermission).mockReturnValue(false);
|
||||||
|
|
||||||
const result = await checkAuth(null, environmentId, mockRequest);
|
const result = await checkAuth(null, environmentId, mockRequest);
|
||||||
|
|
||||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||||
expect(hasPermission).toHaveBeenCalledWith(
|
expect(hasPermission).toHaveBeenCalledWith(mockAuthentication.environmentPermissions, environmentId, "POST");
|
||||||
mockAuthentication.environmentPermissions,
|
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||||
environmentId,
|
expect(result).toBe(mockUnauthorizedResponse);
|
||||||
"POST"
|
});
|
||||||
);
|
|
||||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
|
||||||
expect(result).toBe(mockUnauthorizedResponse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns undefined when no session and authentication has POST permission", async () => {
|
test("returns undefined when no session and authentication has POST permission", async () => {
|
||||||
const mockAuthentication: TAuthenticationApiKey = {
|
const mockAuthentication: TAuthenticationApiKey = {
|
||||||
type: "apiKey",
|
type: "apiKey",
|
||||||
environmentPermissions: [
|
environmentPermissions: [
|
||||||
{
|
{
|
||||||
environmentId: "env-123",
|
environmentId: "env-123",
|
||||||
permission: "write",
|
permission: "write",
|
||||||
environmentType: "development",
|
environmentType: "development",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
projectName: "Project 1",
|
projectName: "Project 1",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hashedApiKey: "hashed-key",
|
hashedApiKey: "hashed-key",
|
||||||
apiKeyId: "api-key-id",
|
apiKeyId: "api-key-id",
|
||||||
organizationId: "org-id",
|
organizationId: "org-id",
|
||||||
organizationAccess: {
|
organizationAccess: {
|
||||||
accessControl: {},
|
accessControl: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||||
vi.mocked(hasPermission).mockReturnValue(true);
|
vi.mocked(hasPermission).mockReturnValue(true);
|
||||||
|
|
||||||
const result = await checkAuth(null, environmentId, mockRequest);
|
const result = await checkAuth(null, environmentId, mockRequest);
|
||||||
|
|
||||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||||
expect(hasPermission).toHaveBeenCalledWith(
|
expect(hasPermission).toHaveBeenCalledWith(mockAuthentication.environmentPermissions, environmentId, "POST");
|
||||||
mockAuthentication.environmentPermissions,
|
expect(result).toBeUndefined();
|
||||||
environmentId,
|
});
|
||||||
"POST"
|
|
||||||
);
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns unauthorizedResponse when session exists but user lacks environment access", async () => {
|
test("returns unauthorizedResponse when session exists but user lacks environment access", async () => {
|
||||||
const mockSession: Session = {
|
const mockSession: Session = {
|
||||||
user: {
|
user: {
|
||||||
id: "user-123",
|
id: "user-123",
|
||||||
},
|
},
|
||||||
expires: "2024-12-31T23:59:59.999Z",
|
expires: "2024-12-31T23:59:59.999Z",
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
|
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
|
||||||
|
|
||||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||||
|
|
||||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||||
expect(result).toBe(mockUnauthorizedResponse);
|
expect(result).toBe(mockUnauthorizedResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns undefined when session exists and user has environment access", async () => {
|
test("returns undefined when session exists and user has environment access", async () => {
|
||||||
const mockSession: Session = {
|
const mockSession: Session = {
|
||||||
user: {
|
user: {
|
||||||
id: "user-123",
|
id: "user-123",
|
||||||
},
|
},
|
||||||
expires: "2024-12-31T23:59:59.999Z",
|
expires: "2024-12-31T23:59:59.999Z",
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||||
|
|
||||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||||
|
|
||||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not call authenticateRequest when session exists", async () => {
|
test("does not call authenticateRequest when session exists", async () => {
|
||||||
const mockSession: Session = {
|
const mockSession: Session = {
|
||||||
user: {
|
user: {
|
||||||
id: "user-123",
|
id: "user-123",
|
||||||
},
|
},
|
||||||
expires: "2024-12-31T23:59:59.999Z",
|
expires: "2024-12-31T23:59:59.999Z",
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||||
|
|
||||||
await checkAuth(mockSession, environmentId, mockRequest);
|
await checkAuth(mockSession, environmentId, mockRequest);
|
||||||
|
|
||||||
expect(authenticateRequest).not.toHaveBeenCalled();
|
expect(authenticateRequest).not.toHaveBeenCalled();
|
||||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,41 +1,38 @@
|
|||||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
|
||||||
import { Session } from "next-auth";
|
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
import { Session } from "next-auth";
|
||||||
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
|
|
||||||
export const checkForRequiredFields = (
|
|
||||||
environmentId: string,
|
|
||||||
fileType: string,
|
|
||||||
encodedFileName: string
|
|
||||||
): Response | undefined => {
|
|
||||||
if (!environmentId) {
|
|
||||||
return responses.badRequestResponse("environmentId is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fileType) {
|
export const checkForRequiredFields = (environmentId: string, fileType: string, encodedFileName: string): Response | undefined => {
|
||||||
return responses.badRequestResponse("contentType is required");
|
if (!environmentId) {
|
||||||
}
|
return responses.badRequestResponse("environmentId is required");
|
||||||
|
}
|
||||||
|
|
||||||
if (!encodedFileName) {
|
if (!fileType) {
|
||||||
return responses.badRequestResponse("fileName is required");
|
return responses.badRequestResponse("contentType is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!encodedFileName) {
|
||||||
|
return responses.badRequestResponse("fileName is required");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => {
|
export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
//check whether its using API key
|
//check whether its using API key
|
||||||
const authentication = await authenticateRequest(request);
|
const authentication = await authenticateRequest(request);
|
||||||
if (!authentication) return responses.notAuthenticatedResponse();
|
if (!authentication) return responses.notAuthenticatedResponse();
|
||||||
|
|
||||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||||
return responses.unauthorizedResponse();
|
return responses.unauthorizedResponse();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||||
|
if (!isUserAuthorized) {
|
||||||
|
return responses.unauthorizedResponse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
};
|
||||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
|
||||||
if (!isUserAuthorized) {
|
|
||||||
return responses.unauthorizedResponse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// headers -> "Content-Type" should be present and set to a valid MIME type
|
// headers -> "Content-Type" should be present and set to a valid MIME type
|
||||||
// body -> should be a valid file object (buffer)
|
// body -> should be a valid file object (buffer)
|
||||||
// method -> PUT (to be the same as the signedUrl method)
|
// method -> PUT (to be the same as the signedUrl method)
|
||||||
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
|
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
|
||||||
import { validateLocalSignedUrl } from "@/lib/crypto";
|
import { validateLocalSignedUrl } from "@/lib/crypto";
|
||||||
@@ -11,6 +10,7 @@ import { authOptions } from "@/modules/auth/lib/authOptions";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
||||||
|
|
||||||
export const POST = async (req: NextRequest): Promise<Response> => {
|
export const POST = async (req: NextRequest): Promise<Response> => {
|
||||||
if (!ENCRYPTION_KEY) {
|
if (!ENCRYPTION_KEY) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { validateFile } from "@/lib/fileValidation";
|
import { validateFile } from "@/lib/fileValidation";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
@@ -6,6 +5,8 @@ import { getServerSession } from "next-auth";
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
|
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
|
||||||
|
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
||||||
|
|
||||||
|
|
||||||
// api endpoint for uploading public files
|
// api endpoint for uploading public files
|
||||||
// uploaded files will be public, anyone can access the file
|
// uploaded files will be public, anyone can access the file
|
||||||
@@ -13,6 +14,7 @@ import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
|
|||||||
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
|
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
|
||||||
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
|
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
|
||||||
|
|
||||||
|
|
||||||
export const POST = async (request: NextRequest): Promise<Response> => {
|
export const POST = async (request: NextRequest): Promise<Response> => {
|
||||||
let storageInput;
|
let storageInput;
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ export const POST = async (request: NextRequest): Promise<Response> => {
|
|||||||
const authResponse = await checkAuth(session, environmentId, request);
|
const authResponse = await checkAuth(session, environmentId, request);
|
||||||
if (authResponse) return authResponse;
|
if (authResponse) return authResponse;
|
||||||
|
|
||||||
|
|
||||||
// Perform server-side file validation first to block dangerous file types
|
// Perform server-side file validation first to block dangerous file types
|
||||||
const fileValidation = validateFile(fileName, fileType);
|
const fileValidation = validateFile(fileName, fileType);
|
||||||
if (!fileValidation.valid) {
|
if (!fileValidation.valid) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
@@ -42,10 +42,10 @@ export const GET = async (
|
|||||||
|
|
||||||
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
const surveyDomain = getSurveyDomain();
|
||||||
// map single use ids to survey links
|
// map single use ids to survey links
|
||||||
const surveyLinks = singleUseIds.map(
|
const surveyLinks = singleUseIds.map(
|
||||||
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
|
(singleUseId) => `${surveyDomain}/s/${survey.id}?suId=${singleUseId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return responses.successResponse(surveyLinks);
|
return responses.successResponse(surveyLinks);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { getDisplay } from "@/lib/display/service";
|
|
||||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||||
@@ -105,14 +104,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// check display
|
|
||||||
if (responseInputData.displayId) {
|
|
||||||
const display = await getDisplay(responseInputData.displayId);
|
|
||||||
if (!display) {
|
|
||||||
return responses.notFoundResponse("Display", responseInputData.displayId, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: TResponse;
|
let response: TResponse;
|
||||||
try {
|
try {
|
||||||
const meta: TResponseInputV2["meta"] = {
|
const meta: TResponseInputV2["meta"] = {
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
WEBAPP_URL: "test-webapp-url",
|
WEBAPP_URL: "test-webapp-url",
|
||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SENTRY_RELEASE: "mock-sentry-release",
|
|
||||||
SENTRY_ENVIRONMENT: "mock-sentry-environment",
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/tolgee/language", () => ({
|
vi.mock("@/tolgee/language", () => ({
|
||||||
@@ -61,18 +59,9 @@ vi.mock("@/tolgee/client", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/sentry/SentryProvider", () => ({
|
vi.mock("@/app/sentry/SentryProvider", () => ({
|
||||||
SentryProvider: ({
|
SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => (
|
||||||
children,
|
|
||||||
sentryDsn,
|
|
||||||
sentryRelease,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
sentryDsn?: string;
|
|
||||||
sentryRelease?: string;
|
|
||||||
}) => (
|
|
||||||
<div data-testid="sentry-provider">
|
<div data-testid="sentry-provider">
|
||||||
SentryProvider: {sentryDsn}
|
SentryProvider: {sentryDsn}
|
||||||
{sentryRelease && ` - Release: ${sentryRelease}`}
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
||||||
import { IS_PRODUCTION, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants";
|
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||||
import { TolgeeNextProvider } from "@/tolgee/client";
|
import { TolgeeNextProvider } from "@/tolgee/client";
|
||||||
import { getLocale } from "@/tolgee/language";
|
import { getLocale } from "@/tolgee/language";
|
||||||
import { getTolgee } from "@/tolgee/server";
|
import { getTolgee } from "@/tolgee/server";
|
||||||
@@ -25,11 +25,7 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
|||||||
return (
|
return (
|
||||||
<html lang={locale} translate="no">
|
<html lang={locale} translate="no">
|
||||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||||
<SentryProvider
|
<SentryProvider sentryDsn={SENTRY_DSN} isEnabled={IS_PRODUCTION}>
|
||||||
sentryDsn={SENTRY_DSN}
|
|
||||||
sentryRelease={SENTRY_RELEASE}
|
|
||||||
sentryEnvironment={SENTRY_ENVIRONMENT}
|
|
||||||
isEnabled={IS_PRODUCTION}>
|
|
||||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||||
{children}
|
{children}
|
||||||
</TolgeeNextProvider>
|
</TolgeeNextProvider>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe("Survey Builder", () => {
|
|||||||
buttonLabel: { default: "common.next" },
|
buttonLabel: { default: "common.next" },
|
||||||
backButtonLabel: { default: "common.back" },
|
backButtonLabel: { default: "common.back" },
|
||||||
shuffleOption: "none",
|
shuffleOption: "none",
|
||||||
required: false,
|
required: true,
|
||||||
});
|
});
|
||||||
expect(question.choices.length).toBe(3);
|
expect(question.choices.length).toBe(3);
|
||||||
expect(question.id).toBeDefined();
|
expect(question.id).toBeDefined();
|
||||||
@@ -141,7 +141,7 @@ describe("Survey Builder", () => {
|
|||||||
inputType: "text",
|
inputType: "text",
|
||||||
buttonLabel: { default: "common.next" },
|
buttonLabel: { default: "common.next" },
|
||||||
backButtonLabel: { default: "common.back" },
|
backButtonLabel: { default: "common.back" },
|
||||||
required: false,
|
required: true,
|
||||||
charLimit: {
|
charLimit: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
@@ -204,7 +204,7 @@ describe("Survey Builder", () => {
|
|||||||
range: 5,
|
range: 5,
|
||||||
buttonLabel: { default: "common.next" },
|
buttonLabel: { default: "common.next" },
|
||||||
backButtonLabel: { default: "common.back" },
|
backButtonLabel: { default: "common.back" },
|
||||||
required: false,
|
required: true,
|
||||||
isColorCodingEnabled: false,
|
isColorCodingEnabled: false,
|
||||||
});
|
});
|
||||||
expect(question.id).toBeDefined();
|
expect(question.id).toBeDefined();
|
||||||
@@ -265,7 +265,7 @@ describe("Survey Builder", () => {
|
|||||||
headline: { default: "NPS Question" },
|
headline: { default: "NPS Question" },
|
||||||
buttonLabel: { default: "common.next" },
|
buttonLabel: { default: "common.next" },
|
||||||
backButtonLabel: { default: "common.back" },
|
backButtonLabel: { default: "common.back" },
|
||||||
required: false,
|
required: true,
|
||||||
isColorCodingEnabled: false,
|
isColorCodingEnabled: false,
|
||||||
});
|
});
|
||||||
expect(question.id).toBeDefined();
|
expect(question.id).toBeDefined();
|
||||||
@@ -324,7 +324,7 @@ describe("Survey Builder", () => {
|
|||||||
label: { default: "I agree to terms" },
|
label: { default: "I agree to terms" },
|
||||||
buttonLabel: { default: "common.next" },
|
buttonLabel: { default: "common.next" },
|
||||||
backButtonLabel: { default: "common.back" },
|
backButtonLabel: { default: "common.back" },
|
||||||
required: false,
|
required: true,
|
||||||
});
|
});
|
||||||
expect(question.id).toBeDefined();
|
expect(question.id).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -377,7 +377,7 @@ describe("Survey Builder", () => {
|
|||||||
headline: { default: "CTA Question" },
|
headline: { default: "CTA Question" },
|
||||||
buttonLabel: { default: "common.next" },
|
buttonLabel: { default: "common.next" },
|
||||||
backButtonLabel: { default: "common.back" },
|
backButtonLabel: { default: "common.back" },
|
||||||
required: false,
|
required: true,
|
||||||
buttonExternal: false,
|
buttonExternal: false,
|
||||||
});
|
});
|
||||||
expect(question.id).toBeDefined();
|
expect(question.id).toBeDefined();
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const buildMultipleChoiceQuestion = ({
|
|||||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||||
shuffleOption: shuffleOption || "none",
|
shuffleOption: shuffleOption || "none",
|
||||||
required: required ?? false,
|
required: required ?? true,
|
||||||
logic,
|
logic,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -105,7 +105,7 @@ export const buildOpenTextQuestion = ({
|
|||||||
headline: createI18nString(headline, []),
|
headline: createI18nString(headline, []),
|
||||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||||
required: required ?? false,
|
required: required ?? true,
|
||||||
longAnswer,
|
longAnswer,
|
||||||
logic,
|
logic,
|
||||||
charLimit: {
|
charLimit: {
|
||||||
@@ -153,7 +153,7 @@ export const buildRatingQuestion = ({
|
|||||||
range,
|
range,
|
||||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||||
required: required ?? false,
|
required: required ?? true,
|
||||||
isColorCodingEnabled,
|
isColorCodingEnabled,
|
||||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||||
@@ -194,7 +194,7 @@ export const buildNPSQuestion = ({
|
|||||||
headline: createI18nString(headline, []),
|
headline: createI18nString(headline, []),
|
||||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||||
required: required ?? false,
|
required: required ?? true,
|
||||||
isColorCodingEnabled,
|
isColorCodingEnabled,
|
||||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||||
@@ -230,7 +230,7 @@ export const buildConsentQuestion = ({
|
|||||||
headline: createI18nString(headline, []),
|
headline: createI18nString(headline, []),
|
||||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||||
required: required ?? false,
|
required: required ?? true,
|
||||||
label: createI18nString(label, []),
|
label: createI18nString(label, []),
|
||||||
logic,
|
logic,
|
||||||
};
|
};
|
||||||
@@ -269,7 +269,7 @@ export const buildCTAQuestion = ({
|
|||||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||||
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
|
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
|
||||||
required: required ?? false,
|
required: required ?? true,
|
||||||
buttonExternal,
|
buttonExternal,
|
||||||
buttonUrl,
|
buttonUrl,
|
||||||
logic,
|
logic,
|
||||||
|
|||||||
@@ -3006,7 +3006,12 @@ const understandLowEngagement = (t: TFnType): TTemplate => {
|
|||||||
t("templates.understand_low_engagement_question_1_choice_4"),
|
t("templates.understand_low_engagement_question_1_choice_4"),
|
||||||
t("templates.understand_low_engagement_question_1_choice_5"),
|
t("templates.understand_low_engagement_question_1_choice_5"),
|
||||||
],
|
],
|
||||||
choiceIds: [reusableOptionIds[0], reusableOptionIds[1], reusableOptionIds[2], reusableOptionIds[3]],
|
choiceIds: [
|
||||||
|
reusableOptionIds[0],
|
||||||
|
reusableOptionIds[1],
|
||||||
|
reusableOptionIds[2],
|
||||||
|
reusableOptionIds[3],
|
||||||
|
],
|
||||||
headline: t("templates.understand_low_engagement_question_1_headline"),
|
headline: t("templates.understand_low_engagement_question_1_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
containsOther: true,
|
containsOther: true,
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { NextRequest } from "next/server";
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { getPublicDomainHost, isPublicDomainConfigured, isRequestFromPublicDomain } from "./domain-utils";
|
|
||||||
|
|
||||||
// Mock the env module
|
|
||||||
vi.mock("@/lib/env", () => ({
|
|
||||||
env: {
|
|
||||||
get PUBLIC_URL() {
|
|
||||||
return process.env.PUBLIC_URL || "";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Domain Utils", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.PUBLIC_URL = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getPublicDomain", () => {
|
|
||||||
test("should return null when PUBLIC_URL is empty", () => {
|
|
||||||
expect(getPublicDomainHost()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return the host from a valid PUBLIC_URL", () => {
|
|
||||||
process.env.PUBLIC_URL = "https://example.com";
|
|
||||||
expect(getPublicDomainHost()).toBe("example.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle URLs with paths", () => {
|
|
||||||
process.env.PUBLIC_URL = "https://example.com/path";
|
|
||||||
expect(getPublicDomainHost()).toBe("example.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle URLs with ports", () => {
|
|
||||||
process.env.PUBLIC_URL = "https://example.com:3000";
|
|
||||||
expect(getPublicDomainHost()).toBe("example.com:3000");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isPublicDomainConfigured", () => {
|
|
||||||
test("should return false when PUBLIC_URL is empty", () => {
|
|
||||||
process.env.PUBLIC_URL = "";
|
|
||||||
expect(isPublicDomainConfigured()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return true when PUBLIC_URL is valid", () => {
|
|
||||||
process.env.PUBLIC_URL = "https://example.com";
|
|
||||||
expect(isPublicDomainConfigured()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isRequestFromPublicDomain", () => {
|
|
||||||
test("should return false when public domain is not configured", () => {
|
|
||||||
process.env.PUBLIC_URL = "";
|
|
||||||
const request = new NextRequest("https://example.com");
|
|
||||||
expect(isRequestFromPublicDomain(request)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return false when host doesn't match public domain", () => {
|
|
||||||
process.env.PUBLIC_URL = "https://example.com";
|
|
||||||
const request = new NextRequest("https://different-domain.com");
|
|
||||||
expect(isRequestFromPublicDomain(request)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return true when host matches public domain", () => {
|
|
||||||
process.env.PUBLIC_URL = "https://example.com";
|
|
||||||
const request = new NextRequest("https://example.com", {
|
|
||||||
headers: {
|
|
||||||
host: "example.com",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(isRequestFromPublicDomain(request)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle domains with ports", () => {
|
|
||||||
process.env.PUBLIC_URL = "https://example.com:3000";
|
|
||||||
const request = new NextRequest("https://example.com:3000", {
|
|
||||||
headers: {
|
|
||||||
host: "example.com:3000",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(isRequestFromPublicDomain(request)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { env } from "@/lib/env";
|
|
||||||
import { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the public domain from PUBLIC_URL environment variable
|
|
||||||
*/
|
|
||||||
export const getPublicDomainHost = (): string | null => {
|
|
||||||
const PUBLIC_URL = env.PUBLIC_URL;
|
|
||||||
if (!PUBLIC_URL) return null;
|
|
||||||
|
|
||||||
return new URL(PUBLIC_URL).host;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if PUBLIC_URL is configured (has a valid public domain)
|
|
||||||
*/
|
|
||||||
export const isPublicDomainConfigured = (): boolean => {
|
|
||||||
return getPublicDomainHost() !== null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current request is coming from the public domain
|
|
||||||
*/
|
|
||||||
export const isRequestFromPublicDomain = (request: NextRequest): boolean => {
|
|
||||||
const host = request.headers.get("host");
|
|
||||||
const publicDomainHost = getPublicDomainHost();
|
|
||||||
|
|
||||||
if (!publicDomainHost) return false;
|
|
||||||
|
|
||||||
return host === publicDomainHost;
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
isAdminDomainRoute,
|
|
||||||
isAuthProtectedRoute,
|
isAuthProtectedRoute,
|
||||||
isClientSideApiRoute,
|
isClientSideApiRoute,
|
||||||
isForgotPasswordRoute,
|
isForgotPasswordRoute,
|
||||||
isLoginRoute,
|
isLoginRoute,
|
||||||
isManagementApiRoute,
|
isManagementApiRoute,
|
||||||
isPublicDomainRoute,
|
|
||||||
isRouteAllowedForDomain,
|
|
||||||
isShareUrlRoute,
|
isShareUrlRoute,
|
||||||
isSignupRoute,
|
isSignupRoute,
|
||||||
isSyncWithUserIdentificationEndpoint,
|
isSyncWithUserIdentificationEndpoint,
|
||||||
@@ -72,9 +69,6 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isClientSideApiRoute("/api/v1/management/something")).toBe(false);
|
expect(isClientSideApiRoute("/api/v1/management/something")).toBe(false);
|
||||||
expect(isClientSideApiRoute("/api/something")).toBe(false);
|
expect(isClientSideApiRoute("/api/something")).toBe(false);
|
||||||
expect(isClientSideApiRoute("/auth/login")).toBe(false);
|
expect(isClientSideApiRoute("/auth/login")).toBe(false);
|
||||||
|
|
||||||
// exception for open graph image generation route, it should not be rate limited
|
|
||||||
expect(isClientSideApiRoute("/api/v1/client/og")).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,138 +136,4 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
|
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isPublicDomainRoute", () => {
|
|
||||||
test("should return true for health endpoint", () => {
|
|
||||||
expect(isPublicDomainRoute("/health")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Static assets are not handled by domain routing - middleware doesn't run on them
|
|
||||||
|
|
||||||
test("should return true for survey routes", () => {
|
|
||||||
expect(isPublicDomainRoute("/s/survey123")).toBe(true);
|
|
||||||
expect(isPublicDomainRoute("/s/survey-id-with-dashes")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return true for contact survey routes", () => {
|
|
||||||
expect(isPublicDomainRoute("/c/jwt-token")).toBe(true);
|
|
||||||
expect(isPublicDomainRoute("/c/very-long-jwt-token-123")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return true for client API routes", () => {
|
|
||||||
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
|
||||||
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return true for share routes", () => {
|
|
||||||
expect(isPublicDomainRoute("/share/abc123/summary")).toBe(true);
|
|
||||||
expect(isPublicDomainRoute("/share/xyz789/responses")).toBe(true);
|
|
||||||
expect(isPublicDomainRoute("/share/anything")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return false for admin-only routes", () => {
|
|
||||||
expect(isPublicDomainRoute("/")).toBe(false);
|
|
||||||
expect(isPublicDomainRoute("/environments/123")).toBe(false);
|
|
||||||
expect(isPublicDomainRoute("/auth/login")).toBe(false);
|
|
||||||
expect(isPublicDomainRoute("/setup/organization")).toBe(false);
|
|
||||||
expect(isPublicDomainRoute("/organizations/123")).toBe(false);
|
|
||||||
expect(isPublicDomainRoute("/product/settings")).toBe(false);
|
|
||||||
expect(isPublicDomainRoute("/api/v1/management/users")).toBe(false);
|
|
||||||
expect(isPublicDomainRoute("/api/v2/management/surveys")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isAdminDomainRoute", () => {
|
|
||||||
test("should return true for health endpoint (backward compatibility)", () => {
|
|
||||||
expect(isAdminDomainRoute("/health")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/health")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Static assets are not handled by domain routing - middleware doesn't run on them
|
|
||||||
|
|
||||||
test("should return true for admin routes", () => {
|
|
||||||
expect(isAdminDomainRoute("/")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/environments/123")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/environments/123/surveys")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/auth/login")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/auth/signup")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/setup/organization")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/setup/team")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/organizations/123")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/organizations/123/settings")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/product/settings")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/product/features")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/api/v1/management/users")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/api/v2/management/surveys")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/pipeline/jobs")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/cron/tasks")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/random/route")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("/s/survey123")).toBe(false);
|
|
||||||
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
|
||||||
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isRouteAllowedForDomain", () => {
|
|
||||||
test("should allow public routes on public domain", () => {
|
|
||||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
|
||||||
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
|
||||||
expect(isRouteAllowedForDomain("/share/abc/summary", true)).toBe(true);
|
|
||||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
|
||||||
// Static assets not tested - middleware doesn't run on them
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should block admin routes on public domain", () => {
|
|
||||||
expect(isRouteAllowedForDomain("/", true)).toBe(false);
|
|
||||||
expect(isRouteAllowedForDomain("/environments/123", true)).toBe(false);
|
|
||||||
expect(isRouteAllowedForDomain("/auth/login", true)).toBe(false);
|
|
||||||
expect(isRouteAllowedForDomain("/api/v1/management/users", true)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should block public routes on admin domain when PUBLIC_URL is configured", () => {
|
|
||||||
// Admin routes should be allowed
|
|
||||||
expect(isRouteAllowedForDomain("/", false)).toBe(true);
|
|
||||||
expect(isRouteAllowedForDomain("/environments/123", false)).toBe(true);
|
|
||||||
expect(isRouteAllowedForDomain("/auth/login", false)).toBe(true);
|
|
||||||
expect(isRouteAllowedForDomain("/api/v1/management/users", false)).toBe(true);
|
|
||||||
expect(isRouteAllowedForDomain("/health", false)).toBe(true);
|
|
||||||
expect(isRouteAllowedForDomain("/pipeline/jobs", false)).toBe(true);
|
|
||||||
expect(isRouteAllowedForDomain("/cron/tasks", false)).toBe(true);
|
|
||||||
|
|
||||||
// Public routes should be blocked on admin domain
|
|
||||||
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
|
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
|
||||||
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
|
||||||
expect(isRouteAllowedForDomain("/share/abc/summary", false)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
|
||||||
test("should handle empty paths", () => {
|
|
||||||
expect(isPublicDomainRoute("")).toBe(false);
|
|
||||||
expect(isAdminDomainRoute("")).toBe(true);
|
|
||||||
expect(isAdminDomainRoute("")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle paths with query parameters", () => {
|
|
||||||
expect(isPublicDomainRoute("/s/survey123?param=value")).toBe(true);
|
|
||||||
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle paths with fragments", () => {
|
|
||||||
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
|
||||||
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle nested survey routes", () => {
|
|
||||||
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
|
||||||
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle nested client API routes", () => {
|
|
||||||
expect(isPublicDomainRoute("/api/v1/client/env123/actions")).toBe(true);
|
|
||||||
expect(isPublicDomainRoute("/api/v2/client/env456/responses")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import {
|
|
||||||
getAllPubliclyAccessibleRoutePatterns,
|
|
||||||
getPublicDomainRoutePatterns,
|
|
||||||
matchesAnyPattern,
|
|
||||||
} from "./route-config";
|
|
||||||
|
|
||||||
export const isLoginRoute = (url: string) =>
|
export const isLoginRoute = (url: string) =>
|
||||||
url === "/api/auth/callback/credentials" || url === "/auth/login";
|
url === "/api/auth/callback/credentials" || url === "/auth/login";
|
||||||
|
|
||||||
@@ -14,9 +8,6 @@ export const isVerifyEmailRoute = (url: string) => url === "/auth/verify-email";
|
|||||||
export const isForgotPasswordRoute = (url: string) => url === "/auth/forgot-password";
|
export const isForgotPasswordRoute = (url: string) => url === "/auth/forgot-password";
|
||||||
|
|
||||||
export const isClientSideApiRoute = (url: string): boolean => {
|
export const isClientSideApiRoute = (url: string): boolean => {
|
||||||
// Open Graph image generation route is a client side API route but it should not be rate limited
|
|
||||||
if (url.includes("/api/v1/client/og")) return false;
|
|
||||||
|
|
||||||
if (url.includes("/api/v1/js/actions")) return true;
|
if (url.includes("/api/v1/js/actions")) return true;
|
||||||
if (url.includes("/api/v1/client/storage")) return true;
|
if (url.includes("/api/v1/client/storage")) return true;
|
||||||
const regex = /^\/api\/v\d+\/client\//;
|
const regex = /^\/api\/v\d+\/client\//;
|
||||||
@@ -47,39 +38,3 @@ export const isSyncWithUserIdentificationEndpoint = (
|
|||||||
const match = url.match(regex);
|
const match = url.match(regex);
|
||||||
return match ? { environmentId: match.groups!.environmentId, userId: match.groups!.userId } : false;
|
return match ? { environmentId: match.groups!.environmentId, userId: match.groups!.userId } : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the route should be accessible on the public domain (PUBLIC_URL)
|
|
||||||
* Uses whitelist approach - only explicitly allowed routes are accessible
|
|
||||||
*/
|
|
||||||
export const isPublicDomainRoute = (url: string): boolean => {
|
|
||||||
const publicRoutePatterns = getAllPubliclyAccessibleRoutePatterns();
|
|
||||||
return matchesAnyPattern(url, publicRoutePatterns);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the route should be accessible on the admin domain (WEBAPP_URL)
|
|
||||||
* When PUBLIC_URL is configured, admin domain should only allow admin-specific routes + health
|
|
||||||
*/
|
|
||||||
export const isAdminDomainRoute = (url: string): boolean => {
|
|
||||||
const publicOnlyRoutePatterns = getPublicDomainRoutePatterns();
|
|
||||||
const isPublicRoute = matchesAnyPattern(url, publicOnlyRoutePatterns);
|
|
||||||
|
|
||||||
if (isPublicRoute) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-public routes, allow them (includes known admin routes and unknown routes like pipeline, cron)
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if a request should be allowed based on domain and route
|
|
||||||
*/
|
|
||||||
export const isRouteAllowedForDomain = (url: string, isPublicDomain: boolean): boolean => {
|
|
||||||
if (isPublicDomain) {
|
|
||||||
return isPublicDomainRoute(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return isAdminDomainRoute(url);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Routes that should be accessible on the public domain (PUBLIC_URL)
|
|
||||||
* Uses whitelist approach - only these routes are allowed on public domain
|
|
||||||
*/
|
|
||||||
const PUBLIC_ROUTES = {
|
|
||||||
// Survey routes
|
|
||||||
SURVEY_ROUTES: [
|
|
||||||
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
|
||||||
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
|
||||||
],
|
|
||||||
|
|
||||||
// API routes accessible from public domain
|
|
||||||
API_ROUTES: [
|
|
||||||
/^\/api\/v[12]\/client\//, // /api/v1/client/** and /api/v2/client/**
|
|
||||||
],
|
|
||||||
|
|
||||||
// Share routes
|
|
||||||
SHARE_ROUTES: [
|
|
||||||
/^\/share\//, // /share/** - shared survey results
|
|
||||||
],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const COMMON_ROUTES = {
|
|
||||||
HEALTH_ROUTES: [/^\/health$/], // /health endpoint
|
|
||||||
PUBLIC_STORAGE_ROUTES: [
|
|
||||||
/^\/storage\/[^/]+\/public\//, // /storage/[environmentId]/public/** - public storage
|
|
||||||
],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get public only route patterns as a flat array
|
|
||||||
*/
|
|
||||||
export const getPublicDomainRoutePatterns = (): RegExp[] => {
|
|
||||||
return Object.values(PUBLIC_ROUTES).flat();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all public route patterns as a flat array
|
|
||||||
*/
|
|
||||||
export const getAllPubliclyAccessibleRoutePatterns = (): RegExp[] => {
|
|
||||||
const routes = {
|
|
||||||
...PUBLIC_ROUTES,
|
|
||||||
...COMMON_ROUTES,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Object.values(routes).flat();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a URL matches any of the given route patterns
|
|
||||||
*/
|
|
||||||
export const matchesAnyPattern = (url: string, patterns: RegExp[]): boolean => {
|
|
||||||
return patterns.some((pattern) => pattern.test(url));
|
|
||||||
};
|
|
||||||
+13
-72
@@ -3,12 +3,12 @@ import { cleanup } from "@testing-library/react";
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { TMembership } from "@formbricks/types/memberships";
|
import { TMembership } from "@formbricks/types/memberships";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import Page from "./page";
|
import Page from "./page";
|
||||||
|
|
||||||
vi.mock("@/lib/project/service", () => ({
|
// Mock dependencies
|
||||||
getUserProjectEnvironmentsByOrganizationIds: vi.fn(),
|
vi.mock("@/lib/environment/service", () => ({
|
||||||
|
getFirstEnvironmentIdByUserId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/instance/service", () => ({
|
vi.mock("@/lib/instance/service", () => ({
|
||||||
@@ -48,11 +48,8 @@ vi.mock("@/modules/ui/components/client-logout", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/ClientEnvironmentRedirect", () => ({
|
vi.mock("@/app/ClientEnvironmentRedirect", () => ({
|
||||||
default: ({ environmentId, userEnvironments }: { environmentId: string; userEnvironments?: string[] }) => (
|
default: ({ environmentId }: { environmentId: string }) => (
|
||||||
<div data-testid="client-environment-redirect">
|
<div data-testid="client-environment-redirect">Environment ID: {environmentId}</div>
|
||||||
Environment ID: {environmentId}
|
|
||||||
{userEnvironments && ` | User Environments: ${userEnvironments.join(", ")}`}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -152,7 +149,7 @@ describe("Page", () => {
|
|||||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||||
const { getUser } = await import("@/lib/user/service");
|
const { getUser } = await import("@/lib/user/service");
|
||||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||||
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
|
||||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||||
const { redirect } = await import("next/navigation");
|
const { redirect } = await import("next/navigation");
|
||||||
@@ -207,23 +204,13 @@ describe("Page", () => {
|
|||||||
role: "owner",
|
role: "owner",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockUserProjects = [
|
|
||||||
{
|
|
||||||
id: "test-project-id",
|
|
||||||
name: "Test Project",
|
|
||||||
environments: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue({
|
vi.mocked(getServerSession).mockResolvedValue({
|
||||||
user: { id: "test-user-id" },
|
user: { id: "test-user-id" },
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||||
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
|
|
||||||
mockUserProjects as unknown as TProject[]
|
|
||||||
);
|
|
||||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||||
|
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||||
vi.mocked(getAccessFlags).mockReturnValue({
|
vi.mocked(getAccessFlags).mockReturnValue({
|
||||||
isManager: false,
|
isManager: false,
|
||||||
@@ -241,8 +228,8 @@ describe("Page", () => {
|
|||||||
const { getServerSession } = await import("next-auth");
|
const { getServerSession } = await import("next-auth");
|
||||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||||
const { getUser } = await import("@/lib/user/service");
|
const { getUser } = await import("@/lib/user/service");
|
||||||
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
|
||||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||||
|
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
|
||||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||||
const { redirect } = await import("next/navigation");
|
const { redirect } = await import("next/navigation");
|
||||||
@@ -297,23 +284,13 @@ describe("Page", () => {
|
|||||||
role: "member",
|
role: "member",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockUserProjects = [
|
|
||||||
{
|
|
||||||
id: "test-project-id",
|
|
||||||
name: "Test Project",
|
|
||||||
environments: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue({
|
vi.mocked(getServerSession).mockResolvedValue({
|
||||||
user: { id: "test-user-id" },
|
user: { id: "test-user-id" },
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||||
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
|
|
||||||
mockUserProjects as unknown as TProject[]
|
|
||||||
);
|
|
||||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||||
|
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||||
vi.mocked(getAccessFlags).mockReturnValue({
|
vi.mocked(getAccessFlags).mockReturnValue({
|
||||||
isManager: false,
|
isManager: false,
|
||||||
@@ -332,9 +309,9 @@ describe("Page", () => {
|
|||||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||||
const { getUser } = await import("@/lib/user/service");
|
const { getUser } = await import("@/lib/user/service");
|
||||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||||
|
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
|
||||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||||
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
|
||||||
const { render } = await import("@testing-library/react");
|
const { render } = await import("@testing-library/react");
|
||||||
|
|
||||||
const mockUser: TUser = {
|
const mockUser: TUser = {
|
||||||
@@ -387,43 +364,7 @@ describe("Page", () => {
|
|||||||
role: "member",
|
role: "member",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockUserProjects = [
|
const mockEnvironmentId = "test-env-id";
|
||||||
{
|
|
||||||
id: "project-1",
|
|
||||||
name: "Test Project",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
organizationId: "test-org-id",
|
|
||||||
styling: { allowStyleOverwrite: true },
|
|
||||||
recontactDays: 0,
|
|
||||||
inAppSurveyBranding: false,
|
|
||||||
linkSurveyBranding: false,
|
|
||||||
config: { channel: "link" as const, industry: "saas" as const },
|
|
||||||
placement: "bottomRight" as const,
|
|
||||||
clickOutsideClose: false,
|
|
||||||
darkOverlay: false,
|
|
||||||
languages: [],
|
|
||||||
logo: null,
|
|
||||||
environments: [
|
|
||||||
{
|
|
||||||
id: "test-env-id",
|
|
||||||
type: "production" as const,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
projectId: "project-1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "test-env-dev",
|
|
||||||
type: "development" as const,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
projectId: "project-1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
] as any;
|
|
||||||
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue({
|
vi.mocked(getServerSession).mockResolvedValue({
|
||||||
user: { id: "test-user-id" },
|
user: { id: "test-user-id" },
|
||||||
@@ -431,8 +372,8 @@ describe("Page", () => {
|
|||||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||||
|
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(mockEnvironmentId);
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||||
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects);
|
|
||||||
vi.mocked(getAccessFlags).mockReturnValue({
|
vi.mocked(getAccessFlags).mockReturnValue({
|
||||||
isManager: false,
|
isManager: false,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
@@ -444,7 +385,7 @@ describe("Page", () => {
|
|||||||
const { container } = render(result);
|
const { container } = render(result);
|
||||||
|
|
||||||
expect(container.querySelector('[data-testid="client-environment-redirect"]')).toHaveTextContent(
|
expect(container.querySelector('[data-testid="client-environment-redirect"]')).toHaveTextContent(
|
||||||
`User Environments: test-env-id, test-env-dev`
|
`Environment ID: ${mockEnvironmentId}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-29
@@ -1,9 +1,9 @@
|
|||||||
import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect";
|
import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect";
|
||||||
|
import { getFirstEnvironmentIdByUserId } from "@/lib/environment/service";
|
||||||
import { getIsFreshInstance } from "@/lib/instance/service";
|
import { getIsFreshInstance } from "@/lib/instance/service";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||||
import { getUserProjectEnvironmentsByOrganizationIds } from "@/lib/project/service";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||||
@@ -34,37 +34,16 @@ const Page = async () => {
|
|||||||
return redirect("/setup/organization/create");
|
return redirect("/setup/organization/create");
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectsByOrg = await getUserProjectEnvironmentsByOrganizationIds(
|
let environmentId: string | null = null;
|
||||||
userOrganizations.map((org) => org.id),
|
environmentId = await getFirstEnvironmentIdByUserId(session.user.id);
|
||||||
user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
// Flatten all environments from all projects across all organizations
|
|
||||||
const allEnvironments = projectsByOrg.flatMap((project) => project.environments);
|
|
||||||
|
|
||||||
// Find first production environment and collect all other environment IDs in one pass
|
|
||||||
const { firstProductionEnvironmentId, otherEnvironmentIds } = allEnvironments.reduce(
|
|
||||||
(acc, env) => {
|
|
||||||
if (env.type === "production" && !acc.firstProductionEnvironmentId) {
|
|
||||||
acc.firstProductionEnvironmentId = env.id;
|
|
||||||
} else {
|
|
||||||
acc.otherEnvironmentIds.add(env.id);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ firstProductionEnvironmentId: null as string | null, otherEnvironmentIds: new Set<string>() }
|
|
||||||
);
|
|
||||||
|
|
||||||
const userEnvironments = [...otherEnvironmentIds];
|
|
||||||
|
|
||||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
||||||
session.user.id,
|
session.user.id,
|
||||||
userOrganizations[0].id
|
userOrganizations[0].id
|
||||||
);
|
);
|
||||||
|
|
||||||
const { isManager, isOwner } = getAccessFlags(currentUserMembership?.role);
|
const { isManager, isOwner } = getAccessFlags(currentUserMembership?.role);
|
||||||
|
|
||||||
if (!firstProductionEnvironmentId) {
|
if (!environmentId) {
|
||||||
if (isOwner || isManager) {
|
if (isOwner || isManager) {
|
||||||
return redirect(`/organizations/${userOrganizations[0].id}/projects/new/mode`);
|
return redirect(`/organizations/${userOrganizations[0].id}/projects/new/mode`);
|
||||||
} else {
|
} else {
|
||||||
@@ -72,10 +51,7 @@ const Page = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put the first production environment at the front of the array
|
return <ClientEnvironmentRedirect environmentId={environmentId} />;
|
||||||
const sortedUserEnvironments = [firstProductionEnvironmentId, ...userEnvironments];
|
|
||||||
|
|
||||||
return <ClientEnvironmentRedirect userEnvironments={sortedUserEnvironments} />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { LinkSurveyNotFound } from "@/modules/survey/link/not-found";
|
import { LinkSurveyNotFound } from "@/modules/survey/link/not-found";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default LinkSurveyNotFound;
|
||||||
return <LinkSurveyNotFound />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,24 +48,6 @@ describe("SentryProvider", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("calls Sentry.init with sentryRelease when provided", () => {
|
|
||||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
|
||||||
const testRelease = "v1.2.3";
|
|
||||||
|
|
||||||
render(
|
|
||||||
<SentryProvider sentryDsn={sentryDsn} sentryRelease={testRelease} isEnabled>
|
|
||||||
<div data-testid="child">Test Content</div>
|
|
||||||
</SentryProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(initSpy).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
dsn: sentryDsn,
|
|
||||||
release: testRelease,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not call Sentry.init when sentryDsn is not provided", () => {
|
test("does not call Sentry.init when sentryDsn is not provided", () => {
|
||||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -6,24 +6,14 @@ import { useEffect } from "react";
|
|||||||
interface SentryProviderProps {
|
interface SentryProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
sentryDsn?: string;
|
sentryDsn?: string;
|
||||||
sentryRelease?: string;
|
|
||||||
sentryEnvironment?: string;
|
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SentryProvider = ({
|
export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProviderProps) => {
|
||||||
children,
|
|
||||||
sentryDsn,
|
|
||||||
sentryRelease,
|
|
||||||
sentryEnvironment,
|
|
||||||
isEnabled,
|
|
||||||
}: SentryProviderProps) => {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sentryDsn && isEnabled) {
|
if (sentryDsn && isEnabled) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: sentryDsn,
|
dsn: sentryDsn,
|
||||||
release: sentryRelease,
|
|
||||||
environment: sentryEnvironment,
|
|
||||||
|
|
||||||
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
||||||
tracesSampleRate: 0,
|
tracesSampleRate: 0,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||||
@@ -47,7 +46,6 @@ const Page = async (props: ResponsesPageProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
const publicDomain = getPublicDomain();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full justify-center">
|
<div className="flex w-full justify-center">
|
||||||
@@ -59,7 +57,7 @@ const Page = async (props: ResponsesPageProps) => {
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
surveyId={surveyId}
|
surveyId={surveyId}
|
||||||
publicDomain={publicDomain}
|
webAppUrl={WEBAPP_URL}
|
||||||
environmentTags={tags}
|
environmentTags={tags}
|
||||||
responsesPerPage={RESPONSES_PER_PAGE}
|
responsesPerPage={RESPONSES_PER_PAGE}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
@@ -51,8 +50,6 @@ const Page = async (props: SummaryPageProps) => {
|
|||||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full justify-center">
|
<div className="flex w-full justify-center">
|
||||||
<PageContentWrapper className="w-full">
|
<PageContentWrapper className="w-full">
|
||||||
@@ -63,7 +60,7 @@ const Page = async (props: SummaryPageProps) => {
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
surveyId={survey.id}
|
surveyId={survey.id}
|
||||||
publicDomain={publicDomain}
|
webAppUrl={WEBAPP_URL}
|
||||||
isReadOnly={true}
|
isReadOnly={true}
|
||||||
locale={DEFAULT_LOCALE}
|
locale={DEFAULT_LOCALE}
|
||||||
initialSurveySummary={initialSurveySummary}
|
initialSurveySummary={initialSurveySummary}
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import {
|
|||||||
ZIntegrationAirtableTokenSchema,
|
ZIntegrationAirtableTokenSchema,
|
||||||
} from "@formbricks/types/integration/airtable";
|
} from "@formbricks/types/integration/airtable";
|
||||||
import { AIRTABLE_CLIENT_ID, AIRTABLE_MESSAGE_LIMIT } from "../constants";
|
import { AIRTABLE_CLIENT_ID, AIRTABLE_MESSAGE_LIMIT } from "../constants";
|
||||||
import { createOrUpdateIntegration, getIntegrationByType } from "../integration/service";
|
import { createOrUpdateIntegration, deleteIntegration, getIntegrationByType } from "../integration/service";
|
||||||
import { delay } from "../utils/promises";
|
|
||||||
import { truncateText } from "../utils/strings";
|
import { truncateText } from "../utils/strings";
|
||||||
|
|
||||||
export const getBases = async (key: string) => {
|
export const getBases = async (key: string) => {
|
||||||
@@ -100,11 +99,7 @@ export const getAirtableToken = async (environmentId: string) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!newToken) {
|
if (!newToken) {
|
||||||
logger.error("Failed to fetch new Airtable token", {
|
throw new Error("Failed to create new token");
|
||||||
environmentId,
|
|
||||||
airtableIntegration,
|
|
||||||
});
|
|
||||||
throw new Error("Failed to fetch new Airtable token");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await createOrUpdateIntegration(environmentId, {
|
await createOrUpdateIntegration(environmentId, {
|
||||||
@@ -121,11 +116,9 @@ export const getAirtableToken = async (environmentId: string) => {
|
|||||||
|
|
||||||
return access_token;
|
return access_token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to get Airtable token", {
|
await deleteIntegration(environmentId);
|
||||||
environmentId,
|
|
||||||
error,
|
throw new Error("invalid token");
|
||||||
});
|
|
||||||
throw new Error("Failed to get Airtable token");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -185,18 +178,6 @@ const addField = async (
|
|||||||
return await req.json();
|
return await req.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getExistingFields = async (key: TIntegrationAirtableCredential, baseId: string, tableId: string) => {
|
|
||||||
const req = await tableFetcher(key, baseId);
|
|
||||||
const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables;
|
|
||||||
const currentTable = tables.find((t) => t.id === tableId);
|
|
||||||
|
|
||||||
if (!currentTable) {
|
|
||||||
throw new Error(`Table with ID ${tableId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Set(currentTable.fields.map((f) => f.name));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const writeData = async (
|
export const writeData = async (
|
||||||
key: TIntegrationAirtableCredential,
|
key: TIntegrationAirtableCredential,
|
||||||
configData: TIntegrationAirtableConfigData,
|
configData: TIntegrationAirtableConfigData,
|
||||||
@@ -205,7 +186,6 @@ export const writeData = async (
|
|||||||
const responses = values[0];
|
const responses = values[0];
|
||||||
const questions = values[1];
|
const questions = values[1];
|
||||||
|
|
||||||
// 1) Build the record payload
|
|
||||||
const data: Record<string, string> = {};
|
const data: Record<string, string> = {};
|
||||||
for (let i = 0; i < questions.length; i++) {
|
for (let i = 0; i < questions.length; i++) {
|
||||||
data[questions[i]] =
|
data[questions[i]] =
|
||||||
@@ -214,73 +194,34 @@ export const writeData = async (
|
|||||||
: responses[i];
|
: responses[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Figure out which fields need creating
|
const req = await tableFetcher(key, configData.baseId);
|
||||||
const existingFields = await getExistingFields(key, configData.baseId, configData.tableId);
|
const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables;
|
||||||
const fieldsToCreate = questions.filter((q) => !existingFields.has(q));
|
|
||||||
|
|
||||||
// 3) Create any missing fields with throttling to respect Airtable's 5 req/sec per base limit
|
const currentTable = tables.find((table) => table.id === configData.tableId);
|
||||||
if (fieldsToCreate.length > 0) {
|
if (currentTable) {
|
||||||
// Sequential processing with delays
|
const currentFields = new Set(currentTable.fields.map((field) => field.name));
|
||||||
const DELAY_BETWEEN_REQUESTS = 250; // 250ms = 4 requests per second (staying under 5/sec limit)
|
const fieldsToCreate = new Set<string>();
|
||||||
|
for (const field of questions) {
|
||||||
|
const hasField = currentFields.has(field);
|
||||||
|
if (!hasField) {
|
||||||
|
fieldsToCreate.add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < fieldsToCreate.length; i++) {
|
if (fieldsToCreate.size > 0) {
|
||||||
const fieldName = fieldsToCreate[i];
|
const createFieldPromise: Promise<any>[] = [];
|
||||||
|
fieldsToCreate.forEach((fieldName) => {
|
||||||
const createRes = await addField(key, configData.baseId, configData.tableId, {
|
createFieldPromise.push(
|
||||||
name: fieldName,
|
addField(key, configData.baseId, configData.tableId, {
|
||||||
type: "singleLineText",
|
name: fieldName,
|
||||||
|
type: "singleLineText",
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (createRes?.error) {
|
await Promise.all(createFieldPromise);
|
||||||
throw new Error(`Failed to create field "${fieldName}": ${JSON.stringify(createRes)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add delay between requests (except for the last one)
|
|
||||||
if (i < fieldsToCreate.length - 1) {
|
|
||||||
await delay(DELAY_BETWEEN_REQUESTS);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Wait for the new fields to show up
|
|
||||||
await waitForFieldsToExist(key, configData, fieldsToCreate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Finally, add the records
|
|
||||||
await addRecords(key, configData.baseId, configData.tableId, data);
|
await addRecords(key, configData.baseId, configData.tableId, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function waitForFieldsToExist(
|
|
||||||
key: TIntegrationAirtableCredential,
|
|
||||||
configData: TIntegrationAirtableConfigData,
|
|
||||||
fieldNames: string[],
|
|
||||||
maxRetries = 5,
|
|
||||||
intervalMs = 2000
|
|
||||||
) {
|
|
||||||
let existingFields: Set<string> = new Set(),
|
|
||||||
missingFields: string[] = [];
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
existingFields = await getExistingFields(key, configData.baseId, configData.tableId);
|
|
||||||
missingFields = fieldNames.filter((f) => !existingFields.has(f));
|
|
||||||
|
|
||||||
if (missingFields.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
|
||||||
logger.error(
|
|
||||||
`Attempt ${attempt}/${maxRetries}: ${missingFields.length} field(s) still missing [${missingFields.join(
|
|
||||||
", "
|
|
||||||
)}], retrying in ${intervalMs / 1000}s…`
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, intervalMs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Timed out waiting for ${missingFields.length} field(s) [${missingFields.join(
|
|
||||||
", "
|
|
||||||
)}] to become available. Available fields: [${Array.from(existingFields).join(", ")}]`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const E2E_TESTING = env.E2E_TESTING === "1";
|
|||||||
export const WEBAPP_URL =
|
export const WEBAPP_URL =
|
||||||
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
|
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
|
||||||
|
|
||||||
|
export const SURVEY_URL = env.SURVEY_URL;
|
||||||
|
|
||||||
// encryption keys
|
// encryption keys
|
||||||
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
|
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
|
||||||
|
|
||||||
@@ -233,8 +235,8 @@ export enum STRIPE_PROJECT_NAMES {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum STRIPE_PRICE_LOOKUP_KEYS {
|
export enum STRIPE_PRICE_LOOKUP_KEYS {
|
||||||
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
|
STARTUP_MONTHLY = "formbricks_startup_monthly",
|
||||||
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
|
STARTUP_YEARLY = "formbricks_startup_yearly",
|
||||||
SCALE_MONTHLY = "formbricks_scale_monthly",
|
SCALE_MONTHLY = "formbricks_scale_monthly",
|
||||||
SCALE_YEARLY = "formbricks_scale_yearly",
|
SCALE_YEARLY = "formbricks_scale_yearly",
|
||||||
}
|
}
|
||||||
@@ -273,24 +275,6 @@ export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY;
|
|||||||
export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY;
|
export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY;
|
||||||
export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY);
|
export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY);
|
||||||
|
|
||||||
// Use the app version for Sentry release (updated during build in production)
|
|
||||||
// Fallback to environment variable if package.json is not accessible
|
|
||||||
export const SENTRY_RELEASE = (() => {
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to read from package.json with proper error handling
|
|
||||||
try {
|
|
||||||
const pkg = require("../package.json");
|
|
||||||
return pkg.version === "0.0.0" ? undefined : `v${pkg.version}`;
|
|
||||||
} catch {
|
|
||||||
// If package.json can't be read (e.g., in some deployment scenarios),
|
|
||||||
// return undefined and let Sentry work without release tracking
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
export const SENTRY_ENVIRONMENT = env.SENTRY_ENVIRONMENT;
|
|
||||||
export const SENTRY_DSN = env.SENTRY_DSN;
|
export const SENTRY_DSN = env.SENTRY_DSN;
|
||||||
|
|
||||||
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
|
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
|
||||||
|
|||||||
@@ -62,19 +62,3 @@ export const deleteDisplay = async (displayId: string): Promise<TDisplay> => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDisplay = reactCache(async (displayId: string): Promise<{ id: string } | null> => {
|
|
||||||
validateInputs([displayId, ZId]);
|
|
||||||
try {
|
|
||||||
const display = await prisma.display.findUnique({
|
|
||||||
where: { id: displayId },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
return display;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
||||||
throw new DatabaseError(error.message);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
+2
-20
@@ -85,23 +85,7 @@ export const env = createEnv({
|
|||||||
SMTP_REJECT_UNAUTHORIZED_TLS: z.enum(["1", "0"]).optional(),
|
SMTP_REJECT_UNAUTHORIZED_TLS: z.enum(["1", "0"]).optional(),
|
||||||
STRIPE_SECRET_KEY: z.string().optional(),
|
STRIPE_SECRET_KEY: z.string().optional(),
|
||||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||||
PUBLIC_URL: z
|
SURVEY_URL: z.string().optional(),
|
||||||
.string()
|
|
||||||
.url()
|
|
||||||
.refine(
|
|
||||||
(url) => {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return parsed.host && parsed.host.length > 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "PUBLIC_URL must be a valid URL with a proper host (e.g., https://example.com)",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
|
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
|
||||||
TERMS_URL: z
|
TERMS_URL: z
|
||||||
.string()
|
.string()
|
||||||
@@ -127,7 +111,6 @@ export const env = createEnv({
|
|||||||
.string()
|
.string()
|
||||||
.transform((val) => parseInt(val))
|
.transform((val) => parseInt(val))
|
||||||
.optional(),
|
.optional(),
|
||||||
SENTRY_ENVIRONMENT: z.string().optional(),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -207,7 +190,7 @@ export const env = createEnv({
|
|||||||
SMTP_AUTHENTICATED: process.env.SMTP_AUTHENTICATED,
|
SMTP_AUTHENTICATED: process.env.SMTP_AUTHENTICATED,
|
||||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||||
PUBLIC_URL: process.env.PUBLIC_URL,
|
SURVEY_URL: process.env.SURVEY_URL,
|
||||||
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
|
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
|
||||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
||||||
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
||||||
@@ -226,6 +209,5 @@ export const env = createEnv({
|
|||||||
AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED,
|
AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED,
|
||||||
AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP,
|
AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP,
|
||||||
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
|
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
|
||||||
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ export const validateSingleFile = (
|
|||||||
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
|
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQuestion[]): boolean => {
|
export const validateFileUploads = (data: TResponseData, questions?: TSurveyQuestion[]): boolean => {
|
||||||
if (!data) return true;
|
|
||||||
for (const key of Object.keys(data)) {
|
for (const key of Object.keys(data)) {
|
||||||
const question = questions?.find((q) => q.id === key);
|
const question = questions?.find((q) => q.id === key);
|
||||||
if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue;
|
if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue;
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
// Mock constants module
|
|
||||||
const envMock = {
|
|
||||||
env: {
|
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
|
||||||
PUBLIC_URL: undefined as string | undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => envMock);
|
|
||||||
|
|
||||||
describe("getPublicDomain", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return WEBAPP_URL when PUBLIC_URL is not set", async () => {
|
|
||||||
const { getPublicDomain } = await import("./getPublicUrl");
|
|
||||||
const domain = getPublicDomain();
|
|
||||||
expect(domain).toBe("http://localhost:3000");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return PUBLIC_URL when it is set", async () => {
|
|
||||||
envMock.env.PUBLIC_URL = "https://surveys.example.com";
|
|
||||||
const { getPublicDomain } = await import("./getPublicUrl");
|
|
||||||
const domain = getPublicDomain();
|
|
||||||
expect(domain).toBe("https://surveys.example.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle empty string PUBLIC_URL by returning WEBAPP_URL", async () => {
|
|
||||||
envMock.env.PUBLIC_URL = "";
|
|
||||||
const { getPublicDomain } = await import("./getPublicUrl");
|
|
||||||
const domain = getPublicDomain();
|
|
||||||
expect(domain).toBe("http://localhost:3000");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle undefined PUBLIC_URL by returning WEBAPP_URL", async () => {
|
|
||||||
envMock.env.PUBLIC_URL = undefined;
|
|
||||||
const { getPublicDomain } = await import("./getPublicUrl");
|
|
||||||
const domain = getPublicDomain();
|
|
||||||
expect(domain).toBe("http://localhost:3000");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { env } from "./env";
|
|
||||||
|
|
||||||
const WEBAPP_URL =
|
|
||||||
env.WEBAPP_URL ?? (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : "") ?? "http://localhost:3000";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the public domain URL
|
|
||||||
* Uses PUBLIC_URL if set, otherwise falls back to WEBAPP_URL
|
|
||||||
*/
|
|
||||||
export const getPublicDomain = (): string => {
|
|
||||||
return env.PUBLIC_URL && env.PUBLIC_URL.trim() !== "" ? env.PUBLIC_URL : WEBAPP_URL;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
// Create a mock module for constants with proper types
|
||||||
|
const constantsMock = {
|
||||||
|
SURVEY_URL: undefined as string | undefined,
|
||||||
|
WEBAPP_URL: "http://localhost:3000" as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the constants module
|
||||||
|
vi.mock("./constants", () => constantsMock);
|
||||||
|
|
||||||
|
describe("getSurveyDomain", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset the mock values before each test
|
||||||
|
constantsMock.SURVEY_URL = undefined;
|
||||||
|
constantsMock.WEBAPP_URL = "http://localhost:3000";
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return WEBAPP_URL when SURVEY_URL is not set", async () => {
|
||||||
|
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||||
|
const domain = getSurveyDomain();
|
||||||
|
expect(domain).toBe("http://localhost:3000");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return SURVEY_URL when it is set", async () => {
|
||||||
|
constantsMock.SURVEY_URL = "https://surveys.example.com";
|
||||||
|
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||||
|
const domain = getSurveyDomain();
|
||||||
|
expect(domain).toBe("https://surveys.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle empty string SURVEY_URL by returning WEBAPP_URL", async () => {
|
||||||
|
constantsMock.SURVEY_URL = "";
|
||||||
|
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||||
|
const domain = getSurveyDomain();
|
||||||
|
expect(domain).toBe("http://localhost:3000");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle undefined SURVEY_URL by returning WEBAPP_URL", async () => {
|
||||||
|
constantsMock.SURVEY_URL = undefined;
|
||||||
|
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||||
|
const domain = getSurveyDomain();
|
||||||
|
expect(domain).toBe("http://localhost:3000");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { SURVEY_URL, WEBAPP_URL } from "./constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the base URL for public surveys
|
||||||
|
* Uses SURVEY_URL if set, otherwise falls back to WEBAPP_URL
|
||||||
|
*/
|
||||||
|
export const getSurveyDomain = (): string => {
|
||||||
|
return SURVEY_URL || WEBAPP_URL;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user