mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 08:20:29 -06:00
Compare commits
118 Commits
saml-sso-e
...
v3.16.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec78038caa | ||
|
|
908614b4e2 | ||
|
|
c584901337 | ||
|
|
087699fb57 | ||
|
|
58213969e8 | ||
|
|
ef973c8995 | ||
|
|
bea02ba3b5 | ||
|
|
1c1e2ee09c | ||
|
|
2bf7fe6c54 | ||
|
|
9639402c39 | ||
|
|
53213b41ee | ||
|
|
b8b5eead7a | ||
|
|
a0044ce376 | ||
|
|
b3a1f24683 | ||
|
|
f06d48698a | ||
|
|
acd508ba19 | ||
|
|
e5591686b4 | ||
|
|
7be7466eee | ||
|
|
8af6c15998 | ||
|
|
17d60eb1e7 | ||
|
|
d6ecafbc23 | ||
|
|
599e847686 | ||
|
|
4e52556f7e | ||
|
|
492a59e7de | ||
|
|
e0be53805e | ||
|
|
5c2860d1a4 | ||
|
|
18ba5bbd8a | ||
|
|
572b613034 | ||
|
|
a9c7140ba6 | ||
|
|
7fa95cd74a | ||
|
|
8c7f36d496 | ||
|
|
42dcbd3e7e | ||
|
|
1c1cd99510 | ||
|
|
b0a7e212dd | ||
|
|
0c1f6f3c3a | ||
|
|
9399b526b8 | ||
|
|
cd60032bc9 | ||
|
|
a941f994ea | ||
|
|
75d170bce5 | ||
|
|
16caae6dd6 | ||
|
|
a490600479 | ||
|
|
be28641722 | ||
|
|
4fdea3221b | ||
|
|
fef30c54b2 | ||
|
|
75362eac7a | ||
|
|
6e3b224944 | ||
|
|
ef1be219b4 | ||
|
|
ba9b01a969 | ||
|
|
e810e38333 | ||
|
|
dab8ad00d5 | ||
|
|
2c34f43c83 | ||
|
|
979fd71a11 | ||
|
|
1be23eebbb | ||
|
|
d10cff917d | ||
|
|
da72101320 | ||
|
|
5f02ad49c1 | ||
|
|
6644bba6ea | ||
|
|
0b7734f725 | ||
|
|
1536bf6907 | ||
|
|
e81190214f | ||
|
|
48c8906a89 | ||
|
|
717b30115b | ||
|
|
1f3962d2d5 | ||
|
|
619f6e408f | ||
|
|
4a8719abaa | ||
|
|
7b59eb3b26 | ||
|
|
8ac280268d | ||
|
|
34e8f4931d | ||
|
|
ac46850a24 | ||
|
|
6328be220a | ||
|
|
882ad99ed7 | ||
|
|
ce47b4c2d8 | ||
|
|
ce8f9de8ec | ||
|
|
ed3c2d2b58 | ||
|
|
9ae226329b | ||
|
|
12c3899b85 | ||
|
|
ccb1353eb5 | ||
|
|
22eb0b79ee | ||
|
|
5eb7a496da | ||
|
|
7ea55e199f | ||
|
|
83eb472acd | ||
|
|
d9fe6ee4f4 | ||
|
|
51b58be079 | ||
|
|
397643330a | ||
|
|
e5fa4328e1 | ||
|
|
4b777f1907 | ||
|
|
c3547ccb36 | ||
|
|
a0f334b300 | ||
|
|
a9f635b768 | ||
|
|
d385b4a0d6 | ||
|
|
5e825413d2 | ||
|
|
8c3e816ccd | ||
|
|
6ddc91ee85 | ||
|
|
14023ca8a9 | ||
|
|
385e8a4262 | ||
|
|
e358104f7c | ||
|
|
c8e9194ab6 | ||
|
|
bebe29815d | ||
|
|
7f40502c94 | ||
|
|
5fb5215680 | ||
|
|
19b80ff042 | ||
|
|
2dfdba2acf | ||
|
|
f7842789de | ||
|
|
59bdd5f065 | ||
|
|
8da1bc71a6 | ||
|
|
0e0259691c | ||
|
|
ac7831fa3d | ||
|
|
db32cb392f | ||
|
|
e5cb01bd88 | ||
|
|
cbef4c2a69 | ||
|
|
86948b70de | ||
|
|
dfe955ca7c | ||
|
|
eb4b2dde05 | ||
|
|
f2dae67813 | ||
|
|
3ffc9bd290 | ||
|
|
a9946737df | ||
|
|
ece3d508a2 | ||
|
|
0d1d227e6a |
216
.cursor/rules/component-migration.mdc
Normal file
216
.cursor/rules/component-migration.mdc
Normal file
@@ -0,0 +1,216 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Component Migration Automation Rule
|
||||
|
||||
## Overview
|
||||
This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases.
|
||||
|
||||
## Trigger
|
||||
When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration").
|
||||
|
||||
## Process
|
||||
|
||||
### Step 1: Discovery and Planning
|
||||
1. **Identify migration parameters:**
|
||||
- Ask user for deprecated component name (e.g., "Modal")
|
||||
- Ask user for new component name(s) (e.g., "Dialog")
|
||||
- Ask for any components to exclude (e.g., "ModalWithTabs")
|
||||
- Ask for specific import paths if needed
|
||||
|
||||
2. **Scan codebase** for deprecated components:
|
||||
- Search for `import.*[DeprecatedComponent]` patterns
|
||||
- Exclude specified components that should not be migrated
|
||||
- List all found components with file paths
|
||||
- Present numbered list to user for confirmation
|
||||
|
||||
### Step 2: Component-by-Component Migration
|
||||
For each component, follow this exact sequence:
|
||||
|
||||
#### 2.1 Component Migration
|
||||
- **Import changes:**
|
||||
- Ask user to provide the new import structure
|
||||
- Example transformation pattern:
|
||||
```typescript
|
||||
// FROM:
|
||||
import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]"
|
||||
|
||||
// TO:
|
||||
import {
|
||||
[NewComponent],
|
||||
[NewComponentPart1],
|
||||
[NewComponentPart2],
|
||||
// ... other parts
|
||||
} from "@/components/ui/[NewComponent]"
|
||||
```
|
||||
|
||||
- **Props transformation:**
|
||||
- Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`)
|
||||
- Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`)
|
||||
- Apply transformations based on user specifications
|
||||
|
||||
- **Structure transformation:**
|
||||
- Ask user for the new component structure pattern
|
||||
- Apply the transformation maintaining all functionality
|
||||
- Preserve all existing logic, state management, and event handlers
|
||||
|
||||
#### 2.2 Wait for User Approval
|
||||
- Present the migration changes
|
||||
- Wait for explicit user approval before proceeding
|
||||
- If rejected, ask for specific feedback and iterate
|
||||
#### 2.3 Re-read and Apply Additional Changes
|
||||
- Re-read the component file to capture any user modifications
|
||||
- Apply any additional improvements the user made
|
||||
- Ensure all changes are incorporated
|
||||
|
||||
#### 2.4 Test File Updates
|
||||
- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`)
|
||||
- **Update test mocks:**
|
||||
- Ask user for new component mock structure
|
||||
- Replace old component mocks with new ones
|
||||
- Example pattern:
|
||||
```typescript
|
||||
// Add to test setup:
|
||||
jest.mock("@/components/ui/[NewComponent]", () => ({
|
||||
[NewComponent]: ({ children, [props] }: any) => ([mock implementation]),
|
||||
[NewComponentPart1]: ({ children }: any) => <div data-testid="[new-component-part1]">{children}</div>,
|
||||
[NewComponentPart2]: ({ children }: any) => <div data-testid="[new-component-part2]">{children}</div>,
|
||||
// ... other parts
|
||||
}));
|
||||
```
|
||||
- **Update test expectations:**
|
||||
- Change test IDs from old component to new component
|
||||
- Update any component-specific assertions
|
||||
- Ensure all new component parts used in the component are mocked
|
||||
|
||||
#### 2.5 Run Tests and Optimize
|
||||
- Execute `Node package manager test -- ComponentName.test.tsx`
|
||||
- Fix any failing tests
|
||||
- Optimize code quality (imports, formatting, etc.)
|
||||
- Re-run tests until all pass
|
||||
- **Maximum 3 iterations** - if still failing, ask user for guidance
|
||||
|
||||
#### 2.6 Wait for Final Approval
|
||||
- Present test results and any optimizations made
|
||||
- Wait for user approval of the complete migration
|
||||
- If rejected, iterate based on feedback
|
||||
|
||||
#### 2.7 Git Commit
|
||||
- Run: `git add .`
|
||||
- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"`
|
||||
- Confirm commit was successful
|
||||
|
||||
### Step 3: Final Report Generation
|
||||
After all components are migrated, generate a comprehensive GitHub PR report:
|
||||
|
||||
#### PR Title
|
||||
```
|
||||
feat: migrate [DeprecatedComponent] components to [NewComponent] system
|
||||
```
|
||||
|
||||
#### PR Description Template
|
||||
```markdown
|
||||
## 🔄 [DeprecatedComponent] to [NewComponent] Migration
|
||||
|
||||
### Overview
|
||||
Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency.
|
||||
|
||||
### Components Migrated
|
||||
[List each component with file path]
|
||||
|
||||
### Technical Changes
|
||||
- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]`
|
||||
- **Props:** [List prop transformations]
|
||||
- **Structure:** Implemented proper [NewComponent] component hierarchy
|
||||
- **Styling:** [Describe styling changes]
|
||||
- **Tests:** Updated all test mocks and expectations
|
||||
|
||||
### Migration Pattern
|
||||
```typescript
|
||||
// Before
|
||||
<[DeprecatedComponent] [oldProps]>
|
||||
[oldStructure]
|
||||
</[DeprecatedComponent]>
|
||||
|
||||
// After
|
||||
<[NewComponent] [newProps]>
|
||||
[newStructure]
|
||||
</[NewComponent]>
|
||||
```
|
||||
|
||||
### Testing
|
||||
- ✅ All existing tests updated and passing
|
||||
- ✅ Component functionality preserved
|
||||
- ✅ UI/UX behavior maintained
|
||||
|
||||
### How to Test This PR
|
||||
1. **Functional Testing:**
|
||||
- Navigate to each migrated component's usage
|
||||
- Verify [component] opens and closes correctly
|
||||
- Test all interactive elements within [components]
|
||||
- Confirm styling and layout are preserved
|
||||
|
||||
2. **Automated Testing:**
|
||||
```bash
|
||||
Node package manager test
|
||||
```
|
||||
|
||||
3. **Visual Testing:**
|
||||
- Check that all [components] maintain proper styling
|
||||
- Verify responsive behavior
|
||||
- Test keyboard navigation and accessibility
|
||||
|
||||
### Breaking Changes
|
||||
[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."]
|
||||
|
||||
### Notes
|
||||
- [Any excluded components] were preserved as they already use [NewComponent] internally
|
||||
- All form validation and complex state management preserved
|
||||
- Enhanced code quality with better imports and formatting
|
||||
```
|
||||
|
||||
## Special Considerations
|
||||
|
||||
### Excluded Components
|
||||
- **DO NOT MIGRATE** components specified by user as exclusions
|
||||
- They may already use the new component internally or have other reasons
|
||||
- Inform user these are skipped and why
|
||||
|
||||
### Complex Components
|
||||
- Preserve all existing functionality (forms, validation, state management)
|
||||
- Maintain prop interfaces
|
||||
- Keep all event handlers and callbacks
|
||||
- Preserve accessibility features
|
||||
|
||||
### Test Coverage
|
||||
- Ensure all new component parts are mocked when used
|
||||
- Mock all new component parts that appear in the component
|
||||
- Update test IDs from old component to new component
|
||||
- Maintain all existing test scenarios
|
||||
|
||||
### Error Handling
|
||||
- If tests fail after 3 iterations, stop and ask user for guidance
|
||||
- If component is too complex, ask user for specific guidance
|
||||
- If unsure about functionality preservation, ask for clarification
|
||||
|
||||
### Migration Patterns
|
||||
- Always ask user for specific migration patterns before starting
|
||||
- Confirm import structures, prop mappings, and component hierarchies
|
||||
- Adapt to different component architectures (simple replacements, complex restructuring, etc.)
|
||||
|
||||
## Success Criteria
|
||||
- All deprecated components successfully migrated to new components
|
||||
- All tests passing
|
||||
- No functionality lost
|
||||
- Code quality maintained or improved
|
||||
- User approval on each component
|
||||
- Successful git commits for each migration
|
||||
- Comprehensive PR report generated
|
||||
|
||||
## Usage Examples
|
||||
- "migrate Modal to Dialog"
|
||||
- "migrate Button to NewButton"
|
||||
- "migrate Card to ModernCard"
|
||||
- "component migration" (will prompt for details)
|
||||
101
.cursor/rules/database.mdc
Normal file
101
.cursor/rules/database.mdc
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
description: >
|
||||
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
||||
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
||||
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
||||
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||
globs: []
|
||||
alwaysApply: agent-requested
|
||||
---
|
||||
# Formbricks Database Schema Reference
|
||||
|
||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||
|
||||
## Database Overview
|
||||
|
||||
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
||||
|
||||
### Core Hierarchy
|
||||
```
|
||||
Organization
|
||||
└── Project
|
||||
└── Environment (production/development)
|
||||
├── Survey
|
||||
├── Contact
|
||||
├── ActionClass
|
||||
└── Integration
|
||||
```
|
||||
|
||||
## Schema Reference
|
||||
|
||||
For the complete and up-to-date database schema, please refer to:
|
||||
- Main schema: `packages/database/schema.prisma`
|
||||
- JSON type definitions: `packages/database/json-types.ts`
|
||||
|
||||
The schema.prisma file contains all model definitions, relationships, enums, and field types. The json-types.ts file contains TypeScript type definitions for JSON fields.
|
||||
|
||||
## Data Access Patterns
|
||||
|
||||
### Multi-tenancy
|
||||
- All data is scoped by Organization
|
||||
- Environment-level isolation for surveys and contacts
|
||||
- Project-level grouping for related surveys
|
||||
|
||||
### Soft Deletion
|
||||
Some models use soft deletion patterns:
|
||||
- Check `isActive` fields where present
|
||||
- Use proper filtering in queries
|
||||
|
||||
### Cascading Deletes
|
||||
Configured cascade relationships:
|
||||
- Organization deletion cascades to all child entities
|
||||
- Survey deletion removes responses, displays, triggers
|
||||
- Contact deletion removes attributes and responses
|
||||
|
||||
## Common Query Patterns
|
||||
|
||||
### Survey with Responses
|
||||
```typescript
|
||||
// Include response count and latest responses
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: { id: surveyId },
|
||||
include: {
|
||||
responses: {
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
},
|
||||
_count: {
|
||||
select: { responses: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Scoping
|
||||
```typescript
|
||||
// Always scope by environment
|
||||
const surveys = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
// Additional filters...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Contact with Attributes
|
||||
```typescript
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
include: {
|
||||
attributes: {
|
||||
include: {
|
||||
attributeKey: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
||||
|
||||
|
||||
23
.cursor/rules/documentations.mdc
Normal file
23
.cursor/rules/documentations.mdc
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
||||
|
||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
||||
|
||||
---
|
||||
title: "FEATURE NAME"
|
||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
||||
icon: "link"
|
||||
---
|
||||
|
||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
|
||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
|
||||
- If a feature is part of the Enterprise Edition, use this note:
|
||||
|
||||
<Note>
|
||||
FEATURE NAME is part of the @Enterprise Edition.
|
||||
</Note>
|
||||
@@ -3,4 +3,5 @@ description: Whenever the user asks to write or update a test file for .tsx or .
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)
|
||||
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md).
|
||||
After writing the tests, run them and check if there's any issue with the tests and if all of them are passing. Fix the issues and rerun the tests until all pass.
|
||||
14
.env.example
14
.env.example
@@ -80,8 +80,8 @@ S3_ENDPOINT_URL=
|
||||
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
||||
S3_FORCE_PATH_STYLE=0
|
||||
|
||||
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
|
||||
# SURVEY_URL=https://survey.example.com
|
||||
# Set this URL to add a public domain for all your client facing routes(default is WEBAPP_URL)
|
||||
# PUBLIC_URL=https://survey.example.com
|
||||
|
||||
#####################
|
||||
# Disable Features #
|
||||
@@ -189,8 +189,7 @@ ENTERPRISE_LICENSE_KEY=
|
||||
UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
@@ -210,9 +209,16 @@ UNKEY_ROOT_KEY=
|
||||
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
|
||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard
|
||||
# SENTRY_ENVIRONMENT=
|
||||
|
||||
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
|
||||
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
||||
# SESSION_MAX_AGE=86400
|
||||
|
||||
# Audit logs options. Default 0.
|
||||
# AUDIT_LOG_ENABLED=0
|
||||
# If the ip should be added in the log or not. Default 0
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Feature request
|
||||
description: "Suggest an idea for this project \U0001F680"
|
||||
type: feature
|
||||
projects: "formbricks/21"
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/task.yml
vendored
11
.github/ISSUE_TEMPLATE/task.yml
vendored
@@ -1,11 +0,0 @@
|
||||
name: Task (internal)
|
||||
description: "Template for creating a task. Used by the Formbricks Team only \U0001f4e5"
|
||||
type: task
|
||||
body:
|
||||
- type: textarea
|
||||
id: task-summary
|
||||
attributes:
|
||||
label: Task description
|
||||
description: A clear detailed-rich description of the task.
|
||||
validations:
|
||||
required: true
|
||||
121
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
Normal file
121
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
name: 'Upload Sentry Sourcemaps'
|
||||
description: 'Extract sourcemaps from Docker image and upload to Sentry'
|
||||
|
||||
inputs:
|
||||
docker_image:
|
||||
description: 'Docker image to extract sourcemaps from'
|
||||
required: true
|
||||
release_version:
|
||||
description: 'Sentry release version (e.g., v1.2.3)'
|
||||
required: true
|
||||
sentry_auth_token:
|
||||
description: 'Sentry authentication token'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate Sentry auth token
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "🔐 Validating Sentry authentication token..."
|
||||
|
||||
# Assign token to local variable for secure handling
|
||||
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
|
||||
|
||||
# Test the token by making a simple API call to Sentry
|
||||
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
|
||||
-H "Authorization: Bearer $SENTRY_TOKEN" \
|
||||
"https://sentry.io/api/0/organizations/formbricks/")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
|
||||
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
|
||||
if [ -f /tmp/sentry_response.json ]; then
|
||||
echo "Response body:"
|
||||
cat /tmp/sentry_response.json
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Sentry auth token validated successfully"
|
||||
|
||||
# Clean up temp file
|
||||
rm -f /tmp/sentry_response.json
|
||||
|
||||
- name: Extract sourcemaps from Docker image
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
|
||||
|
||||
# Create temporary container from the image and capture its ID
|
||||
echo "Creating temporary container..."
|
||||
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
|
||||
echo "Container created with ID: $CONTAINER_ID"
|
||||
|
||||
# Set up cleanup function to ensure container is removed on script exit
|
||||
cleanup_container() {
|
||||
# Capture the current exit code to preserve it
|
||||
local original_exit_code=$?
|
||||
|
||||
echo "🧹 Cleaning up Docker container..."
|
||||
|
||||
# Remove the container if it exists (ignore errors if already removed)
|
||||
if [ -n "$CONTAINER_ID" ]; then
|
||||
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
|
||||
echo "Container $CONTAINER_ID removed"
|
||||
fi
|
||||
|
||||
# Exit with the original exit code to preserve script success/failure status
|
||||
exit $original_exit_code
|
||||
}
|
||||
|
||||
# Register cleanup function to run on script exit (success or failure)
|
||||
trap cleanup_container EXIT
|
||||
|
||||
# Extract .next directory containing sourcemaps
|
||||
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
|
||||
|
||||
# Verify sourcemaps exist
|
||||
if [ ! -d "./extracted-next/static/chunks" ]; then
|
||||
echo "❌ Error: .next/static/chunks directory not found in Docker image"
|
||||
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
|
||||
echo "✅ Found $sourcemap_count sourcemap files"
|
||||
|
||||
if [ "$sourcemap_count" -eq 0 ]; then
|
||||
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Sentry release and upload sourcemaps
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
|
||||
SENTRY_ORG: formbricks
|
||||
SENTRY_PROJECT: formbricks-cloud
|
||||
with:
|
||||
environment: production
|
||||
version: ${{ inputs.release_version }}
|
||||
sourcemaps: './extracted-next/'
|
||||
|
||||
- name: Clean up extracted files
|
||||
shell: bash
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Clean up extracted files
|
||||
rm -rf ./extracted-next
|
||||
echo "🧹 Cleaned up extracted files"
|
||||
60
.github/workflows/deploy-formbricks-cloud.yml
vendored
60
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -4,16 +4,16 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.'
|
||||
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
|
||||
required: true
|
||||
type: string
|
||||
REPOSITORY:
|
||||
description: 'The repository to use for the Docker image'
|
||||
description: "The repository to use for the Docker image"
|
||||
required: false
|
||||
type: string
|
||||
default: 'ghcr.io/formbricks/formbricks'
|
||||
default: "ghcr.io/formbricks/formbricks"
|
||||
ENVIRONMENT:
|
||||
description: 'The environment to deploy to'
|
||||
description: "The environment to deploy to"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
@@ -22,16 +22,16 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Docker image to release'
|
||||
description: "The version of the Docker image to release"
|
||||
required: true
|
||||
type: string
|
||||
REPOSITORY:
|
||||
description: 'The repository to use for the Docker image'
|
||||
description: "The repository to use for the Docker image"
|
||||
required: false
|
||||
type: string
|
||||
default: 'ghcr.io/formbricks/formbricks'
|
||||
default: "ghcr.io/formbricks/formbricks"
|
||||
ENVIRONMENT:
|
||||
description: 'The environment to deploy to'
|
||||
description: "The environment to deploy to"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helmfile-version: 'v1.0.0'
|
||||
helmfile-version: "v1.0.0"
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helmfile-version: 'v1.0.0'
|
||||
helmfile-version: "v1.0.0"
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
@@ -100,3 +100,43 @@ jobs:
|
||||
helmfile-auto-init: "false"
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- name: Purge Cloudflare Cache
|
||||
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
|
||||
env:
|
||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
# Set hostname based on environment
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
|
||||
PURGE_HOST="app.formbricks.com"
|
||||
else
|
||||
PURGE_HOST="stage.app.formbricks.com"
|
||||
fi
|
||||
|
||||
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
|
||||
|
||||
# Prepare JSON payload for selective cache purge
|
||||
json_payload=$(cat << EOF
|
||||
{
|
||||
"hosts": ["$PURGE_HOST"]
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Make API call to Cloudflare
|
||||
response=$(curl -s -X POST \
|
||||
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
|
||||
-H "Authorization: Bearer $CF_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$json_payload")
|
||||
|
||||
echo "Cloudflare API response: $response"
|
||||
|
||||
# Verify the operation was successful
|
||||
if [[ "$(echo "$response" | jq -r .success)" == "true" ]]; then
|
||||
echo "✅ Successfully purged cache for $PURGE_HOST"
|
||||
else
|
||||
echo "❌ Cloudflare cache purge failed"
|
||||
echo "Error details: $(echo "$response" | jq -r .errors)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
17
.github/workflows/e2e.yml
vendored
17
.github/workflows/e2e.yml
vendored
@@ -45,6 +45,16 @@ jobs:
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
valkey:
|
||||
image: valkey/valkey:8.1.1
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--entrypoint "valkey-server"
|
||||
--health-cmd="valkey-cli ping"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
@@ -79,6 +89,7 @@ jobs:
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
@@ -92,6 +103,12 @@ jobs:
|
||||
# pnpm prisma migrate deploy
|
||||
pnpm db:migrate:dev
|
||||
|
||||
- name: Run Rate Limiter Load Tests
|
||||
run: |
|
||||
echo "Running rate limiter load tests with Redis/Valkey..."
|
||||
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
|
||||
shell: bash
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
|
||||
22
.github/workflows/formbricks-release.yml
vendored
22
.github/workflows/formbricks-release.yml
vendored
@@ -32,3 +32,25 @@ jobs:
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- docker-build
|
||||
- deploy-formbricks-cloud
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Upload Sentry Sourcemaps
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -10,8 +10,6 @@ permissions:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
1
.github/workflows/sonarqube.yml
vendored
1
.github/workflows/sonarqube.yml
vendored
@@ -43,6 +43,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -41,6 +41,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
46
.github/workflows/upload-sentry-sourcemaps.yml
vendored
Normal file
46
.github/workflows/upload-sentry-sourcemaps.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Upload Sentry Sourcemaps (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
docker_image:
|
||||
description: "Docker image to extract sourcemaps from"
|
||||
required: true
|
||||
type: string
|
||||
release_version:
|
||||
description: "Release version (e.g., v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
tag_version:
|
||||
description: "Docker image tag (leave empty to use release_version)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload-sourcemaps:
|
||||
name: Upload Sourcemaps to Sentry
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set Docker Image
|
||||
run: |
|
||||
if [ -n "${{ inputs.tag_version }}" ]; then
|
||||
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Upload Sourcemaps to Sentry
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
with:
|
||||
docker_image: ${{ env.DOCKER_IMAGE }}
|
||||
release_version: ${{ inputs.release_version }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -73,3 +73,4 @@ infra/terraform/.terraform/
|
||||
/.idea/
|
||||
/*.iml
|
||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||
.cursorrules
|
||||
|
||||
@@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
|
||||
|
||||
## 🛠 Crafting Pull Requests
|
||||
|
||||
Ready to dive into the code and make a real impact? Here's your path:
|
||||
|
||||
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
|
||||
|
||||
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
|
||||
|
||||
1. **Tweak and Transform:** Work your coding magic and apply your changes.
|
||||
|
||||
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
|
||||
|
||||
Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
|
||||
For the time being, we don't have the capacity to properly facilitate community contributions. It's a lot of engineering attention often spent on issues which don't follow our prioritization, so we've decided to only facilitate community code contributions in rare exceptions in the coming months.
|
||||
|
||||
## 🚀 Aspiring Features
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ Here are a few options:
|
||||
|
||||
- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap.
|
||||
|
||||
Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.
|
||||
- Note: For the time being, we can only facilitate code contributions as an exception.
|
||||
|
||||
## All Thanks To Our Contributors
|
||||
|
||||
|
||||
@@ -14,10 +14,9 @@ const config: StorybookConfig = {
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-onboarding"),
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
getAbsolutePath("@storybook/addon-essentials"),
|
||||
getAbsolutePath("@chromatic-com/storybook"),
|
||||
getAbsolutePath("@storybook/addon-interactions"),
|
||||
getAbsolutePath("@storybook/addon-a11y"),
|
||||
getAbsolutePath("@storybook/addon-docs"),
|
||||
],
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/react-vite"),
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { Preview } from "@storybook/react";
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TolgeeProvider } from "@tolgee/react";
|
||||
import React from "react";
|
||||
import "../../web/modules/ui/globals.css";
|
||||
import { TolgeeBase } from "../../web/tolgee/shared";
|
||||
|
||||
// Create a Storybook-specific Tolgee decorator
|
||||
const withTolgee = (Story: any) => {
|
||||
const tolgee = TolgeeBase().init({
|
||||
tagNewKeys: [], // No branch tagging in Storybook
|
||||
});
|
||||
|
||||
return React.createElement(
|
||||
TolgeeProvider,
|
||||
{ tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } },
|
||||
React.createElement(Story)
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
@@ -10,6 +26,7 @@ const preview: Preview = {
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [withTolgee],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
||||
@@ -14,23 +14,19 @@
|
||||
"eslint-plugin-react-refresh": "0.4.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@storybook/addon-a11y": "8.6.12",
|
||||
"@storybook/addon-essentials": "8.6.12",
|
||||
"@storybook/addon-interactions": "8.6.12",
|
||||
"@storybook/addon-links": "8.6.12",
|
||||
"@storybook/addon-onboarding": "8.6.12",
|
||||
"@storybook/blocks": "8.6.12",
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@chromatic-com/storybook": "^4.0.1",
|
||||
"@storybook/addon-a11y": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/react-vite": "9.0.15",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
||||
"@typescript-eslint/parser": "8.32.0",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"esbuild": "0.25.4",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.6.12",
|
||||
"vite": "6.3.5"
|
||||
"storybook": "9.0.15",
|
||||
"vite": "6.3.5",
|
||||
"@storybook/addon-docs": "9.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta } from "@storybook/blocks";
|
||||
import { Meta } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import Accessibility from "./assets/accessibility.png";
|
||||
import AddonLibrary from "./assets/addon-library.png";
|
||||
|
||||
@@ -25,21 +25,9 @@ RUN corepack prepare pnpm@9.15.9 --activate
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
|
||||
# BuildKit secret handling without hardcoded fallback values
|
||||
# This approach relies entirely on secrets passed from GitHub Actions
|
||||
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
|
||||
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
|
||||
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
|
||||
echo 'else' >> /tmp/read-secrets.sh && \
|
||||
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
|
||||
echo 'fi' >> /tmp/read-secrets.sh && \
|
||||
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
|
||||
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
|
||||
echo 'else' >> /tmp/read-secrets.sh && \
|
||||
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
|
||||
echo 'fi' >> /tmp/read-secrets.sh && \
|
||||
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
|
||||
chmod +x /tmp/read-secrets.sh
|
||||
# Copy the secrets handling script
|
||||
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
|
||||
RUN chmod +x /tmp/read-secrets.sh
|
||||
|
||||
# Increase Node.js memory limit as a regular build argument
|
||||
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||
@@ -62,6 +50,9 @@ RUN touch apps/web/.env
|
||||
# Install the dependencies
|
||||
RUN pnpm install --ignore-scripts
|
||||
|
||||
# Build the database package first
|
||||
RUN pnpm build --filter=@formbricks/database
|
||||
|
||||
# Build the project using our secret reader script
|
||||
# This mounts the secrets only during this build step without storing them in layers
|
||||
RUN --mount=type=secret,id=database_url \
|
||||
@@ -106,20 +97,8 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
|
||||
|
||||
COPY --from=installer /app/packages/database/migration ./packages/database/migration
|
||||
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
|
||||
|
||||
COPY --from=installer /app/packages/database/src ./packages/database/src
|
||||
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
|
||||
|
||||
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
|
||||
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
|
||||
|
||||
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
|
||||
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
|
||||
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
||||
|
||||
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
||||
@@ -142,12 +121,14 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
|
||||
RUN npm install -g prisma
|
||||
|
||||
# Create a startup script to handle the conditional logic
|
||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||
RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh
|
||||
|
||||
EXPOSE 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV NODE_ENV="production"
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
# Prepare volume for uploads
|
||||
@@ -158,12 +139,4 @@ VOLUME /home/nextjs/apps/web/uploads/
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
|
||||
echo "Starting cron jobs..."; \
|
||||
supercronic -quiet /app/docker/cronjobs & \
|
||||
else \
|
||||
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
|
||||
fi; \
|
||||
(cd packages/database && npm run db:migrate:deploy) && \
|
||||
(cd packages/database && npm run db:create-saml-database:deploy) && \
|
||||
exec node apps/web/server.js
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
@@ -27,7 +27,7 @@ describe("ConnectWithFormbricks", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
@@ -40,7 +40,7 @@ describe("ConnectWithFormbricks", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
@@ -53,7 +53,7 @@ describe("ConnectWithFormbricks", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
@@ -67,7 +67,7 @@ describe("ConnectWithFormbricks", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
|
||||
@@ -12,14 +12,14 @@ import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||
|
||||
interface ConnectWithFormbricksProps {
|
||||
environment: TEnvironment;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
widgetSetupCompleted: boolean;
|
||||
channel: TProjectConfigChannel;
|
||||
}
|
||||
|
||||
export const ConnectWithFormbricks = ({
|
||||
environment,
|
||||
webAppUrl,
|
||||
publicDomain,
|
||||
widgetSetupCompleted,
|
||||
channel,
|
||||
}: ConnectWithFormbricksProps) => {
|
||||
@@ -49,7 +49,7 @@ export const ConnectWithFormbricks = ({
|
||||
<div className="flex w-1/2 flex-col space-y-4">
|
||||
<OnboardingSetupInstructions
|
||||
environmentId={environment.id}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={publicDomain}
|
||||
channel={channel}
|
||||
widgetSetupCompleted={widgetSetupCompleted}
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("OnboardingSetupInstructions", () => {
|
||||
// Provide some default props for testing
|
||||
const defaultProps = {
|
||||
environmentId: "env-123",
|
||||
webAppUrl: "https://example.com",
|
||||
publicDomain: "https://example.com",
|
||||
channel: "app" as const, // Assuming channel is either "app" or "website"
|
||||
widgetSetupCompleted: false,
|
||||
};
|
||||
|
||||
@@ -18,14 +18,14 @@ const tabs = [
|
||||
|
||||
interface OnboardingSetupInstructionsProps {
|
||||
environmentId: string;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
channel: TProjectConfigChannel;
|
||||
widgetSetupCompleted: boolean;
|
||||
}
|
||||
|
||||
export const OnboardingSetupInstructions = ({
|
||||
environmentId,
|
||||
webAppUrl,
|
||||
publicDomain,
|
||||
channel,
|
||||
widgetSetupCompleted,
|
||||
}: OnboardingSetupInstructionsProps) => {
|
||||
@@ -34,7 +34,7 @@ export const OnboardingSetupInstructions = ({
|
||||
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${webAppUrl}";
|
||||
var appUrl = "${publicDomain}";
|
||||
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)}();
|
||||
</script>
|
||||
@@ -44,7 +44,7 @@ export const OnboardingSetupInstructions = ({
|
||||
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${webAppUrl}";
|
||||
var appUrl = "${publicDomain}";
|
||||
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)}();
|
||||
</script>
|
||||
@@ -57,7 +57,7 @@ export const OnboardingSetupInstructions = ({
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
environmentId: "${environmentId}",
|
||||
appUrl: "${webAppUrl}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const OnboardingSetupInstructions = ({
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
environmentId: "${environmentId}",
|
||||
appUrl: "${webAppUrl}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
@@ -30,6 +30,8 @@ const Page = async (props: ConnectPageProps) => {
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<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")} />
|
||||
@@ -39,7 +41,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
</div>
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
publicDomain={publicDomain}
|
||||
widgetSetupCompleted={environment.appSetupCompleted}
|
||||
channel={channel}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
PUBLIC_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
@@ -86,6 +86,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { LandingSidebar } from "./landing-sidebar";
|
||||
|
||||
// Mock constants that this test needs
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
// Mock server actions that this test needs
|
||||
vi.mock("@/modules/auth/actions/sign-out", () => ({
|
||||
logSignOutAction: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Module mocks must be declared before importing the component
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
|
||||
}));
|
||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
||||
|
||||
// Mock our useSignOut hook
|
||||
const mockSignOut = vi.fn();
|
||||
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
||||
useSignOut: () => ({
|
||||
signOut: mockSignOut,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open }: { open: boolean }) => (
|
||||
@@ -70,6 +88,13 @@ describe("LandingSidebar component", () => {
|
||||
const logoutItem = await screen.findByText("common.logout");
|
||||
await userEvent.click(logoutItem);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "o1",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import {
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -44,6 +44,7 @@ export const LandingSidebar = ({
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
||||
|
||||
const { t } = useTranslate();
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -123,7 +124,14 @@ export const LandingSidebar = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOut({ callbackUrl: "/auth/login" });
|
||||
await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
PUBLIC_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
@@ -89,6 +89,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/service");
|
||||
|
||||
@@ -23,7 +23,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
@@ -98,6 +97,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
|
||||
@@ -35,6 +35,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
|
||||
@@ -34,6 +34,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
@@ -26,6 +26,14 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
REDIS_URL: undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Contact Page Re-export", () => {
|
||||
|
||||
@@ -4,7 +4,9 @@ import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import {
|
||||
getOrganizationProjectsLimit,
|
||||
getRoleManagementPermission,
|
||||
@@ -20,62 +22,69 @@ const ZCreateProjectAction = z.object({
|
||||
data: ZProjectUpdateInput,
|
||||
});
|
||||
|
||||
export const createProjectAction = authenticatedActionClient
|
||||
.schema(ZCreateProjectAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { user } = ctx;
|
||||
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"project",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const organizationId = parsedInput.organizationId;
|
||||
const organizationId = parsedInput.organizationId;
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZProjectUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await checkAuthorizationUpdated({
|
||||
userId: user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZProjectUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||
|
||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||
throw new OperationNotAllowedError("Organization project limit reached");
|
||||
}
|
||||
|
||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||
|
||||
if (!canDoRoleManagement) {
|
||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||
|
||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||
throw new OperationNotAllowedError("Organization project limit reached");
|
||||
}
|
||||
|
||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||
|
||||
if (!canDoRoleManagement) {
|
||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||
}
|
||||
}
|
||||
|
||||
const project = await createProject(parsedInput.organizationId, parsedInput.data);
|
||||
const updatedNotificationSettings = {
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...user.notificationSettings?.weeklySummary,
|
||||
[project.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = project.id;
|
||||
ctx.auditLoggingCtx.newObject = project;
|
||||
return project;
|
||||
}
|
||||
|
||||
const project = await createProject(parsedInput.organizationId, parsedInput.data);
|
||||
const updatedNotificationSettings = {
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...user.notificationSettings?.weeklySummary,
|
||||
[project.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
return project;
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
|
||||
import { getSurveysByActionClassId } from "@/lib/survey/service";
|
||||
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { z } from "zod";
|
||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -14,63 +16,80 @@ const ZDeleteActionClassAction = z.object({
|
||||
actionClassId: ZId,
|
||||
});
|
||||
|
||||
export const deleteActionClassAction = authenticatedActionClient
|
||||
.schema(ZDeleteActionClassAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await deleteActionClass(parsedInput.actionClassId);
|
||||
});
|
||||
export const deleteActionClassAction = authenticatedActionClient.schema(ZDeleteActionClassAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"actionClass",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
|
||||
ctx.auditLoggingCtx.oldObject = await getActionClass(parsedInput.actionClassId);
|
||||
return await deleteActionClass(parsedInput.actionClassId);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateActionClassAction = z.object({
|
||||
actionClassId: ZId,
|
||||
updatedAction: ZActionClassInput,
|
||||
});
|
||||
|
||||
export const updateActionClassAction = authenticatedActionClient
|
||||
.schema(ZUpdateActionClassAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const actionClass = await getActionClass(parsedInput.actionClassId);
|
||||
if (actionClass === null) {
|
||||
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
|
||||
export const updateActionClassAction = authenticatedActionClient.schema(ZUpdateActionClassAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"actionClass",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const actionClass = await getActionClass(parsedInput.actionClassId);
|
||||
if (actionClass === null) {
|
||||
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
|
||||
ctx.auditLoggingCtx.oldObject = actionClass;
|
||||
const result = await updateActionClass(
|
||||
actionClass.environmentId,
|
||||
parsedInput.actionClassId,
|
||||
parsedInput.updatedAction
|
||||
);
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await updateActionClass(
|
||||
actionClass.environmentId,
|
||||
parsedInput.actionClassId,
|
||||
parsedInput.updatedAction
|
||||
);
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetActiveInactiveSurveysAction = z.object({
|
||||
actionClassId: ZId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -8,23 +8,40 @@ import { ActionDetailModal } from "./ActionDetailModal";
|
||||
// Import mocked components
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
|
||||
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
|
||||
<div data-testid="modal-with-tabs">
|
||||
<span data-testid="modal-label">{label}</span>
|
||||
<span data-testid="modal-description">{description}</span>
|
||||
<span data-testid="modal-open">{open.toString()}</span>
|
||||
<button onClick={() => setOpen(false)}>Close</button>
|
||||
{icon}
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.title}>
|
||||
<h2>{tab.title}</h2>
|
||||
{tab.children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
// Mock the Dialog components
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="dialog">
|
||||
{children}
|
||||
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-content">{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-header">{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<h2 data-testid="dialog-title">{children}</h2>
|
||||
),
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<p data-testid="dialog-description">{children}</p>
|
||||
),
|
||||
DialogBody: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-body">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./ActionActivityTab", () => ({
|
||||
@@ -44,6 +61,22 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useTranslate
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
const translations = {
|
||||
"common.activity": "Activity",
|
||||
"common.settings": "Settings",
|
||||
"common.no_code": "No Code",
|
||||
"common.action": "Action",
|
||||
"common.code": "Code",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
@@ -89,58 +122,68 @@ describe("ActionDetailModal", () => {
|
||||
vi.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
test("renders ModalWithTabs with correct props", () => {
|
||||
test("renders correctly when open", () => {
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action");
|
||||
expect(screen.getByTestId("code-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
// Only the first tab (Activity) should be active initially
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockedModalWithTabs).toHaveBeenCalled();
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
test("does not render when open is false", () => {
|
||||
render(<ActionDetailModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check basic props
|
||||
expect(props.open).toBe(true);
|
||||
expect(props.setOpen).toBe(mockSetOpen);
|
||||
expect(props.label).toBe(mockActionClass.name);
|
||||
expect(props.description).toBe(mockActionClass.description);
|
||||
test("switches tabs correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
// Check icon data-testid based on the mock for the default 'code' type
|
||||
expect(props.icon).toBeDefined();
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
|
||||
// Initially shows activity tab (first tab is active)
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
|
||||
// Check tabs structure
|
||||
expect(props.tabs).toHaveLength(2);
|
||||
expect(props.tabs[0].title).toBe("common.activity");
|
||||
expect(props.tabs[1].title).toBe("common.settings");
|
||||
// Click settings tab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
|
||||
// Check if the correct mocked components are used as children
|
||||
// Access the mocked functions directly
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
|
||||
// Now shows settings tab content
|
||||
expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
// Click activity tab again
|
||||
const activityTab = screen.getByText("Activity");
|
||||
await user.click(activityTab);
|
||||
|
||||
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
|
||||
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
|
||||
// Back to activity tab content
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check props passed to child components
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
|
||||
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
|
||||
expect(activityTabProps.isReadOnly).toBe(false);
|
||||
expect(activityTabProps.environment).toBe(mockEnvironment);
|
||||
expect(activityTabProps.actionClass).toBe(mockActionClass);
|
||||
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
|
||||
test("resets to first tab when modal is reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.actionClass).toBe(mockActionClass);
|
||||
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
|
||||
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
|
||||
expect(settingsTabProps.isReadOnly).toBe(false);
|
||||
// Switch to settings tab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
|
||||
|
||||
// Close modal
|
||||
rerender(<ActionDetailModal {...defaultProps} open={false} />);
|
||||
|
||||
// Reopen modal
|
||||
rerender(<ActionDetailModal {...defaultProps} open={true} />);
|
||||
|
||||
// Should be back to activity tab (first tab)
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correct icon based on action type", () => {
|
||||
@@ -148,33 +191,68 @@ describe("ActionDetailModal", () => {
|
||||
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
expect(screen.getByTestId("nocode-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expect the 'nocode-icon' based on the updated mock and action type
|
||||
expect(props.icon).toBeDefined();
|
||||
test("handles action without description", () => {
|
||||
const actionWithoutDescription = { ...mockActionClass, description: "" };
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
|
||||
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action");
|
||||
});
|
||||
|
||||
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
|
||||
test("passes correct props to ActionActivityTab", () => {
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
expect(mockedActionActivityTab).toHaveBeenCalledWith(
|
||||
{
|
||||
otherEnvActionClasses: mockOtherEnvActionClasses,
|
||||
otherEnvironment: mockOtherEnvironment,
|
||||
isReadOnly: false,
|
||||
environment: mockEnvironment,
|
||||
actionClass: mockActionClass,
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("passes correct props to ActionSettingsTab when tab is active", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
// ActionSettingsTab should not be called initially since first tab is active
|
||||
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
|
||||
expect(mockedActionSettingsTab).not.toHaveBeenCalled();
|
||||
|
||||
// Click the settings tab to activate ActionSettingsTab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
|
||||
// Now ActionSettingsTab should be called with correct props
|
||||
expect(mockedActionSettingsTab).toHaveBeenCalledWith(
|
||||
{
|
||||
actionClass: mockActionClass,
|
||||
actionClasses: mockActionClasses,
|
||||
setOpen: mockSetOpen,
|
||||
isReadOnly: false,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("passes isReadOnly prop correctly", () => {
|
||||
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
|
||||
// Access the mocked component directly
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.isReadOnly).toBe(true);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.isReadOnly).toBe(true);
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
expect(mockedActionActivityTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isReadOnly: true,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,16 @@ export const ActionDetailModal = ({
|
||||
},
|
||||
];
|
||||
|
||||
const typeDescription = () => {
|
||||
if (actionClass.description) return actionClass.description;
|
||||
else
|
||||
return (
|
||||
(actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) +
|
||||
" " +
|
||||
t("common.action").toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalWithTabs
|
||||
@@ -67,7 +77,7 @@ export const ActionDetailModal = ({
|
||||
tabs={tabs}
|
||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
label={actionClass.name}
|
||||
description={actionClass.description || ""}
|
||||
description={typeDescription()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,22 +11,21 @@ export const ActionClassDataRow = ({
|
||||
locale: TUserLocale;
|
||||
}) => {
|
||||
return (
|
||||
<div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
|
||||
<div className="m-2 grid grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-start py-3 pl-6 text-sm">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="mt-1 h-5 w-5 flex-shrink-0 text-slate-500">
|
||||
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-medium text-slate-900">{actionClass.name}</div>
|
||||
<div className="text-xs text-slate-400">{actionClass.description}</div>
|
||||
<div className="text-left">
|
||||
<div className="break-words font-medium text-slate-900">{actionClass.name}</div>
|
||||
<div className="break-words text-xs text-slate-400">{actionClass.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -210,14 +210,13 @@ export const ActionSettingsTab = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
||||
<div>
|
||||
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!isReadOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setOpenDeleteDialog(true)}
|
||||
className="mr-3"
|
||||
id="deleteActionModalTrigger">
|
||||
<TrashIcon />
|
||||
{t("common.delete")}
|
||||
|
||||
@@ -22,14 +22,29 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, ...props }: any) =>
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal" {...props}>
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
|
||||
DialogTitle: ({ children, className }: any) => (
|
||||
<h2 data-testid="dialog-title" className={className}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-description">{children}</div>
|
||||
),
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
@@ -70,17 +85,21 @@ describe("AddActionModal", () => {
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens the modal when the 'Add Action' button is clicked", async () => {
|
||||
test("opens the dialog when the 'Add Action' button is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -108,35 +127,35 @@ describe("AddActionModal", () => {
|
||||
expect(props.setActionClasses).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("closes the modal when the close button (simulated) is clicked", async () => {
|
||||
test("closes the dialog when the close button (simulated) is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked Modal's close button
|
||||
const closeModalButton = screen.getByText("Close Modal");
|
||||
await userEvent.click(closeModalButton);
|
||||
// Simulate closing via the mocked Dialog's close button
|
||||
const closeDialogButton = screen.getByText("Close Dialog");
|
||||
await userEvent.click(closeDialogButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
|
||||
test("closes the dialog when setOpen is called from CreateNewActionTab", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked CreateNewActionTab's button
|
||||
const closeFromTabButton = screen.getByText("Close from Tab");
|
||||
await userEvent.click(closeFromTabButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -26,36 +33,26 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
|
||||
{t("common.add_action")}
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<MousePointerClickIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.actions.track_new_user_action")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<MousePointerClickIcon />
|
||||
<DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -101,6 +101,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
isPendingDowngrade={isPendingDowngrade ?? false}
|
||||
active={active}
|
||||
environmentId={environment.id}
|
||||
locale={user.locale}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -10,6 +10,17 @@ import { TUser } from "@formbricks/types/user";
|
||||
import { getLatestStableFbReleaseAction } from "../actions/actions";
|
||||
import { MainNavigation } from "./MainNavigation";
|
||||
|
||||
// Mock constants that this test needs
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
// Mock server actions that this test needs
|
||||
vi.mock("@/modules/auth/actions/sign-out", () => ({
|
||||
logSignOutAction: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||||
@@ -18,6 +29,9 @@ vi.mock("next/navigation", () => ({
|
||||
vi.mock("next-auth/react", () => ({
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
||||
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
||||
getLatestStableFbReleaseAction: vi.fn(),
|
||||
}));
|
||||
@@ -203,7 +217,11 @@ describe("MainNavigation", () => {
|
||||
});
|
||||
|
||||
test("renders user dropdown and handles logout", async () => {
|
||||
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
|
||||
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
|
||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||
|
||||
// Set up localStorage spy on the mocked localStorage
|
||||
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
// Find the avatar and get its parent div which acts as the trigger
|
||||
@@ -224,7 +242,15 @@ describe("MainNavigation", () => {
|
||||
const logoutButton = screen.getByText("common.logout");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "org1",
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
@@ -90,6 +90,7 @@ export const MainNavigation = ({
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const project = projects.find((project) => project.id === environment.projectId);
|
||||
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
@@ -389,8 +390,15 @@ export const MainNavigation = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
|
||||
router.push(route.url);
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { EnvironmentContextWrapper, useEnvironment } from "./environment-context";
|
||||
|
||||
// Mock environment data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "test-project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
// Mock project data
|
||||
const mockProject = {
|
||||
id: "test-project-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "test-org-id",
|
||||
config: {
|
||||
channel: "app",
|
||||
industry: "saas",
|
||||
},
|
||||
linkSurveyBranding: true,
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: {
|
||||
light: "#ffffff",
|
||||
dark: "#000000",
|
||||
},
|
||||
questionColor: {
|
||||
light: "#000000",
|
||||
dark: "#ffffff",
|
||||
},
|
||||
inputColor: {
|
||||
light: "#000000",
|
||||
dark: "#ffffff",
|
||||
},
|
||||
inputBorderColor: {
|
||||
light: "#cccccc",
|
||||
dark: "#444444",
|
||||
},
|
||||
cardBackgroundColor: {
|
||||
light: "#ffffff",
|
||||
dark: "#000000",
|
||||
},
|
||||
cardBorderColor: {
|
||||
light: "#cccccc",
|
||||
dark: "#444444",
|
||||
},
|
||||
isDarkModeEnabled: false,
|
||||
isLogoHidden: false,
|
||||
hideProgressBar: false,
|
||||
roundness: 8,
|
||||
cardArrangement: {
|
||||
linkSurveys: "casual",
|
||||
appSurveys: "casual",
|
||||
},
|
||||
},
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
logo: {
|
||||
url: "test-logo.png",
|
||||
bgColor: "#ffffff",
|
||||
},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
} as TProject;
|
||||
|
||||
// Test component that uses the hook
|
||||
const TestComponent = () => {
|
||||
const { environment, project } = useEnvironment();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="environment-id">{environment.id}</div>
|
||||
<div data-testid="environment-type">{environment.type}</div>
|
||||
<div data-testid="project-id">{project.id}</div>
|
||||
<div data-testid="project-organization-id">{project.organizationId}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe("EnvironmentContext", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("provides environment and project data to child components", () => {
|
||||
render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
||||
expect(screen.getByTestId("project-organization-id")).toHaveTextContent("test-org-id");
|
||||
});
|
||||
|
||||
test("throws error when useEnvironment is used outside of provider", () => {
|
||||
const TestComponentWithoutProvider = () => {
|
||||
useEnvironment();
|
||||
return <div>Should not render</div>;
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponentWithoutProvider />);
|
||||
}).toThrow("useEnvironment must be used within an EnvironmentProvider");
|
||||
});
|
||||
|
||||
test("updates context value when environment or project changes", () => {
|
||||
const { rerender } = render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
||||
|
||||
const updatedEnvironment = {
|
||||
...mockEnvironment,
|
||||
type: "production" as const,
|
||||
};
|
||||
|
||||
rerender(
|
||||
<EnvironmentContextWrapper environment={updatedEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("production");
|
||||
});
|
||||
|
||||
test("memoizes context value correctly", () => {
|
||||
const { rerender } = render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
// Re-render with same props
|
||||
rerender(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
// Should still work correctly
|
||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
}
|
||||
|
||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||
|
||||
export const useEnvironment = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
throw new Error("useEnvironment must be used within an EnvironmentProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
project,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
}),
|
||||
[environment, project]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromIntegrationId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromIntegrationId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZIntegrationInput } from "@formbricks/types/integration";
|
||||
@@ -20,48 +22,79 @@ const ZCreateOrUpdateIntegrationAction = z.object({
|
||||
|
||||
export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
.schema(ZCreateOrUpdateIntegrationAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"createdUpdated",
|
||||
"integration",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
return await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
||||
});
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const result = await createOrUpdateIntegration(
|
||||
parsedInput.environmentId,
|
||||
parsedInput.integrationData
|
||||
);
|
||||
ctx.auditLoggingCtx.integrationId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteIntegrationAction = z.object({
|
||||
integrationId: ZId,
|
||||
});
|
||||
|
||||
export const deleteIntegrationAction = authenticatedActionClient
|
||||
.schema(ZDeleteIntegrationAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromIntegrationId(parsedInput.integrationId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"integration",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const organizationId = await getOrganizationIdFromIntegrationId(parsedInput.integrationId);
|
||||
|
||||
return await deleteIntegration(parsedInput.integrationId);
|
||||
});
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.integrationId = parsedInput.integrationId;
|
||||
const result = await deleteIntegration(parsedInput.integrationId);
|
||||
ctx.auditLoggingCtx.oldObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -92,14 +92,24 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen }) =>
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
|
||||
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children }) => <div data-testid="alert">{children}</div>,
|
||||
|
||||
@@ -10,8 +10,16 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,11 +27,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
@@ -68,6 +76,80 @@ const NoBaseFoundError = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderQuestionSelection = ({
|
||||
t,
|
||||
selectedSurvey,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeHiddenFields,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
}: {
|
||||
t: TFnType;
|
||||
selectedSurvey: TSurvey;
|
||||
control: Control<IntegrationModalInputs>;
|
||||
includeVariables: boolean;
|
||||
setIncludeVariables: (value: boolean) => void;
|
||||
includeHiddenFields: boolean;
|
||||
includeMetadata: boolean;
|
||||
setIncludeHiddenFields: (value: boolean) => void;
|
||||
setIncludeMetadata: (value: boolean) => void;
|
||||
includeCreatedAt: boolean;
|
||||
setIncludeCreatedAt: (value: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<Controller
|
||||
key={question.id}
|
||||
control={control}
|
||||
name={"questions"}
|
||||
render={({ field }) => (
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={question.id}
|
||||
value={question.id}
|
||||
className="bg-white"
|
||||
checked={field.value?.includes(question.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([...field.value, question.id])
|
||||
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
setIncludeMetadata={setIncludeMetadata}
|
||||
includeCreatedAt={includeCreatedAt}
|
||||
setIncludeCreatedAt={setIncludeCreatedAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddIntegrationModal = ({
|
||||
open,
|
||||
setOpenWithStates,
|
||||
@@ -210,182 +292,148 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={handleClose} noPadding>
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent className="overflow-visible md:overflow-visible">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" />
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={AirtableLogo}
|
||||
alt={t("environments.integrations.airtable.airtable_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.airtable.link_airtable_table")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.airtable.sync_responses_with_airtable")}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(submitHandler)}>
|
||||
<div className="flex rounded-lg p-6">
|
||||
<div className="flex w-full flex-col gap-y-4 pt-5">
|
||||
{airtableArray.length ? (
|
||||
<BaseSelectDropdown
|
||||
control={control}
|
||||
isLoading={isLoading}
|
||||
fetchTable={fetchTable}
|
||||
airtableArray={airtableArray}
|
||||
setValue={setValue}
|
||||
defaultValue={defaultData?.base}
|
||||
/>
|
||||
) : (
|
||||
<NoBaseFoundError />
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
|
||||
<DialogBody className="overflow-visible">
|
||||
<div className="flex w-full flex-col gap-y-4">
|
||||
{airtableArray.length ? (
|
||||
<BaseSelectDropdown
|
||||
control={control}
|
||||
name="table"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
disabled={!tables.length}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
}}
|
||||
defaultValue={defaultData?.table}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
{tables.length ? (
|
||||
<SelectContent>
|
||||
{tables.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
) : null}
|
||||
</Select>
|
||||
)}
|
||||
isLoading={isLoading}
|
||||
fetchTable={fetchTable}
|
||||
airtableArray={airtableArray}
|
||||
setValue={setValue}
|
||||
defaultValue={defaultData?.base}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NoBaseFoundError />
|
||||
)}
|
||||
|
||||
{surveys.length ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="survey">{t("common.select_survey")}</Label>
|
||||
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="survey"
|
||||
name="table"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
disabled={!tables.length}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("questions", []);
|
||||
}}
|
||||
defaultValue={defaultData?.survey}>
|
||||
defaultValue={defaultData?.table}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
{tables.length ? (
|
||||
<SelectContent>
|
||||
{tables.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
) : null}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!surveys.length ? (
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.create_survey_warning")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{survey && selectedSurvey && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<Controller
|
||||
key={question.id}
|
||||
control={control}
|
||||
name={"questions"}
|
||||
render={({ field }) => (
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={question.id}
|
||||
value={question.id}
|
||||
className="bg-white"
|
||||
checked={field.value?.includes(question.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([...field.value, question.id])
|
||||
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getLocalizedValue(question.headline, "default")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{surveys.length ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="survey">{t("common.select_survey")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="survey"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("questions", []);
|
||||
}}
|
||||
defaultValue={defaultData?.survey}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
setIncludeMetadata={setIncludeMetadata}
|
||||
includeCreatedAt={includeCreatedAt}
|
||||
setIncludeCreatedAt={setIncludeCreatedAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-x-2">
|
||||
{isEditMode ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleDelete(defaultData.index);
|
||||
}}
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
variant="destructive">
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.create_survey_warning")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit">{t("common.save")}</Button>
|
||||
{survey &&
|
||||
selectedSurvey &&
|
||||
renderQuestionSelection({
|
||||
t,
|
||||
selectedSurvey,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeHiddenFields,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{isEditMode ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleDelete(defaultData.index);
|
||||
}}
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
variant="destructive">
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="submit">{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,6 +49,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integration/service");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
|
||||
@@ -88,9 +88,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
|
||||
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -304,10 +319,9 @@ describe("AddIntegrationModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
@@ -332,10 +346,9 @@ describe("AddIntegrationModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
|
||||
@@ -14,10 +14,18 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -202,31 +210,28 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image
|
||||
className="w-12"
|
||||
src={GoogleSheetLogo}
|
||||
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={GoogleSheetLogo}
|
||||
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(linkSheet)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
|
||||
<DialogBody>
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isLinkingSheet}>
|
||||
{selectedIntegration
|
||||
? t("common.update")
|
||||
: t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isLinkingSheet}>
|
||||
{selectedIntegration
|
||||
? t("common.update")
|
||||
: t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -74,13 +74,41 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="dialog">{children}</div> : null,
|
||||
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-header" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<p data-testid="dialog-description" className={className}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<h2 data-testid="dialog-title">{children}</h2>
|
||||
),
|
||||
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-body" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-footer" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
PlusIcon: () => <span data-testid="plus-icon">+</span>,
|
||||
XIcon: () => <span data-testid="x-icon">x</span>,
|
||||
TrashIcon: () => <span data-testid="trash-icon">🗑️</span>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -334,7 +362,7 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
|
||||
@@ -359,7 +387,7 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
|
||||
@@ -381,7 +409,7 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
expect(columnDropdowns[1]).toHaveValue("p2");
|
||||
|
||||
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
@@ -445,8 +473,8 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
|
||||
|
||||
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
|
||||
await userEvent.click(xButton);
|
||||
const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button
|
||||
await userEvent.click(trashButton);
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -12,11 +12,19 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, XIcon } from "lucide-react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -336,9 +344,9 @@ export const AddIntegrationModal = ({
|
||||
col={mapping[idx].column}
|
||||
ques={mapping[idx].question}
|
||||
/>
|
||||
<div className="flex w-full items-center">
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<div className="flex w-full items-center">
|
||||
<div className="w-[340px] max-w-full">
|
||||
<div className="max-w-full flex-1">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
||||
items={filteredQuestionItems}
|
||||
@@ -384,7 +392,7 @@ export const AddIntegrationModal = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="h-px w-4 border-t border-t-slate-300" />
|
||||
<div className="w-[340px] max-w-full">
|
||||
<div className="max-w-full flex-1">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
|
||||
items={getFilteredDbItems()}
|
||||
@@ -430,53 +438,45 @@ export const AddIntegrationModal = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1 hover:bg-slate-300 ${
|
||||
idx === mapping.length - 1 ? "visible" : "invisible"
|
||||
}`}
|
||||
onClick={addRow}>
|
||||
<PlusIcon className="h-5 w-5 font-bold text-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 rounded-md p-1 hover:bg-red-100 ${
|
||||
mapping.length > 1 ? "visible" : "invisible"
|
||||
}`}
|
||||
onClick={deleteRow}>
|
||||
<XIcon className="h-5 w-5 text-red-500" />
|
||||
</button>
|
||||
<div className="flex space-x-2">
|
||||
{mapping.length > 1 && (
|
||||
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg">
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image
|
||||
className="w-12"
|
||||
src={NotionLogo}
|
||||
alt={t("environments.integrations.notion.notion_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.notion.link_notion_database")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 flex items-start space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={NotionLogo}
|
||||
alt={t("environments.integrations.notion.notion_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.notion.notion_integration_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4">
|
||||
<DialogBody>
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
|
||||
<Label>
|
||||
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
|
||||
</Label>
|
||||
<div className="mt-4 max-h-[20vh] w-full overflow-y-auto">
|
||||
<div className="mt-1 space-y-2 overflow-y-auto">
|
||||
{mapping.map((_, idx) => (
|
||||
<MappingRow idx={idx} key={idx} />
|
||||
))}
|
||||
@@ -530,43 +530,40 @@ export const AddIntegrationModal = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
setMapping([]);
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLinkingDatabase}
|
||||
disabled={mapping.filter((m) => m.error).length > 0}>
|
||||
{selectedIntegration
|
||||
? t("common.update")
|
||||
: t("environments.integrations.notion.link_database")}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
setMapping([]);
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLinkingDatabase}
|
||||
disabled={mapping.filter((m) => m.error).length > 0}>
|
||||
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,6 +32,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getSlackChannels } from "@/lib/slack/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
@@ -83,9 +83,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
|
||||
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -121,6 +136,8 @@ vi.mock("@tolgee/react", async () => {
|
||||
if (key === "common.all_questions") return "All questions";
|
||||
if (key === "common.selected_questions") return "Selected questions";
|
||||
if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel";
|
||||
if (key === "environments.integrations.slack.slack_integration_description")
|
||||
return "Send responses directly to Slack.";
|
||||
if (key === "common.update") return "Update";
|
||||
if (key === "common.delete") return "Delete";
|
||||
if (key === "common.cancel") return "Cancel";
|
||||
@@ -312,10 +329,9 @@ describe("AddChannelMappingModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
|
||||
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
@@ -339,10 +355,9 @@ describe("AddChannelMappingModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
|
||||
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
|
||||
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Questions")).toBeInTheDocument();
|
||||
|
||||
@@ -7,9 +7,17 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
@@ -189,24 +197,28 @@ export const AddChannelMappingModal = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image className="w-12" src={SlackLogo} alt="Slack logo" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.slack.link_slack_channel")}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={SlackLogo}
|
||||
alt={t("environments.integrations.slack.slack_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.slack.link_slack_channel")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.slack.slack_integration_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(linkChannel)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(linkChannel)}>
|
||||
<DialogBody>
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -289,31 +301,29 @@ export const AddChannelMappingModal = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{selectedIntegration ? (
|
||||
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isLinkingChannel}>
|
||||
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isLinkingChannel}>
|
||||
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
@@ -5,6 +6,7 @@ import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { Session } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
@@ -13,12 +15,20 @@ import EnvLayout from "./layout";
|
||||
|
||||
// Mock sub-components to render identifiable elements
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
|
||||
EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>,
|
||||
EnvironmentLayout: ({ children, environmentId, session }: any) => (
|
||||
<div data-testid="EnvironmentLayout" data-environment-id={environmentId} data-session={session?.user?.id}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
||||
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
|
||||
<div data-testid="EnvironmentIdBaseLayout">
|
||||
{environmentId}
|
||||
EnvironmentIdBaseLayout: ({ children, environmentId, session, user, organization }: any) => (
|
||||
<div
|
||||
data-testid="EnvironmentIdBaseLayout"
|
||||
data-environment-id={environmentId}
|
||||
data-session={session?.user?.id}
|
||||
data-user={user?.id}
|
||||
data-organization={organization?.id}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
@@ -27,7 +37,24 @@ vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||
ToasterClient: () => <div data-testid="ToasterClient" />,
|
||||
}));
|
||||
vi.mock("./components/EnvironmentStorageHandler", () => ({
|
||||
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
|
||||
default: ({ environmentId }: any) => (
|
||||
<div data-testid="EnvironmentStorageHandler" data-environment-id={environmentId} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
|
||||
EnvironmentContextWrapper: ({ children, environment, project }: any) => (
|
||||
<div
|
||||
data-testid="EnvironmentContextWrapper"
|
||||
data-environment-id={environment?.id}
|
||||
data-project-id={project?.id}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mocks for dependencies
|
||||
@@ -37,26 +64,43 @@ vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getProjectByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("EnvLayout", () => {
|
||||
const mockSession = { user: { id: "user1" } } as Session;
|
||||
const mockUser = { id: "user1", email: "user1@example.com" } as TUser;
|
||||
const mockOrganization = { id: "org1", name: "Org1", billing: {} } as TOrganization;
|
||||
const mockProject = { id: "proj1", name: "Test Project" } as TProject;
|
||||
const mockEnvironment = { id: "env1", type: "production" } as TEnvironment;
|
||||
const mockMembership = {
|
||||
id: "member1",
|
||||
role: "owner",
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
} as TMembership;
|
||||
const mockTranslation = ((key: string) => key) as any;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders successfully when all dependencies return valid data", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
|
||||
id: "member1",
|
||||
} as unknown as TMembership);
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
|
||||
|
||||
const result = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
@@ -64,56 +108,43 @@ describe("EnvLayout", () => {
|
||||
});
|
||||
render(result);
|
||||
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
|
||||
// Verify main layout structure
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-environment-id", "env1");
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-session", "user1");
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-user", "user1");
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-organization", "org1");
|
||||
|
||||
// Verify environment storage handler
|
||||
expect(screen.getByTestId("EnvironmentStorageHandler")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveAttribute("data-environment-id", "env1");
|
||||
|
||||
// Verify context wrapper
|
||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1");
|
||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-project-id", "proj1");
|
||||
|
||||
// Verify environment layout
|
||||
expect(screen.getByTestId("EnvironmentLayout")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-environment-id", "env1");
|
||||
expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-session", "user1");
|
||||
|
||||
// Verify children are rendered
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Content");
|
||||
|
||||
// Verify all services were called with correct parameters
|
||||
expect(environmentIdLayoutChecks).toHaveBeenCalledWith("env1");
|
||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env1");
|
||||
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
|
||||
});
|
||||
|
||||
test("throws error if project is not found", async () => {
|
||||
test("redirects when session is null", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
|
||||
id: "member1",
|
||||
} as unknown as TMembership);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.project_not_found");
|
||||
});
|
||||
|
||||
test("throws error if membership is not found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.membership_not_found");
|
||||
});
|
||||
|
||||
test("calls redirect when session is null", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: undefined as unknown as Session,
|
||||
user: undefined as unknown as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
t: mockTranslation,
|
||||
session: null as unknown as Session,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||
throw new Error("Redirect called");
|
||||
@@ -125,18 +156,16 @@ describe("EnvLayout", () => {
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("Redirect called");
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws error if user is null", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: ((key: string) => key) as any,
|
||||
session: { user: { id: "user1" } } as Session,
|
||||
user: undefined as unknown as TUser,
|
||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||
});
|
||||
|
||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||
throw new Error("Redirect called");
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: null as unknown as TUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -145,5 +174,154 @@ describe("EnvLayout", () => {
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.user_not_found");
|
||||
|
||||
// Verify redirect was not called
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws error if project is not found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.project_not_found");
|
||||
|
||||
// Verify both project and environment were called in Promise.all
|
||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env1");
|
||||
});
|
||||
|
||||
test("throws error if environment is not found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.environment_not_found");
|
||||
|
||||
// Verify both project and environment were called in Promise.all
|
||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env1");
|
||||
});
|
||||
|
||||
test("throws error if membership is not found", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div>Content</div>,
|
||||
})
|
||||
).rejects.toThrow("common.membership_not_found");
|
||||
|
||||
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
|
||||
});
|
||||
|
||||
test("handles Promise.all correctly for project and environment", async () => {
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
|
||||
// Mock Promise.all to verify it's called correctly
|
||||
const getProjectSpy = vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
const getEnvironmentSpy = vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
|
||||
|
||||
const result = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div data-testid="child">Content</div>,
|
||||
});
|
||||
render(result);
|
||||
|
||||
// Verify both calls were made
|
||||
expect(getProjectSpy).toHaveBeenCalledWith("env1");
|
||||
expect(getEnvironmentSpy).toHaveBeenCalledWith("env1");
|
||||
|
||||
// Verify successful rendering
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles different environment types correctly", async () => {
|
||||
const developmentEnvironment = { id: "env1", type: "development" } as TEnvironment;
|
||||
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(developmentEnvironment);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
|
||||
|
||||
const result = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div data-testid="child">Content</div>,
|
||||
});
|
||||
render(result);
|
||||
|
||||
// Verify context wrapper receives the development environment
|
||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1");
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles different user roles correctly", async () => {
|
||||
const memberMembership = {
|
||||
id: "member1",
|
||||
role: "member",
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
} as TMembership;
|
||||
|
||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||
t: mockTranslation,
|
||||
session: mockSession,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(memberMembership);
|
||||
|
||||
const result = await EnvLayout({
|
||||
params: Promise.resolve({ environmentId: "env1" }),
|
||||
children: <div data-testid="child">Content</div>,
|
||||
});
|
||||
render(result);
|
||||
|
||||
// Verify successful rendering with member role
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
@@ -11,7 +13,6 @@ const EnvLayout = async (props: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||
@@ -24,11 +25,19 @@ const EnvLayout = async (props: {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(params.environmentId);
|
||||
const [project, environment] = await Promise.all([
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
|
||||
if (!membership) {
|
||||
@@ -42,9 +51,11 @@ const EnvLayout = async (props: {
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||
{children}
|
||||
</EnvironmentLayout>
|
||||
<EnvironmentContextWrapper environment={environment} project={project}>
|
||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||
{children}
|
||||
</EnvironmentLayout>
|
||||
</EnvironmentContextWrapper>
|
||||
</EnvironmentIdBaseLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,14 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://example.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("AppConnectionPage Re-export", () => {
|
||||
|
||||
@@ -25,6 +25,14 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("GeneralSettingsPage re-export", () => {
|
||||
|
||||
@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
describe("LanguagesPage re-export", () => {
|
||||
|
||||
@@ -25,6 +25,14 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ProjectLookSettingsPage re-export", () => {
|
||||
|
||||
@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
describe("TagsPage re-export", () => {
|
||||
|
||||
@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
describe("ProjectTeams re-export", () => {
|
||||
|
||||
@@ -41,6 +41,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { z } from "zod";
|
||||
import { ZUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
@@ -11,8 +13,25 @@ const ZUpdateNotificationSettingsAction = z.object({
|
||||
|
||||
export const updateNotificationSettingsAction = authenticatedActionClient
|
||||
.schema(ZUpdateNotificationSettingsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await updateUser(ctx.user.id, {
|
||||
notificationSettings: parsedInput.notificationSettings,
|
||||
});
|
||||
});
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
const result = await updateUser(ctx.user.id, {
|
||||
notificationSettings: parsedInput.notificationSettings,
|
||||
});
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/modules/ui/components/switch", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
updateNotificationSettingsAction: vi.fn(() => Promise.resolve()),
|
||||
updateNotificationSettingsAction: vi.fn(() => Promise.resolve({ data: true })),
|
||||
}));
|
||||
|
||||
const surveyId = "survey1";
|
||||
@@ -246,4 +246,204 @@ describe("NotificationSwitch", () => {
|
||||
});
|
||||
expect(updateNotificationSettingsAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction fails for 'alert' type", async () => {
|
||||
const mockErrorResponse = { serverError: "Failed to update notification settings" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("Failed to update notification settings", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction fails for 'weeklySummary' type", async () => {
|
||||
const mockErrorResponse = { serverError: "Database connection failed" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
|
||||
renderSwitch({
|
||||
surveyOrProjectOrOrganizationId: projectId,
|
||||
notificationSettings: initialSettings,
|
||||
notificationType: "weeklySummary",
|
||||
});
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for weeklySummary");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, weeklySummary: { [projectId]: false } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("Database connection failed", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
|
||||
const mockErrorResponse = { serverError: "Permission denied" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
|
||||
renderSwitch({
|
||||
surveyOrProjectOrOrganizationId: organizationId,
|
||||
notificationSettings: initialSettings,
|
||||
notificationType: "unsubscribedOrganizationIds",
|
||||
});
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [organizationId] },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("Permission denied", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction returns null", async () => {
|
||||
const mockErrorResponse = { serverError: "An error occurred" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction returns undefined", async () => {
|
||||
const mockErrorResponse = { serverError: "An error occurred" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction returns response without data property", async () => {
|
||||
const mockErrorResponse = { validationErrors: { _errors: ["Invalid input"] } };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("Invalid input", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction throws an exception", async () => {
|
||||
const mockErrorResponse = { serverError: "Network error" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("Network error", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("switch remains enabled after error occurs", async () => {
|
||||
const mockErrorResponse = { serverError: "Failed to update" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Failed to update", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(switchInput).toBeEnabled(); // Switch should be re-enabled after error
|
||||
});
|
||||
|
||||
test("shows error toast with validation errors for specific fields", async () => {
|
||||
const mockErrorResponse = {
|
||||
validationErrors: {
|
||||
notificationSettings: {
|
||||
_errors: ["Invalid notification settings"],
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
|
||||
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for alert");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("notificationSettingsInvalid notification settings", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
@@ -24,6 +26,7 @@ export const NotificationSwitch = ({
|
||||
}: NotificationSwitchProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
const isChecked =
|
||||
notificationType === "unsubscribedOrganizationIds"
|
||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
||||
@@ -50,7 +53,20 @@ export const NotificationSwitch = ({
|
||||
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
|
||||
}
|
||||
|
||||
await updateNotificationSettingsAction({ notificationSettings: updatedNotificationSettings });
|
||||
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
if (updatedNotificationSettingsActionResponse?.data) {
|
||||
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
|
||||
id: "notification-switch",
|
||||
});
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedNotificationSettingsActionResponse);
|
||||
toast.error(errorMessage, {
|
||||
id: "notification-switch",
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
@@ -104,9 +120,6 @@ export const NotificationSwitch = ({
|
||||
disabled={isLoading}
|
||||
onCheckedChange={async () => {
|
||||
await handleSwitchChange();
|
||||
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
|
||||
id: "notification-switch",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { deleteFile } from "@/lib/storage/service";
|
||||
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
@@ -27,93 +29,154 @@ const limiter = rateLimit({
|
||||
allowedPerInterval: 3, // max 3 calls for email verification per hour
|
||||
});
|
||||
|
||||
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
||||
return {
|
||||
...(parsedInput.name && { name: parsedInput.name }),
|
||||
...(parsedInput.locale && { locale: parsedInput.locale }),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleEmailUpdate({
|
||||
ctx,
|
||||
parsedInput,
|
||||
payload,
|
||||
}: {
|
||||
ctx: any;
|
||||
parsedInput: any;
|
||||
payload: TUserUpdateInput;
|
||||
}) {
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
if (!inputEmail || ctx.user.email === inputEmail) return payload;
|
||||
|
||||
try {
|
||||
await limiter(ctx.user.id);
|
||||
} catch {
|
||||
throw new TooManyRequestsError("Too many requests");
|
||||
}
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||
}
|
||||
if (!parsedInput.password) {
|
||||
throw new AuthenticationError("Password is required to update email.");
|
||||
}
|
||||
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
|
||||
if (!isCorrectPassword) {
|
||||
throw new AuthorizationError("Incorrect credentials");
|
||||
}
|
||||
const isEmailUnique = await getIsEmailUnique(inputEmail);
|
||||
if (!isEmailUnique) return payload;
|
||||
|
||||
if (EMAIL_VERIFICATION_DISABLED) {
|
||||
payload.email = inputEmail;
|
||||
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
|
||||
} else {
|
||||
await sendVerificationNewEmail(ctx.user.id, inputEmail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
.schema(
|
||||
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
|
||||
password: ZUserPassword.optional(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
|
||||
let payload: TUserUpdateInput = {
|
||||
...(parsedInput.name && { name: parsedInput.name }),
|
||||
...(parsedInput.locale && { locale: parsedInput.locale }),
|
||||
};
|
||||
|
||||
// Only process email update if a new email is provided and it's different from current email
|
||||
if (inputEmail && ctx.user.email !== inputEmail) {
|
||||
// Check rate limit
|
||||
try {
|
||||
await limiter(ctx.user.id);
|
||||
} catch {
|
||||
throw new TooManyRequestsError("Too many requests");
|
||||
}
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||
}
|
||||
|
||||
if (!parsedInput.password) {
|
||||
throw new AuthenticationError("Password is required to update email.");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
|
||||
if (!isCorrectPassword) {
|
||||
throw new AuthorizationError("Incorrect credentials");
|
||||
}
|
||||
|
||||
// Check if the new email is unique, no user exists with the new email
|
||||
const isEmailUnique = await getIsEmailUnique(inputEmail);
|
||||
|
||||
// If the new email is unique, proceed with the email update
|
||||
if (isEmailUnique) {
|
||||
if (EMAIL_VERIFICATION_DISABLED) {
|
||||
payload.email = inputEmail;
|
||||
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
|
||||
} else {
|
||||
await sendVerificationNewEmail(ctx.user.id, inputEmail);
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
if (Object.keys(payload).length > 0) {
|
||||
await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
avatarUrl: z.string(),
|
||||
});
|
||||
|
||||
export const updateAvatarAction = authenticatedActionClient
|
||||
.schema(ZUpdateAvatarAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
return await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
|
||||
});
|
||||
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZRemoveAvatarAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const removeAvatarAction = authenticatedActionClient
|
||||
.schema(ZRemoveAvatarAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const imageUrl = ctx.user.imageUrl;
|
||||
if (!imageUrl) {
|
||||
throw new Error("Image not found");
|
||||
}
|
||||
export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
const imageUrl = ctx.user.imageUrl;
|
||||
if (!imageUrl) {
|
||||
throw new Error("Image not found");
|
||||
}
|
||||
|
||||
const fileName = getFileNameWithIdFromUrl(imageUrl);
|
||||
if (!fileName) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
const fileName = getFileNameWithIdFromUrl(imageUrl);
|
||||
if (!fileName) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
|
||||
if (!deletionResult.success) {
|
||||
throw new Error("Deletion failed");
|
||||
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
|
||||
if (!deletionResult.success) {
|
||||
throw new Error("Deletion failed");
|
||||
}
|
||||
const result = await updateUser(ctx.user.id, { imageUrl: null });
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
return await updateUser(ctx.user.id, { imageUrl: null });
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
export const resetPasswordAction = authenticatedActionClient.action(
|
||||
withAuditLogging(
|
||||
"passwordReset",
|
||||
"user",
|
||||
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { updateUserAction } from "../actions";
|
||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
||||
import { EditProfileDetailsForm } from "./EditProfileDetailsForm";
|
||||
|
||||
const mockUser = {
|
||||
@@ -24,6 +24,8 @@ const mockUser = {
|
||||
objective: "other",
|
||||
} as unknown as TUser;
|
||||
|
||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
||||
|
||||
// Mock window.location.reload
|
||||
const originalLocation = window.location;
|
||||
beforeEach(() => {
|
||||
@@ -35,6 +37,11 @@ beforeEach(() => {
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
|
||||
updateUserAction: vi.fn(),
|
||||
resetPasswordAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/actions", () => ({
|
||||
forgotPasswordAction: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
@@ -50,7 +57,13 @@ describe("EditProfileDetailsForm", () => {
|
||||
test("renders with initial user data and updates successfully", async () => {
|
||||
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
|
||||
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />);
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={true}
|
||||
isPasswordResetEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
expect(nameInput).toHaveValue(mockUser.name);
|
||||
@@ -91,7 +104,13 @@ describe("EditProfileDetailsForm", () => {
|
||||
const errorMessage = "Update failed";
|
||||
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
await userEvent.clear(nameInput);
|
||||
@@ -109,7 +128,13 @@ describe("EditProfileDetailsForm", () => {
|
||||
});
|
||||
|
||||
test("update button is disabled initially and enables on change", async () => {
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={false}
|
||||
/>
|
||||
);
|
||||
const updateButton = screen.getByText("common.update");
|
||||
expect(updateButton).toBeDisabled();
|
||||
|
||||
@@ -117,4 +142,68 @@ describe("EditProfileDetailsForm", () => {
|
||||
await userEvent.type(nameInput, " updated");
|
||||
expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("reset password button works", async () => {
|
||||
vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } });
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetPasswordAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading");
|
||||
});
|
||||
});
|
||||
|
||||
test("reset password button handles error correctly", async () => {
|
||||
const errorMessage = "Reset failed";
|
||||
vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage });
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetPasswordAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
test("reset password button shows loading state", async () => {
|
||||
vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
expect(resetButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -13,17 +14,16 @@ import {
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||
import { updateUserAction } from "../actions";
|
||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
||||
|
||||
// Schema & types
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
|
||||
@@ -31,15 +31,18 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email:
|
||||
});
|
||||
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
|
||||
|
||||
interface IEditProfileDetailsFormProps {
|
||||
user: TUser;
|
||||
isPasswordResetEnabled?: boolean;
|
||||
emailVerificationDisabled: boolean;
|
||||
}
|
||||
|
||||
export const EditProfileDetailsForm = ({
|
||||
user,
|
||||
isPasswordResetEnabled,
|
||||
emailVerificationDisabled,
|
||||
}: {
|
||||
user: TUser;
|
||||
emailVerificationDisabled: boolean;
|
||||
}) => {
|
||||
}: IEditProfileDetailsFormProps) => {
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TEditProfileNameForm>({
|
||||
defaultValues: {
|
||||
@@ -52,7 +55,10 @@ export const EditProfileDetailsForm = ({
|
||||
});
|
||||
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const handleConfirmPassword = async (password: string) => {
|
||||
const values = form.getValues();
|
||||
@@ -86,8 +92,13 @@ export const EditProfileDetailsForm = ({
|
||||
toast.success(t("auth.verification-requested.new_email_verification_success"));
|
||||
} else {
|
||||
toast.success(t("environments.settings.profile.email_change_initiated"));
|
||||
await signOut({ redirect: false });
|
||||
router.push(`/email-change-without-verification-success`);
|
||||
await signOutWithAudit({
|
||||
reason: "email_change",
|
||||
redirectUrl: "/email-change-without-verification-success",
|
||||
redirect: true,
|
||||
callbackUrl: "/email-change-without-verification-success",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -118,6 +129,28 @@ export const EditProfileDetailsForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
setIsResettingPassword(true);
|
||||
|
||||
const result = await resetPasswordAction();
|
||||
if (result?.data) {
|
||||
toast.success(t("auth.forgot-password.email-sent.heading"));
|
||||
|
||||
await signOutWithAudit({
|
||||
reason: "password_reset",
|
||||
redirectUrl: "/auth/login",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
setIsResettingPassword(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
@@ -202,6 +235,26 @@ export const EditProfileDetailsForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{isPasswordResetEnabled && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="reset-password">{t("auth.forgot-password.reset_password")}</Label>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{t("auth.forgot-password.reset_password_description")}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Input type="email" id="reset-password" defaultValue={user.email} disabled />
|
||||
<Button
|
||||
onClick={handleResetPassword}
|
||||
loading={isResettingPassword}
|
||||
disabled={isResettingPassword}
|
||||
size="default"
|
||||
variant="secondary">
|
||||
{t("auth.forgot-password.reset_password")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
|
||||
@@ -4,18 +4,27 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { PasswordConfirmationModal } from "./password-confirmation-modal";
|
||||
|
||||
// Mock the Modal component
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, title }: any) =>
|
||||
// Mock the Dialog component
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button data-testid="modal-close" onClick={() => setOpen(false)}>
|
||||
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
|
||||
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock the PasswordInput component
|
||||
@@ -54,13 +63,13 @@ describe("PasswordConfirmationModal", () => {
|
||||
|
||||
test("renders nothing when open is false", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders modal content when open is true", () => {
|
||||
test("renders dialog content when open is true", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays old and new email addresses", () => {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -54,64 +62,69 @@ export const PasswordConfirmationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.email-change.confirm_password_description")}
|
||||
</p>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("auth.forgot-password.reset.confirm_password")}</DialogTitle>
|
||||
<DialogDescription>{t("auth.email-change.confirm_password_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
|
||||
<p>
|
||||
<strong>{t("auth.email-change.old_email")}:</strong>
|
||||
<br /> {oldEmail.toLowerCase()}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("auth.email-change.new_email")}:</strong>
|
||||
<br /> {newEmail.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
|
||||
<p>
|
||||
<strong>{t("auth.email-change.old_email")}:</strong>
|
||||
<br /> {oldEmail.toLowerCase()}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("auth.email-change.new_email")}:</strong>
|
||||
<br /> {newEmail.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-x-2 text-right">
|
||||
<Button type="button" variant="secondary" onClick={handleCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={handleCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,8 @@ import Page from "./page";
|
||||
|
||||
// Mock services and utils
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
IS_FORMBRICKS_CLOUD: 1,
|
||||
PASSWORD_RESET_DISABLED: 1,
|
||||
EMAIL_VERIFICATION_DISABLED: true,
|
||||
}));
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -32,6 +32,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.account_settings")}>
|
||||
@@ -42,7 +44,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
<SettingsCard
|
||||
title={t("environments.settings.profile.personal_information")}
|
||||
description={t("environments.settings.profile.update_personal_info")}>
|
||||
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
|
||||
<EditProfileDetailsForm
|
||||
user={user}
|
||||
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
|
||||
isPasswordResetEnabled={isPasswordResetEnabled}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("common.avatar")}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { deleteOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -16,43 +18,65 @@ const ZUpdateOrganizationNameAction = z.object({
|
||||
|
||||
export const updateOrganizationNameAction = authenticatedActionClient
|
||||
.schema(ZUpdateOrganizationNameAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
});
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"organization",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteOrganizationAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const deleteOrganizationAction = authenticatedActionClient
|
||||
.schema(ZDeleteOrganizationAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"organization",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await deleteOrganization(parsedInput.organizationId);
|
||||
});
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
return await deleteOrganization(parsedInput.organizationId);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -30,6 +30,14 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("TeamsPage re-export", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { H3, Small } from "@/modules/ui/components/typography";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
export const SettingsCard = ({
|
||||
@@ -31,7 +32,7 @@ export const SettingsCard = ({
|
||||
id={title}>
|
||||
<div className="border-b border-slate-200 px-4 pb-4">
|
||||
<div className="flex">
|
||||
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
|
||||
<H3 className="capitalize">{title}</H3>
|
||||
<div className="ml-2">
|
||||
{beta && <Badge size="normal" type="warning" text="Beta" />}
|
||||
{soon && (
|
||||
@@ -39,7 +40,9 @@ export const SettingsCard = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
<Small color="muted" margin="headerDescription">
|
||||
{description}
|
||||
</Small>
|
||||
</div>
|
||||
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -45,6 +45,14 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
||||
|
||||
@@ -26,8 +26,26 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: vi.fn(({ children, open, onOpenChange }) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
DialogContent: vi.fn(({ children, hideCloseButton, width, className }) => (
|
||||
<div
|
||||
data-testid="dialog-content"
|
||||
data-hide-close-button={hideCloseButton}
|
||||
data-width={width}
|
||||
className={className}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
|
||||
DialogFooter: vi.fn(({ children }) => <div data-testid="dialog-footer">{children}</div>),
|
||||
}));
|
||||
|
||||
const mockResponses = [
|
||||
@@ -163,12 +181,12 @@ describe("ResponseCardModal", () => {
|
||||
test("should not render if selectedResponseId is null", () => {
|
||||
const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should render the modal when a response is selected", () => {
|
||||
test("should render the dialog when a response is selected", () => {
|
||||
render(<ResponseCardModal {...defaultProps} />);
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("single-response-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -204,14 +222,6 @@ describe("ResponseCardModal", () => {
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("should call setSelectedResponseId with null when close button is clicked", async () => {
|
||||
render(<ResponseCardModal {...defaultProps} />);
|
||||
const buttons = screen.getAllByTestId("mock-button");
|
||||
const closeButton = buttons.find((button) => button.querySelector("svg.lucide-x"));
|
||||
if (closeButton) await userEvent.click(closeButton);
|
||||
expect(mockSetSelectedResponseId).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => {
|
||||
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(true);
|
||||
@@ -229,11 +239,10 @@ describe("ResponseCardModal", () => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("should render ChevronLeft, ChevronRight, and XIcon", () => {
|
||||
test("should render ChevronLeft and ChevronRight icons", () => {
|
||||
render(<ResponseCardModal {...defaultProps} />);
|
||||
expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument();
|
||||
expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument();
|
||||
expect(document.querySelector(".lucide-x")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react";
|
||||
import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
@@ -64,42 +64,20 @@ export const ResponseCardModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedResponseId(null);
|
||||
const handleClose = (open: boolean) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setSelectedResponseId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// If no response is selected or currentIndex is null, do not render the modal
|
||||
if (selectedResponseId === null || currentIndex === null) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
hideCloseButton
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
size="xxl"
|
||||
className="max-h-[80vh] overflow-auto"
|
||||
noPadding>
|
||||
<div className="h-full rounded-lg">
|
||||
<div className="relative h-full w-full overflow-auto p-4">
|
||||
<div className="mb-4 flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
disabled={currentIndex === 0}
|
||||
variant="ghost"
|
||||
className="border bg-white p-2">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex === responses.length - 1}
|
||||
variant="ghost"
|
||||
className="border bg-white p-2">
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button className="border bg-white p-2" onClick={handleClose} variant="ghost">
|
||||
<XIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent width="wide">
|
||||
<DialogBody>
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
response={responses[currentIndex]}
|
||||
@@ -113,8 +91,20 @@ export const ResponseCardModal = ({
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex === responses.length - 1}
|
||||
variant="outline"
|
||||
size="icon">
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ interface ResponsePageProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
user?: TUser;
|
||||
environmentTags: TTag[];
|
||||
responsesPerPage: number;
|
||||
@@ -32,7 +32,7 @@ export const ResponsePage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
webAppUrl,
|
||||
publicDomain,
|
||||
user,
|
||||
environmentTags,
|
||||
responsesPerPage,
|
||||
@@ -155,7 +155,7 @@ export const ResponsePage = ({
|
||||
<>
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} />}
|
||||
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
|
||||
</div>
|
||||
<ResponseDataView
|
||||
survey={survey}
|
||||
|
||||
@@ -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 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 { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
@@ -65,8 +65,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getSurveyUrl", () => ({
|
||||
getSurveyDomain: vi.fn(),
|
||||
vi.mock("@/lib/getPublicUrl", () => ({
|
||||
getPublicDomain: vi.fn(),
|
||||
}));
|
||||
|
||||
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 mockLocale: TUserLocale = "en-US";
|
||||
const mockSurveyDomain = "http://customdomain.com";
|
||||
const mockPublicDomain = "http://customdomain.com";
|
||||
|
||||
const mockParams = {
|
||||
environmentId: mockEnvironmentId,
|
||||
@@ -179,7 +179,7 @@ describe("ResponsesPage", () => {
|
||||
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
|
||||
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -205,7 +205,7 @@ describe("ResponsesPage", () => {
|
||||
survey: mockSurvey,
|
||||
isReadOnly: false,
|
||||
user: mockUser,
|
||||
surveyDomain: mockSurveyDomain,
|
||||
publicDomain: mockPublicDomain,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
@@ -224,7 +224,7 @@ describe("ResponsesPage", () => {
|
||||
environment: mockEnvironment,
|
||||
survey: mockSurvey,
|
||||
surveyId: mockSurveyId,
|
||||
webAppUrl: "http://localhost:3000",
|
||||
publicDomain: mockPublicDomain,
|
||||
environmentTags: mockTags,
|
||||
user: mockUser,
|
||||
responsesPerPage: 10,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -33,11 +35,14 @@ const Page = async (props) => {
|
||||
|
||||
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const surveyDomain = getSurveyDomain();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -49,8 +54,11 @@ const Page = async (props) => {
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
|
||||
@@ -59,7 +67,7 @@ const Page = async (props) => {
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={params.surveyId}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
publicDomain={publicDomain}
|
||||
environmentTags={tags}
|
||||
user={user}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { putFile } from "@/lib/storage/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { convertToCsv } from "@/lib/utils/file-conversion";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
|
||||
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
|
||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -63,37 +70,55 @@ const ZGenerateResultShareUrlAction = z.object({
|
||||
|
||||
export const generateResultShareUrlAction = authenticatedActionClient
|
||||
.schema(ZGenerateResultShareUrlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
const resultShareKey = customAlphabet(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
||||
20
|
||||
)();
|
||||
const resultShareKey = customAlphabet(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
||||
20
|
||||
)();
|
||||
|
||||
await updateSurvey({ ...survey, resultShareKey });
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = survey;
|
||||
|
||||
return resultShareKey;
|
||||
});
|
||||
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
|
||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||
|
||||
return resultShareKey;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetResultShareUrlAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -132,30 +157,50 @@ const ZDeleteResultShareUrlAction = z.object({
|
||||
|
||||
export const deleteResultShareUrlAction = authenticatedActionClient
|
||||
.schema(ZDeleteResultShareUrlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
return await updateSurvey({ ...survey, resultShareKey: null });
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = survey;
|
||||
|
||||
const newSurvey = await updateSurvey({ ...survey, resultShareKey: null });
|
||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||
|
||||
return newSurvey;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetEmailHtmlAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -182,3 +227,128 @@ export const getEmailHtmlAction = authenticatedActionClient
|
||||
|
||||
return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
|
||||
});
|
||||
|
||||
const ZGeneratePersonalLinksAction = z.object({
|
||||
surveyId: ZId,
|
||||
segmentId: ZId,
|
||||
environmentId: ZId,
|
||||
expirationDays: z.number().optional(),
|
||||
});
|
||||
|
||||
export const generatePersonalLinksAction = authenticatedActionClient
|
||||
.schema(ZGeneratePersonalLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Get contacts and generate personal links
|
||||
const contactsResult = await generatePersonalLinks(
|
||||
parsedInput.surveyId,
|
||||
parsedInput.segmentId,
|
||||
parsedInput.expirationDays
|
||||
);
|
||||
|
||||
if (!contactsResult || contactsResult.length === 0) {
|
||||
throw new UnknownError("No contacts found for the selected segment");
|
||||
}
|
||||
|
||||
// Prepare CSV data with the specified headers and order
|
||||
const csvHeaders = [
|
||||
"Formbricks Contact ID",
|
||||
"User ID",
|
||||
"First Name",
|
||||
"Last Name",
|
||||
"Email",
|
||||
"Personal Link",
|
||||
];
|
||||
|
||||
const csvData = contactsResult
|
||||
.map((contact) => {
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
const attributes = contact.attributes ?? {};
|
||||
return {
|
||||
"Formbricks Contact ID": contact.contactId,
|
||||
"User ID": attributes.userId ?? "",
|
||||
"First Name": attributes.firstName ?? "",
|
||||
"Last Name": attributes.lastName ?? "",
|
||||
Email: attributes.email ?? "",
|
||||
"Personal Link": contact.surveyUrl,
|
||||
};
|
||||
})
|
||||
.filter((contact) => contact !== null);
|
||||
|
||||
// Convert to CSV using the file conversion utility
|
||||
const csvContent = await convertToCsv(csvHeaders, csvData);
|
||||
const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`;
|
||||
|
||||
// Store file temporarily and return download URL
|
||||
const fileBuffer = Buffer.from(csvContent);
|
||||
await putFile(fileName, fileBuffer, "private", parsedInput.environmentId);
|
||||
|
||||
const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`;
|
||||
|
||||
return {
|
||||
downloadUrl,
|
||||
fileName,
|
||||
count: csvData.length,
|
||||
};
|
||||
});
|
||||
|
||||
const ZUpdateSingleUseLinksAction = z.object({
|
||||
surveyId: ZId,
|
||||
environmentId: ZId,
|
||||
isSingleUse: z.boolean(),
|
||||
isSingleUseEncryption: z.boolean(),
|
||||
});
|
||||
|
||||
export const updateSingleUseLinksAction = authenticatedActionClient
|
||||
.schema(ZUpdateSingleUseLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
const updatedSurvey = await updateSurvey({
|
||||
...survey,
|
||||
singleUse: { enabled: parsedInput.isSingleUse, isEncrypted: parsedInput.isSingleUseEncryption },
|
||||
});
|
||||
|
||||
return updatedSurvey;
|
||||
});
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySingleUse,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
// Mock data
|
||||
const mockSurveyWeb = {
|
||||
id: "survey1",
|
||||
name: "Web Survey",
|
||||
environmentId: "env1",
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: 0,
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
closeOnDate: null,
|
||||
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
|
||||
triggers: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [],
|
||||
styling: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockSurveyLink = {
|
||||
...mockSurveyWeb,
|
||||
id: "survey2",
|
||||
name: "Link Survey",
|
||||
type: "link",
|
||||
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
role: "project_manager",
|
||||
objective: "other",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
locale: "en-US",
|
||||
} as unknown as TUser;
|
||||
|
||||
// Mocks
|
||||
const mockRouterRefresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: mockRouterRefresh,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (str: string) => str,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
|
||||
ShareSurveyLink: vi.fn(() => <div>ShareSurveyLinkMock</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/badge", () => ({
|
||||
Badge: vi.fn(({ text }) => <span data-testid="badge-mock">{text}</span>),
|
||||
}));
|
||||
|
||||
const mockEmbedViewComponent = vi.fn();
|
||||
vi.mock("./shareEmbedModal/EmbedView", () => ({
|
||||
EmbedView: (props: any) => mockEmbedViewComponent(props),
|
||||
}));
|
||||
|
||||
const mockPanelInfoViewComponent = vi.fn();
|
||||
vi.mock("./shareEmbedModal/PanelInfoView", () => ({
|
||||
PanelInfoView: (props: any) => mockPanelInfoViewComponent(props),
|
||||
}));
|
||||
|
||||
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
|
||||
vi.mock("@/modules/ui/components/dialog", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/modules/ui/components/dialog")>(
|
||||
"@/modules/ui/components/dialog"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
Dialog: (props: React.ComponentProps<typeof actual.Dialog>) => {
|
||||
capturedDialogOnOpenChange = props.onOpenChange;
|
||||
return <actual.Dialog {...props} />;
|
||||
},
|
||||
// DialogTitle, DialogContent, DialogDescription will be the actual components
|
||||
// due to ...actual spread and no specific mock for them here.
|
||||
};
|
||||
});
|
||||
|
||||
describe("ShareEmbedSurvey", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
capturedDialogOnOpenChange = undefined;
|
||||
});
|
||||
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
survey: mockSurveyWeb,
|
||||
surveyDomain: "test.com",
|
||||
open: true,
|
||||
modalView: "start" as "start" | "embed" | "panel",
|
||||
setOpen: mockSetOpen,
|
||||
user: mockUser,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockEmbedViewComponent.mockImplementation(
|
||||
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, surveyDomain, locale }) => (
|
||||
<div>
|
||||
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
|
||||
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
|
||||
<div data-testid="embedview-activeid">{activeId}</div>
|
||||
<div data-testid="embedview-survey-id">{survey.id}</div>
|
||||
<div data-testid="embedview-email">{email}</div>
|
||||
<div data-testid="embedview-surveyUrl">{surveyUrl}</div>
|
||||
<div data-testid="embedview-surveyDomain">{surveyDomain}</div>
|
||||
<div data-testid="embedview-locale">{locale}</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => (
|
||||
<button onClick={() => handleInitialPageButton()}>PanelInfoViewMockContent</button>
|
||||
));
|
||||
});
|
||||
|
||||
test("renders initial 'start' view correctly when open and modalView is 'start'", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
|
||||
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
|
||||
});
|
||||
|
||||
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
|
||||
await userEvent.click(embedButton);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches to 'panel' view when 'Send to panel' button is clicked", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||
const panelButton = screen.getByText("environments.surveys.summary.send_to_panel");
|
||||
await userEvent.click(panelButton);
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setOpen(false) when handleInitialPageButton is triggered from EmbedView", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
const embedViewButton = screen.getByText("EmbedViewMockContent");
|
||||
await userEvent.click(embedViewButton);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("calls setOpen(false) when handleInitialPageButton is triggered from PanelInfoView", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
|
||||
await userEvent.click(panelInfoViewButton);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} open={true} survey={mockSurveyWeb} />);
|
||||
expect(capturedDialogOnOpenChange).toBeDefined();
|
||||
|
||||
// Simulate Dialog closing
|
||||
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(false);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockRouterRefresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate Dialog opening
|
||||
mockRouterRefresh.mockClear();
|
||||
mockSetOpen.mockClear();
|
||||
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(true);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(true);
|
||||
expect(mockRouterRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("correctly configures for 'link' survey type in embed view", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
|
||||
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
|
||||
tabs: { id: string; label: string; icon: LucideIcon }[];
|
||||
activeId: string;
|
||||
};
|
||||
expect(embedViewProps.tabs.length).toBe(3);
|
||||
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
|
||||
expect(embedViewProps.tabs[0].id).toBe("email");
|
||||
expect(embedViewProps.activeId).toBe("email");
|
||||
});
|
||||
|
||||
test("correctly configures for 'web' survey type in embed view", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />);
|
||||
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
|
||||
tabs: { id: string; label: string; icon: LucideIcon }[];
|
||||
activeId: string;
|
||||
};
|
||||
expect(embedViewProps.tabs.length).toBe(1);
|
||||
expect(embedViewProps.tabs[0].id).toBe("app");
|
||||
expect(embedViewProps.activeId).toBe("app");
|
||||
});
|
||||
|
||||
test("useEffect does not change activeId if survey.type changes from web to link (while in embed view)", () => {
|
||||
const { rerender } = render(
|
||||
<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />
|
||||
);
|
||||
expect(vi.mocked(mockEmbedViewComponent).mock.calls[0][0].activeId).toBe("app");
|
||||
|
||||
rerender(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
|
||||
expect(vi.mocked(mockEmbedViewComponent).mock.calls[1][0].activeId).toBe("app"); // Current behavior
|
||||
});
|
||||
|
||||
test("initial showView is set by modalView prop when open is true", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("useEffect sets showView to 'start' when open becomes false", () => {
|
||||
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed
|
||||
|
||||
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
|
||||
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found
|
||||
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
|
||||
// To verify showView is 'start', we'd need to inspect internal state or render start view elements
|
||||
// For now, we trust the useEffect sets showView, and if it were to re-open in 'start' mode, it would show.
|
||||
// The main check is that the previous view ('embed') is gone.
|
||||
});
|
||||
|
||||
test("renders correct label for link tab based on singleUse survey property", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
|
||||
let embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
|
||||
tabs: { id: string; label: string }[];
|
||||
};
|
||||
let linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
|
||||
expect(linkTab?.label).toBe("environments.surveys.summary.share_the_link");
|
||||
cleanup();
|
||||
vi.mocked(mockEmbedViewComponent).mockClear();
|
||||
|
||||
const mockSurveyLinkSingleUse: TSurvey = {
|
||||
...mockSurveyLink,
|
||||
singleUse: { enabled: true, isEncrypted: true },
|
||||
};
|
||||
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLinkSingleUse} modalView="embed" />);
|
||||
embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
|
||||
tabs: { id: string; label: string }[];
|
||||
};
|
||||
linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
|
||||
expect(linkTab?.label).toBe("environments.surveys.summary.single_use_links");
|
||||
});
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
BellRing,
|
||||
BlocksIcon,
|
||||
Code2Icon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
SmartphoneIcon,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { EmbedView } from "./shareEmbedModal/EmbedView";
|
||||
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
|
||||
|
||||
interface ShareEmbedSurveyProps {
|
||||
survey: TSurvey;
|
||||
surveyDomain: string;
|
||||
open: boolean;
|
||||
modalView: "start" | "embed" | "panel";
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
user: TUser;
|
||||
}
|
||||
|
||||
export const ShareEmbedSurvey = ({
|
||||
survey,
|
||||
surveyDomain,
|
||||
open,
|
||||
modalView,
|
||||
setOpen,
|
||||
user,
|
||||
}: ShareEmbedSurveyProps) => {
|
||||
const router = useRouter();
|
||||
const environmentId = survey.environmentId;
|
||||
const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false;
|
||||
const { email } = user;
|
||||
const { t } = useTranslate();
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
|
||||
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
|
||||
{
|
||||
id: "link",
|
||||
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{ id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon },
|
||||
].filter((tab) => !(survey.type === "link" && tab.id === "app")),
|
||||
[t, isSingleUseLinkSurvey, survey.type]
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id);
|
||||
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
|
||||
const [surveyUrl, setSurveyUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.type !== "link") {
|
||||
setActiveId(tabs[3].id);
|
||||
}
|
||||
}, [survey.type, tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setShowView(modalView);
|
||||
} else {
|
||||
setShowView("start");
|
||||
}
|
||||
}, [open, modalView]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id);
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setShowView("start");
|
||||
}
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleInitialPageButton = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTitle className="sr-only" />
|
||||
<DialogContent className="w-full max-w-xl bg-white p-0 md:max-w-3xl lg:h-[700px] lg:max-w-5xl">
|
||||
{showView === "start" ? (
|
||||
<div className="h-full max-w-full overflow-hidden">
|
||||
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
|
||||
<DialogTitle>
|
||||
<p className="pt-2 text-xl font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.your_survey_is_public")} 🎉
|
||||
</p>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
surveyDomain={surveyDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-[300px] flex-col items-center justify-center gap-8 rounded-b-lg bg-slate-50 px-8 lg:h-3/5">
|
||||
<p className="-mt-8 text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowView("embed")}
|
||||
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
|
||||
<Code2Icon className="h-6 w-6 text-slate-700" />
|
||||
{t("environments.surveys.summary.embed_survey")}
|
||||
</button>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/settings/notifications`}
|
||||
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
|
||||
<BellRing className="h-6 w-6 text-slate-700" />
|
||||
{t("environments.surveys.summary.configure_alerts")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/integrations`}
|
||||
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
|
||||
<BlocksIcon className="h-6 w-6 text-slate-700" />
|
||||
{t("environments.surveys.summary.setup_integrations")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowView("panel")}
|
||||
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
|
||||
<UsersRound className="h-6 w-6 text-slate-700" />
|
||||
{t("environments.surveys.summary.send_to_panel")}
|
||||
<Badge
|
||||
size="tiny"
|
||||
type="success"
|
||||
className="absolute right-3 top-3"
|
||||
text={t("common.new")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : showView === "embed" ? (
|
||||
<EmbedView
|
||||
handleInitialPageButton={handleInitialPageButton}
|
||||
tabs={survey.type === "link" ? tabs : [tabs[3]]}
|
||||
disableBack={false}
|
||||
activeId={activeId}
|
||||
environmentId={environmentId}
|
||||
setActiveId={setActiveId}
|
||||
survey={survey}
|
||||
email={email}
|
||||
surveyUrl={surveyUrl}
|
||||
surveyDomain={surveyDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
/>
|
||||
) : showView === "panel" ? (
|
||||
<PanelInfoView handleInitialPageButton={handleInitialPageButton} disableBack={false} />
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -20,9 +20,22 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Modal
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
|
||||
// Mock Dialog
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: vi.fn(({ children, open, onOpenChange }) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
DialogContent: vi.fn(({ children, ...props }) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
|
||||
}));
|
||||
|
||||
// Mock useTranslate
|
||||
@@ -120,7 +133,7 @@ describe("ShareSurveyResults", () => {
|
||||
|
||||
test("does not render content when modal is closed (open is false)", () => {
|
||||
render(<ShareSurveyResults {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("environments.surveys.summary.survey_results_are_public")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
|
||||
import { Clipboard } from "lucide-react";
|
||||
@@ -26,70 +26,72 @@ export const ShareSurveyResults = ({
|
||||
}: ShareEmbedSurveyProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} size="lg">
|
||||
{showPublishModal && surveyUrl ? (
|
||||
<div className="flex flex-col rounded-2xl bg-white px-12 py-6">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<CheckCircle2Icon className="h-20 w-20 text-slate-300" />
|
||||
<div>
|
||||
<p className="text-lg font-medium text-slate-600">
|
||||
{t("environments.surveys.summary.survey_results_are_public")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
|
||||
<span>{surveyUrl}</span>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogBody>
|
||||
{showPublishModal && surveyUrl ? (
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<CheckCircle2Icon className="text-primary h-20 w-20" />
|
||||
<div>
|
||||
<p className="text-primary text-lg font-medium">
|
||||
{t("environments.surveys.summary.survey_results_are_public")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
|
||||
<span>{surveyUrl}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title="Copy survey link to clipboard"
|
||||
aria-label="Copy survey link to clipboard"
|
||||
className="hover:cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
toast.success(t("common.link_copied"));
|
||||
}}>
|
||||
<Clipboard />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
className="text-center"
|
||||
onClick={() => handleUnpublish()}>
|
||||
{t("environments.surveys.summary.unpublish_from_web")}
|
||||
</Button>
|
||||
<Button className="text-center" asChild>
|
||||
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("environments.surveys.summary.view_site")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title="Copy survey link to clipboard"
|
||||
aria-label="Copy survey link to clipboard"
|
||||
className="hover:cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
toast.success(t("common.link_copied"));
|
||||
}}>
|
||||
<Clipboard />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
className="text-center"
|
||||
onClick={() => handleUnpublish()}>
|
||||
{t("environments.surveys.summary.unpublish_from_web")}
|
||||
</Button>
|
||||
<Button className="text-center" asChild>
|
||||
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("environments.surveys.summary.view_site")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col rounded-2xl bg-white p-8">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
|
||||
<div>
|
||||
<p className="text-lg font-medium text-slate-600">
|
||||
{t("environments.surveys.summary.publish_to_web_warning")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.publish_to_web_warning_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
|
||||
{t("environments.surveys.summary.publish_to_web")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col rounded-2xl bg-white p-8">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
|
||||
<div>
|
||||
<p className="text-lg font-medium text-slate-600">
|
||||
{t("environments.surveys.summary.publish_to_web_warning")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.publish_to_web_warning_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
|
||||
{t("environments.surveys.summary.publish_to_web")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -104,13 +104,15 @@ describe("SummaryDropOffs", () => {
|
||||
|
||||
// Check drop-off counts and percentages
|
||||
expect(screen.getByText("20")).toBeInTheDocument();
|
||||
expect(screen.getByText("(20%)")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("15")).toBeInTheDocument();
|
||||
expect(screen.getByText("(19%)")).toBeInTheDocument(); // 18.75% rounded to 19%
|
||||
|
||||
expect(screen.getByText("10")).toBeInTheDocument();
|
||||
expect(screen.getByText("(15%)")).toBeInTheDocument(); // 15.38% rounded to 15%
|
||||
|
||||
// Check percentage values
|
||||
const percentageElements = screen.getAllByText(/\d+%/);
|
||||
expect(percentageElements).toHaveLength(3);
|
||||
expect(percentageElements[0]).toHaveTextContent("20%");
|
||||
expect(percentageElements[1]).toHaveTextContent("19%");
|
||||
expect(percentageElements[2]).toHaveTextContent("15%");
|
||||
});
|
||||
|
||||
test("renders empty state when dropOff array is empty", () => {
|
||||
|
||||
@@ -23,9 +23,9 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="">
|
||||
<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 pl-4 md:pl-6">{t("common.questions")}</div>
|
||||
<div className="flex justify-center">
|
||||
<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="col-span-3 px-4 md:px-6">{t("common.questions")}</div>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -37,14 +37,16 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="px-4 text-center md:px-6">{t("environments.surveys.summary.impressions")}</div>
|
||||
<div className="pr-6 text-center md:pl-6">{t("environments.surveys.summary.drop_offs")}</div>
|
||||
<div className="px-4 text-right md:px-6">{t("environments.surveys.summary.impressions")}</div>
|
||||
<div className="px-4 text-right md:mr-1 md:pl-6 md:pr-6">
|
||||
{t("environments.surveys.summary.drop_offs")}
|
||||
</div>
|
||||
</div>
|
||||
{dropOff.map((quesDropOff) => (
|
||||
<div
|
||||
key={quesDropOff.questionId}
|
||||
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 pl-4 md:pl-6">
|
||||
className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm">
|
||||
<div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
|
||||
{getIcon(quesDropOff.questionType)}
|
||||
<p>
|
||||
{formatTextWithSlashes(
|
||||
@@ -57,17 +59,21 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
"default"
|
||||
)["default"],
|
||||
"@",
|
||||
["text-lg"]
|
||||
["text-sm"]
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">
|
||||
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
|
||||
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
|
||||
<div className="pl-6 text-center md:px-6">
|
||||
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
|
||||
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
|
||||
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
|
||||
{quesDropOff.impressions}
|
||||
</div>
|
||||
<div className="px-4 py-2 text-right md:px-6">
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -117,13 +117,13 @@ export const SummaryMetadata = ({
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ interface SummaryPageProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
initialSurveySummary?: TSurveySummary;
|
||||
@@ -46,7 +46,7 @@ export const SummaryPage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
webAppUrl,
|
||||
publicDomain,
|
||||
locale,
|
||||
isReadOnly,
|
||||
initialSurveySummary,
|
||||
@@ -133,7 +133,7 @@ export const SummaryPage = ({
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
{!isReadOnly && !isSharingPage && (
|
||||
<ResultsShareButton survey={surveyMemoized} webAppUrl={webAppUrl} />
|
||||
<ResultsShareButton survey={surveyMemoized} publicDomain={publicDomain} />
|
||||
)}
|
||||
</div>
|
||||
<ScrollToTop containerId="mainContent" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user