Compare commits

...

80 Commits

Author SHA1 Message Date
Victor Santos
b894587a20 Added finally block and tests 2025-06-24 06:58:14 -03:00
Victor Santos
8fbff5cdc5 moved logic to survey-list 2025-06-23 10:30:03 -03:00
Varun Singh
d13037cb50 fix: Empty survey list state after deleting the last survey. 2025-06-21 00:38:02 +05:30
Piyush Gupta
c3547ccb36 fix: default environment redirect (#6033) 2025-06-20 16:46:43 +00:00
Johannes
a0f334b300 chore: add rules (#6036) 2025-06-19 09:02:25 -07:00
Jakob Schott
a9f635b768 chore: Satisfy SonarQube ReadOnly props for all question types (#6021) 2025-06-19 06:10:11 +00:00
Jakob Schott
d385b4a0d6 fix: Set non-required as default value on questions (#6018) 2025-06-19 06:09:36 +00:00
Matti Nannt
5e825413d2 chore(infra): switch staging to internal lb (#6012) 2025-06-18 12:04:53 +00:00
Johannes
8c3e816ccd fix: remove Formbricks branding from Link Pages (#5989)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-16 16:18:25 +00:00
Anshuman Pandey
6ddc91ee85 fix: deletes local storage environment id on logout (#5957) 2025-06-16 14:01:16 +00:00
Saurav Jain
14023ca8a9 fix: keyboard accessibility issue (#3768) (#5941) 2025-06-16 15:45:52 +02:00
Dhruwang Jariwala
385e8a4262 fix: Airtable fix (#5976)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-16 12:37:05 +00:00
Matti Nannt
e358104f7c chore: fast return ping endpoint when telemetry is disabled (#5893)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-16 12:14:07 +00:00
Dhruwang Jariwala
c8e9194ab6 fix: broken email embed for rating question (#5890)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-16 11:49:19 +00:00
Matti Nannt
bebe29815d feat: domain based access control (#5985)
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-06-16 11:37:02 +00:00
victorvhs017
7f40502c94 fix: Removed footer on follow-up email if white labelling enabled (#5984) 2025-06-16 10:59:57 +00:00
Dhruwang Jariwala
5fb5215680 fix: email enumeration via signup page (#5853)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-13 16:25:40 +00:00
Varun Singh
19b80ff042 fix: misplaced button text for 'preview survey' (#5972) 2025-06-13 05:29:41 -07:00
Jakob Schott
2dfdba2acf chore: Optimize text sizing and alignment for Drop-Off table (#5914)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-06-13 11:21:45 +00:00
Johannes
f7842789de docs: tweak API wording (#5978) 2025-06-12 03:45:41 -07:00
Johannes
59bdd5f065 docs: add recall info to variables (#5977) 2025-06-12 03:21:53 -07:00
Jonas Höbenreich
8da1bc71a6 fix: duplicate name survey copy issue (#3865)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-11 10:59:51 +02:00
Piyush Gupta
0e0259691c fix: recall in survey editor (#5938) 2025-06-11 05:33:52 +00:00
Dhruwang Jariwala
ac7831fa3d fix: auth checks in storage management api (#5931) 2025-06-11 04:56:20 +00:00
Dhruwang Jariwala
db32cb392f chore: added curosr rules for database schema (#5935) 2025-06-10 14:01:06 +00:00
Piyush Jain
e5cb01bd88 chore(cache): remove old servers (#5950) 2025-06-10 06:44:19 +00:00
Piyush Gupta
cbef4c2a69 fix: broken templates (#5955)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-06-10 05:04:05 +00:00
Harsh Bhat
86948b70de docs: add EE license activation docs (#5930)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-06-09 12:39:03 +00:00
Dhruwang Jariwala
dfe955ca7c chore: purge cache after deployment (#5934)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-09 11:02:28 +00:00
Piyush Jain
eb4b2dde05 chore(elasticache): add serverless redis (#5943) 2025-06-09 07:01:51 +00:00
victorvhs017
f2dae67813 chore: updated docs (#5940) 2025-06-06 11:54:24 +00:00
DivyanshuLohani
3ffc9bd290 fix: iframe url not being automatically populated (#5892)
Co-authored-by: Divyanshu Lohani <DivyanshuLohani@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-06-06 09:12:59 +02:00
victorvhs017
a9946737df feat: audit logs (#5866)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-06-05 19:31:39 +00:00
Archit Sehgal
ece3d508a2 fix: back button in survey published modal closes modal instead of navigating (#5831)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-06-05 20:45:47 +02:00
Piyush Gupta
0d1d227e6a feat: add security headers for Referrer-Policy and Permissions-Policy (#5877) 2025-06-05 04:21:10 +00:00
Matti Nannt
c0b8edfdf2 chore: Comprehensive Cache Optimization & Performance Enhancement (#5926)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-04 20:33:17 +02:00
Dhruwang Jariwala
45fec0e184 fix: language tweaks (#5933) 2025-06-04 11:27:19 +00:00
Dhruwang Jariwala
2c2ba919c6 fix: backspacing headline causing infinite loop (#5891)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-06-04 10:26:15 +00:00
Piyush Jain
6d8adc6168 chore: switch curl images to quay.io (#5916) 2025-05-31 11:19:22 +00:00
Anshuman Pandey
ec208960e8 fix: surveys package resize observer issue (#5907)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-29 19:00:28 +00:00
Piyush Gupta
b9505158b4 fix: ciphers issue for fb staging (#5908) 2025-05-29 14:39:20 +00:00
abhishek
ad0c3421f0 fix: alignment issue in file upload (#5828) 2025-05-29 16:40:18 +02:00
Matti Nannt
916c00344b chore: clean up public directory and update cache headers (#5904)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-29 10:46:41 +00:00
Jakob Schott
459cdee17e chore: tweak language select dropdown width (#5878) 2025-05-29 03:54:51 +00:00
Harsh Bhat
bb26a64dbb docs: follow up update (#5601)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-29 03:24:58 +00:00
Harsh Bhat
29a3fa532a docs: RTL support in multi-lang docs (#5898)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-29 03:02:52 +00:00
Harsh Bhat
738b8f9012 docs: android sdk (#5889) 2025-05-29 02:47:26 +00:00
Matti Nannt
c95272288e fix: caching issue in newest next version (#5902) 2025-05-28 21:44:39 +02:00
Piyush Gupta
919febd166 fix: resend verification email translation (#5881) 2025-05-28 09:51:55 +00:00
Dhruwang Jariwala
10ccc20b53 fix: recall not working for NPS question (#5895) 2025-05-28 09:44:55 +00:00
Dhruwang Jariwala
d9ca64da54 fix: favicon warning (#5874)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-28 08:09:51 +00:00
Anshuman Pandey
ce00ec97d1 fix: js-core trackAction bugs (#5843)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-27 17:14:21 +00:00
Matti Nannt
2b9cd37c6c chore: enable rate limiting by default in helm chart (#5879) 2025-05-27 14:36:39 +02:00
Piyush Gupta
f8f14eb6f3 fix: weak cipher suite usage (#5873) 2025-05-27 12:09:16 +00:00
Matti Nannt
645fc863aa fix: performance issues on survey summary (#5885)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-27 12:07:31 +00:00
Anshuman Pandey
c53f030b24 fix: multiple close function calls because of timeouts (#5886) 2025-05-27 07:20:35 +00:00
devin-ai-integration[bot]
45d74f9ba0 fix: Update JS SDK log messages for clarity (#5819)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matti.sh>
2025-05-26 09:57:37 +00:00
Piyush Gupta
87870919ca fix: issues in the email change feature (#5868) 2025-05-24 12:04:58 +00:00
Piyush Gupta
ce2fdde474 fix: rtl issue in open text placeholder (#5855) 2025-05-23 11:47:38 +00:00
Harsh Bhat
6e2f30c6ed chore: add no index for survey pages (#5859) 2025-05-23 05:44:22 +00:00
Jakob Schott
5c8040008a fix: 602 modal height on small screens (#5863) 2025-05-23 05:34:43 +00:00
Harsh Bhat
639e25d679 chore: canonical seo issue (#5852) 2025-05-21 13:38:41 +00:00
Piyush Gupta
f7e5ef96d2 feat: added email change feature (#5837)
Co-authored-by: Paribesh01 <nepalparibesh01@gmail.com>
Co-authored-by: Paribesh Nepal <100255987+Paribesh01@users.noreply.github.com>
2025-05-21 11:23:12 +00:00
Dhruwang Jariwala
745f5487e9 fix: tweaks in open text question (#5841)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-21 06:20:40 +00:00
devin-ai-integration[bot]
0e7f3adf53 feat: Make session maxAge configurable with environment variable (#5830)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matti.sh>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-21 05:49:18 +00:00
Dhruwang Jariwala
342d2b1fc4 fix: response getting stuck (#5849) 2025-05-21 05:33:13 +00:00
Piyush Gupta
15279685f7 fix: delete pre-filled value (#5839) 2025-05-21 04:23:05 +00:00
Matti Nannt
12aa959f50 fix: slow responses query slowing down database (#5846) 2025-05-21 04:13:31 +00:00
Johannes
9478946c7a fix: fix icon in new docs page (#5836) 2025-05-19 04:53:57 -07:00
Johannes
8560bbf28b docs: documentation of multi-tenancy of Formbricks Cloud (#5835) 2025-05-19 04:47:26 -07:00
victorvhs017
df7afe1b64 fix: non-interactive elements without roles (#5804)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-19 10:10:13 +00:00
Piyush Gupta
df52b60d61 fix: env-var-generation in mac os for self-hosting (#5814) 2025-05-17 07:50:15 +00:00
Jakob Schott
65b051f0eb feat: download selection of responses (#5488)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-17 00:59:14 +00:00
Dhruwang Jariwala
7678084061 fix: unknown property warnings (#5800) 2025-05-16 13:45:48 +00:00
victorvhs017
022d33d06f chore: track server action with sentry and general fixes (#5799) 2025-05-16 12:02:06 +00:00
Anshuman Pandey
4d157bf8dc fix: user attributes updates api email fix (#5827) 2025-05-16 11:48:34 +00:00
Dhruwang Jariwala
9fcbe4e8c5 chore: swap next and back button input (#5748)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-16 08:51:12 +00:00
Piyush Gupta
5aeb92eb4f chore: removes https enforcement from management api (#5810) 2025-05-15 19:40:04 +00:00
Matti Nannt
00dfa629b5 fix: build process warnings (#5734)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-15 15:46:05 +00:00
Piyush Gupta
3ca471b6a2 feat: implement user management role configuration and access control (#5808) 2025-05-15 15:09:33 +00:00
820 changed files with 29056 additions and 23261 deletions

View File

@@ -0,0 +1,61 @@
---
description:
globs:
alwaysApply: false
---
# Build & Deployment Best Practices
## Build Process
### Running Builds
- Use `pnpm build` from project root for full build
- Monitor for React hooks warnings and fix them immediately
- Ensure all TypeScript errors are resolved before deployment
### Common Build Issues & Fixes
#### React Hooks Warnings
- Capture ref values in variables within useEffect cleanup
- Avoid accessing `.current` directly in cleanup functions
- Pattern for fixing ref cleanup warnings:
```typescript
useEffect(() => {
const currentRef = myRef.current;
return () => {
if (currentRef) {
currentRef.cleanup();
}
};
}, []);
```
#### Test Failures During Build
- Ensure all test mocks include required constants like `SESSION_MAX_AGE`
- Mock Next.js navigation hooks properly: `useParams`, `useRouter`, `useSearchParams`
- Remove unused imports and constants from test files
- Use literal values instead of imported constants when the constant isn't actually needed
### Test Execution
- Run `pnpm test` to execute all tests
- Use `pnpm test -- --run filename.test.tsx` for specific test files
- Fix test failures before merging code
- Ensure 100% test coverage for new components
### Performance Monitoring
- Monitor build times and optimize if necessary
- Watch for memory usage during builds
- Use proper caching strategies for faster rebuilds
### Deployment Checklist
1. All tests passing
2. Build completes without warnings
3. TypeScript compilation successful
4. No linter errors
5. Database migrations applied (if any)
6. Environment variables configured
### EKS Deployment Considerations
- Ensure latest code is deployed to all pods
- Monitor AWS RDS Performance Insights for database issues
- Verify environment-specific configurations
- Check pod health and resource usage

View File

@@ -0,0 +1,414 @@
---
description: Caching rules for performance improvements
globs:
alwaysApply: false
---
# Cache Optimization Patterns for Formbricks
## Cache Strategy Overview
Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
- **Redis** for persistent cross-request caching
- **React `cache()`** for request-level deduplication
- **NO Next.js `unstable_cache()`** - avoid for reliability
## Key Files
### Core Cache Infrastructure
- [apps/web/modules/cache/lib/service.ts](mdc:apps/web/modules/cache/lib/service.ts) - Redis cache service
- [apps/web/modules/cache/lib/withCache.ts](mdc:apps/web/modules/cache/lib/withCache.ts) - Cache wrapper utilities
- [apps/web/modules/cache/lib/cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts) - Enterprise cache key patterns and utilities
### Environment State Caching (Critical Endpoint)
- [apps/web/app/api/v1/client/[environmentId]/environment/route.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/route.ts) - Main endpoint serving hundreds of thousands of SDK clients
- [apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts) - Optimized data layer with caching
## Enterprise-Grade Cache Key Patterns
**Always use** the `createCacheKey` utilities from [cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts):
```typescript
// ✅ Correct patterns
createCacheKey.environment.state(environmentId) // "fb:env:abc123:state"
createCacheKey.organization.billing(organizationId) // "fb:org:xyz789:billing"
createCacheKey.license.status(organizationId) // "fb:license:org123:status"
createCacheKey.user.permissions(userId, orgId) // "fb:user:456:org:123:permissions"
// ❌ Never use flat keys - collision-prone
"environment_abc123"
"user_data_456"
```
## When to Use Each Cache Type
### Use React `cache()` for Request Deduplication
```typescript
// ✅ Prevents multiple calls within same request
export const getEnterpriseLicense = reactCache(async () => {
// Complex license validation logic
});
```
### Use `withCache()` for Simple Database Queries
```typescript
// ✅ Simple caching with automatic fallback (TTL in milliseconds)
export const getActionClasses = (environmentId: string) => {
return withCache(() => fetchActionClassesFromDB(environmentId), {
key: createCacheKey.environment.actionClasses(environmentId),
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
})();
};
```
### Use Explicit Redis Cache for Complex Business Logic
```typescript
// ✅ Full control for high-stakes endpoints
export const getEnvironmentState = async (environmentId: string) => {
const cached = await environmentStateCache.getEnvironmentState(environmentId);
if (cached) return cached;
const fresh = await buildComplexState(environmentId);
await environmentStateCache.setEnvironmentState(environmentId, fresh);
return fresh;
};
```
## Caching Decision Framework
### When TO Add Caching
```typescript
// ✅ Expensive operations that benefit from caching
- Database queries (>10ms typical)
- External API calls (>50ms typical)
- Complex computations (>5ms)
- File system operations
- Heavy data transformations
// Example: Database query with complex joins (TTL in milliseconds)
export const getEnvironmentWithDetails = withCache(
async (environmentId: string) => {
return prisma.environment.findUnique({
where: { id: environmentId },
include: { /* complex joins */ }
});
},
{ key: createCacheKey.environment.details(environmentId), ttl: 60 * 30 * 1000 } // 30 minutes
)();
```
### When NOT to Add Caching
```typescript
// ❌ Don't cache these operations - minimal overhead
- Simple property access (<0.1ms)
- Basic transformations (<1ms)
- Functions that just call already-cached functions
- Pure computation without I/O
// ❌ Bad example: Redundant caching
const getCachedLicenseFeatures = withCache(
async () => {
const license = await getEnterpriseLicense(); // Already cached!
return license.active ? license.features : null; // Just property access
},
{ key: "license-features", ttl: 1800 * 1000 } // 30 minutes in milliseconds
);
// ✅ Good example: Simple and efficient
const getLicenseFeatures = async () => {
const license = await getEnterpriseLicense(); // Already cached
return license.active ? license.features : null; // 0.1ms overhead
};
```
### Computational Overhead Analysis
Before adding caching, analyze the overhead:
```typescript
// ✅ High overhead - CACHE IT
- Database queries: ~10-100ms
- External APIs: ~50-500ms
- File I/O: ~5-50ms
- Complex algorithms: >5ms
// ❌ Low overhead - DON'T CACHE
- Property access: ~0.001ms
- Simple lookups: ~0.1ms
- Basic validation: ~1ms
- Type checks: ~0.01ms
// Example decision tree:
const expensiveOperation = async () => {
return prisma.query(); // 50ms - CACHE IT
};
const cheapOperation = (data: any) => {
return data.property; // 0.001ms - DON'T CACHE
};
```
### Avoid Cache Wrapper Anti-Pattern
```typescript
// ❌ Don't create wrapper functions just for caching
const getCachedUserPermissions = withCache(
async (userId: string) => getUserPermissions(userId),
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
);
// ✅ Add caching directly to the original function
export const getUserPermissions = withCache(
async (userId: string) => {
return prisma.user.findUnique({
where: { id: userId },
include: { permissions: true }
});
},
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
);
```
## TTL Coordination Strategy
### Multi-Layer Cache Coordination
For endpoints serving client SDKs, coordinate TTLs across layers:
```typescript
// Client SDK cache (expiresAt) - longest TTL for fewer requests
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
// Server Redis cache - shorter TTL ensures fresh data for clients
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
// HTTP cache headers (seconds)
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
```
### Standard TTL Guidelines (in milliseconds for cache-manager + Keyv)
```typescript
// Configuration data - rarely changes
const CONFIG_TTL = 60 * 60 * 24 * 1000; // 24 hours
// User data - moderate frequency
const USER_TTL = 60 * 60 * 2 * 1000; // 2 hours
// Survey data - changes moderately
const SURVEY_TTL = 60 * 15 * 1000; // 15 minutes
// Billing data - expensive to compute
const BILLING_TTL = 60 * 30 * 1000; // 30 minutes
// Action classes - infrequent changes
const ACTION_CLASS_TTL = 60 * 30 * 1000; // 30 minutes
```
## High-Frequency Endpoint Optimization
### Performance Patterns for High-Volume Endpoints
```typescript
// ✅ Optimized high-frequency endpoint pattern
export const GET = async (request: NextRequest, props: { params: Promise<{ id: string }> }) => {
const params = await props.params;
try {
// Simple validation (avoid Zod for high-frequency)
if (!params.id || typeof params.id !== 'string') {
return responses.badRequestResponse("ID is required", undefined, true);
}
// Single optimized query with caching
const data = await getOptimizedData(params.id);
return responses.successResponse(
{
data,
expiresAt: new Date(Date.now() + CLIENT_TTL * 1000), // SDK cache duration
},
true,
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
);
} catch (err) {
// Simplified error handling for performance
if (err instanceof ResourceNotFoundError) {
return responses.notFoundResponse(err.resourceType, err.resourceId);
}
logger.error({ error: err, url: request.url }, "Error in high-frequency endpoint");
return responses.internalServerErrorResponse(err.message, true);
}
};
```
### Avoid These Performance Anti-Patterns
```typescript
// ❌ Avoid for high-frequency endpoints
const inputValidation = ZodSchema.safeParse(input); // Too slow
const startTime = Date.now(); logger.debug(...); // Logging overhead
const { data, revalidateEnvironment } = await get(); // Complex return types
```
### CORS Optimization
```typescript
// ✅ Balanced CORS caching (not too aggressive)
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
true,
"public, s-maxage=3600, max-age=3600" // 1 hour balanced approach
);
};
```
## Redis Cache Migration from Next.js
### Avoid Legacy Next.js Patterns
```typescript
// ❌ Old Next.js unstable_cache pattern (avoid)
const getCachedData = unstable_cache(
async (id) => fetchData(id),
['cache-key'],
{ tags: ['environment'], revalidate: 900 }
);
// ❌ Don't use revalidateEnvironment flags with Redis
return { data, revalidateEnvironment: true }; // This gets cached incorrectly!
// ✅ New Redis pattern with withCache (TTL in milliseconds)
export const getCachedData = (id: string) =>
withCache(
() => fetchData(id),
{
key: createCacheKey.environment.data(id),
ttl: 60 * 15 * 1000, // 15 minutes in milliseconds
}
)();
```
### Remove Revalidation Logic
When migrating from Next.js `unstable_cache`:
- Remove `revalidateEnvironment` or similar flags
- Remove tag-based invalidation logic
- Use TTL-based expiration instead
- Handle one-time updates (like `appSetupCompleted`) directly in cache
## Data Layer Optimization
### Single Query Pattern
```typescript
// ✅ Optimize with single database query
export const getOptimizedEnvironmentData = async (environmentId: string) => {
return prisma.environment.findUniqueOrThrow({
where: { id: environmentId },
include: {
project: {
select: { id: true, recontactDays: true, /* ... */ }
},
organization: {
select: { id: true, billing: true }
},
surveys: {
where: { status: "inProgress" },
select: { id: true, name: true, /* ... */ }
},
actionClasses: {
select: { id: true, name: true, /* ... */ }
}
}
});
};
// ❌ Avoid multiple separate queries
const environment = await getEnvironment(id);
const organization = await getOrganization(environment.organizationId);
const surveys = await getSurveys(id);
const actionClasses = await getActionClasses(id);
```
## Invalidation Best Practices
**Always use explicit key-based invalidation:**
```typescript
// ✅ Clear and debuggable
await invalidateCache(createCacheKey.environment.state(environmentId));
await invalidateCache([
createCacheKey.environment.surveys(environmentId),
createCacheKey.environment.actionClasses(environmentId)
]);
// ❌ Avoid complex tag systems
await invalidateByTags(["environment", "survey"]); // Don't do this
```
## Critical Performance Targets
### High-Frequency Endpoint Goals
- **Cache hit ratio**: >85%
- **Response time P95**: <200ms
- **Database load reduction**: >60%
- **HTTP cache duration**: 1hr browser, 30min Cloudflare
- **SDK refresh interval**: 1 hour with 30min server cache
### Performance Monitoring
- Use **existing elastic cache analytics** for metrics
- Log cache errors and warnings (not debug info)
- Track database query reduction
- Monitor response times for cached endpoints
- **Avoid performance logging** in high-frequency endpoints
## Error Handling Pattern
Always provide fallback to fresh data on cache errors:
```typescript
try {
const cached = await cache.get(key);
if (cached) return cached;
const fresh = await fetchFresh();
await cache.set(key, fresh, ttl); // ttl in milliseconds
return fresh;
} catch (error) {
// ✅ Always fallback to fresh data
logger.warn("Cache error, fetching fresh", { key, error });
return fetchFresh();
}
```
## Common Pitfalls to Avoid
1. **Never use Next.js `unstable_cache()`** - unreliable in production
2. **Don't use revalidation flags with Redis** - they get cached incorrectly
3. **Avoid Zod validation** for simple parameters in high-frequency endpoints
4. **Don't add performance logging** to high-frequency endpoints
5. **Coordinate TTLs** between client and server caches
6. **Don't over-engineer** with complex tag systems
7. **Avoid caching rapidly changing data** (real-time metrics)
8. **Always validate cache keys** to prevent collisions
9. **Don't add redundant caching layers** - analyze computational overhead first
10. **Avoid cache wrapper functions** - add caching directly to expensive operations
11. **Don't cache property access or simple transformations** - overhead is negligible
12. **Analyze the full call chain** before adding caching to avoid double-caching
13. **Remember TTL is in milliseconds** for cache-manager + Keyv stack (not seconds)
## Monitoring Strategy
- Use **existing elastic cache analytics** for metrics
- Log cache errors and warnings
- Track database query reduction
- Monitor response times for cached endpoints
- **Don't add custom metrics** that duplicate existing monitoring
## Important Notes
### TTL Units
- **cache-manager + Keyv**: TTL in **milliseconds**
- **Direct Redis commands**: TTL in **seconds** (EXPIRE, SETEX) or **milliseconds** (PEXPIRE, PSETEX)
- **HTTP cache headers**: TTL in **seconds** (max-age, s-maxage)
- **Client SDK**: TTL in **seconds** (expiresAt calculation)

View File

@@ -0,0 +1,41 @@
---
description:
globs:
alwaysApply: false
---
# Database Performance & Prisma Best Practices
## Critical Performance Rules
### Response Count Queries
- **NEVER** use `skip`/`offset` with `prisma.response.count()` - this causes expensive subqueries with OFFSET
- Always use only `where` clauses for count operations: `prisma.response.count({ where: { ... } })`
- For pagination, separate count queries from data queries
- Reference: [apps/web/lib/response/service.ts](mdc:apps/web/lib/response/service.ts) line 654-686
### Prisma Query Optimization
- Use proper indexes defined in [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
- Leverage existing indexes: `@@index([surveyId, createdAt])`, `@@index([createdAt])`
- Use cursor-based pagination for large datasets instead of offset-based
- Cache frequently accessed data using React Cache and custom cache tags
### Date Range Filtering
- When filtering by `createdAt`, always use indexed queries
- Combine with `surveyId` for optimal performance: `{ surveyId, createdAt: { gte: start, lt: end } }`
- Avoid complex WHERE clauses that can't utilize indexes
### Count vs Data Separation
- Always separate count queries from data fetching queries
- Use `Promise.all()` to run count and data queries in parallel
- Example pattern from [apps/web/modules/api/v2/management/responses/lib/response.ts](mdc:apps/web/modules/api/v2/management/responses/lib/response.ts):
```typescript
const [responses, totalCount] = await Promise.all([
prisma.response.findMany(query),
prisma.response.count({ where: whereClause }),
]);
```
### Monitoring & Debugging
- Monitor AWS RDS Performance Insights for problematic queries
- Look for queries with OFFSET in count operations - these indicate performance issues
- Use proper error handling with `DatabaseError` for Prisma exceptions

101
.cursor/rules/database.mdc Normal file
View 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.

View File

@@ -0,0 +1,23 @@
---
description: Guideline for writing end-user facing documentation in the apps/docs folder
globs:
alwaysApply: false
---
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
---
title: "FEATURE NAME"
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
icon: "link"
---
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
- If a feature is part of the Enterprise Edition, use this note:
<Note>
FEATURE NAME is part of the @Enterprise Edition.
</Note>

View File

@@ -0,0 +1,152 @@
---
description:
globs:
alwaysApply: false
---
# EKS & ALB Optimization Guide for Error Reduction
## Infrastructure Overview
This project uses AWS EKS with Application Load Balancer (ALB) for the Formbricks application. The infrastructure has been optimized to minimize ELB 502/504 errors through careful configuration of connection handling, health checks, and pod lifecycle management.
## Key Infrastructure Files
### Terraform Configuration
- **Main Infrastructure**: [infra/terraform/main.tf](mdc:infra/terraform/main.tf) - EKS cluster, VPC, Karpenter, and core AWS resources
- **Monitoring**: [infra/terraform/cloudwatch.tf](mdc:infra/terraform/cloudwatch.tf) - CloudWatch alarms for 502/504 error tracking and alerting
- **Database**: [infra/terraform/rds.tf](mdc:infra/terraform/rds.tf) - Aurora PostgreSQL configuration
### Helm Configuration
- **Production**: [infra/formbricks-cloud-helm/values.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values.yaml.gotmpl) - Optimized ALB and pod configurations
- **Staging**: [infra/formbricks-cloud-helm/values-staging.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values-staging.yaml.gotmpl) - Staging environment with spot instances
- **Deployment**: [infra/formbricks-cloud-helm/helmfile.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/helmfile.yaml.gotmpl) - Multi-environment Helm releases
## ALB Optimization Patterns
### Connection Handling Optimizations
```yaml
# Key ALB annotations for reducing 502/504 errors
alb.ingress.kubernetes.io/load-balancer-attributes: |
idle_timeout.timeout_seconds=120,
connection_logs.s3.enabled=false,
access_logs.s3.enabled=false
alb.ingress.kubernetes.io/target-group-attributes: |
deregistration_delay.timeout_seconds=30,
stickiness.enabled=false,
load_balancing.algorithm.type=least_outstanding_requests,
target_group_health.dns_failover.minimum_healthy_targets.count=1
```
### Health Check Configuration
- **Interval**: 15 seconds for faster detection of unhealthy targets
- **Timeout**: 5 seconds to prevent false positives
- **Thresholds**: 2 healthy, 3 unhealthy for balanced responsiveness
- **Path**: `/health` endpoint optimized for < 100ms response time
## Pod Lifecycle Management
### Graceful Shutdown Pattern
```yaml
# PreStop hook to allow connection draining
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
# Termination grace period for complete cleanup
terminationGracePeriodSeconds: 45
```
### Health Probe Strategy
- **Startup Probe**: 5s initial delay, 5s interval, max 60s startup time
- **Readiness Probe**: 10s delay, 10s interval for traffic readiness
- **Liveness Probe**: 30s delay, 30s interval for container health
### Rolling Update Configuration
```yaml
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 25% # Maintain capacity during updates
maxSurge: 50% # Allow faster rollouts
```
## Karpenter Node Management
### Node Lifecycle Optimization
- **Startup Taints**: Prevent traffic during node initialization
- **Graceful Shutdown**: 30s grace period for pod eviction
- **Consolidation Delay**: 60s to reduce unnecessary churn
- **Eviction Policies**: Configured for smooth pod migrations
### Instance Selection
- **Families**: c8g, c7g, m8g, m7g, r8g, r7g (ARM64 Graviton)
- **Sizes**: 2, 4, 8 vCPUs for cost optimization
- **Bottlerocket AMI**: Enhanced security and performance
## Monitoring & Alerting
### Critical ALB Metrics
1. **ELB 502 Errors**: Threshold 20 over 5 minutes
2. **ELB 504 Errors**: Threshold 15 over 5 minutes
3. **Target Connection Errors**: Threshold 50 over 5 minutes
4. **4XX Errors**: Threshold 100 over 10 minutes (client issues)
### Expected Improvements
- **60-80% reduction** in ELB 502 errors
- **Faster recovery** during pod restarts
- **Better connection reuse** efficiency
- **Improved autoscaling** responsiveness
## Deployment Patterns
### Infrastructure Updates
1. **Terraform First**: Apply infrastructure changes via [infra/deploy-improvements.sh](mdc:infra/deploy-improvements.sh)
2. **Helm Second**: Deploy application configurations
3. **Verification**: Check pod status, endpoints, and ALB health
4. **Monitoring**: Watch CloudWatch metrics for 24-48 hours
### Environment-Specific Configurations
- **Production**: On-demand instances, stricter resource limits
- **Staging**: Spot instances, rate limiting disabled, relaxed resources
## Troubleshooting Patterns
### 502 Error Investigation
1. Check pod readiness and health probe status
2. Verify ALB target group health
3. Review deregistration timing during deployments
4. Monitor connection pool utilization
### 504 Error Analysis
1. Check application response times
2. Verify timeout configurations (ALB: 120s, App: aligned)
3. Review database query performance
4. Monitor resource utilization during traffic spikes
### Connection Error Patterns
1. Verify Karpenter node lifecycle timing
2. Check pod termination grace periods
3. Review ALB connection draining settings
4. Monitor cluster autoscaling events
## Best Practices
### When Making Changes
- **Test in staging first** with same configurations
- **Monitor metrics** for 24-48 hours after changes
- **Use gradual rollouts** with proper health checks
- **Maintain ALB timeout alignment** across all layers
### Performance Optimization
- **Health endpoint** should respond < 100ms consistently
- **Connection pooling** aligned with ALB idle timeouts
- **Resource requests/limits** tuned for consistent performance
- **Graceful shutdown** implemented in application code
### Monitoring Strategy
- **Real-time alerts** for error rate spikes
- **Trend analysis** for connection patterns
- **Capacity planning** based on LCU usage
- **4XX pattern analysis** for client behavior insights

View File

@@ -0,0 +1,334 @@
---
description:
globs:
alwaysApply: false
---
# Formbricks Architecture & Patterns
## Monorepo Structure
### Apps Directory
- `apps/web/` - Main Next.js web application
- `packages/` - Shared packages and utilities
### Key Directories in Web App
```
apps/web/
├── app/ # Next.js 13+ app directory
│ ├── (app)/ # Main application routes
│ ├── (auth)/ # Authentication routes
│ ├── api/ # API routes
│ └── share/ # Public sharing routes
├── components/ # Shared components
├── lib/ # Utility functions and services
└── modules/ # Feature-specific modules
```
## Routing Patterns
### App Router Structure
The application uses Next.js 13+ app router with route groups:
```
(app)/environments/[environmentId]/
├── surveys/[surveyId]/
│ ├── (analysis)/ # Analysis views
│ │ ├── responses/ # Response management
│ │ ├── summary/ # Survey summary
│ │ └── hooks/ # Analysis-specific hooks
│ ├── edit/ # Survey editing
│ └── settings/ # Survey settings
```
### Dynamic Routes
- `[environmentId]` - Environment-specific routes
- `[surveyId]` - Survey-specific routes
- `[sharingKey]` - Public sharing routes
## Service Layer Pattern
### Service Organization
Services are organized by domain in `apps/web/lib/`:
```typescript
// Example: Response service
// apps/web/lib/response/service.ts
export const getResponseCountAction = async ({
surveyId,
filterCriteria,
}: {
surveyId: string;
filterCriteria: any;
}) => {
// Service implementation
};
```
### Action Pattern
Server actions follow a consistent pattern:
```typescript
// Action wrapper for service calls
export const getResponseCountAction = async (params) => {
try {
const result = await responseService.getCount(params);
return { data: result };
} catch (error) {
return { error: error.message };
}
};
```
## Context Patterns
### Provider Structure
Context providers follow a consistent pattern:
```typescript
// Provider component
export const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => {
const [selectedFilter, setSelectedFilter] = useState(defaultFilter);
const value = {
selectedFilter,
setSelectedFilter,
// ... other state and methods
};
return (
<ResponseFilterContext.Provider value={value}>
{children}
</ResponseFilterContext.Provider>
);
};
// Hook for consuming context
export const useResponseFilter = () => {
const context = useContext(ResponseFilterContext);
if (!context) {
throw new Error('useResponseFilter must be used within ResponseFilterProvider');
}
return context;
};
```
### Context Composition
Multiple contexts are often composed together:
```typescript
// Layout component with multiple providers
export default function AnalysisLayout({ children }: { children: React.ReactNode }) {
return (
<ResponseFilterProvider>
<ResponseCountProvider>
{children}
</ResponseCountProvider>
</ResponseFilterProvider>
);
}
```
## Component Patterns
### Page Components
Page components are located in the app directory and follow this pattern:
```typescript
// apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
export default function ResponsesPage() {
return (
<div>
<ResponsesTable />
<ResponsesPagination />
</div>
);
}
```
### Component Organization
- **Pages** - Route components in app directory
- **Components** - Reusable UI components
- **Modules** - Feature-specific components and logic
### Shared Components
Common components are in `apps/web/components/`:
- UI components (buttons, inputs, modals)
- Layout components (headers, sidebars)
- Data display components (tables, charts)
## Hook Patterns
### Custom Hook Structure
Custom hooks follow consistent patterns:
```typescript
export const useResponseCount = ({
survey,
initialCount
}: {
survey: TSurvey;
initialCount?: number;
}) => {
const [responseCount, setResponseCount] = useState(initialCount ?? 0);
const [isLoading, setIsLoading] = useState(false);
// Hook logic...
return {
responseCount,
isLoading,
refetch,
};
};
```
### Hook Dependencies
- Use context hooks for shared state
- Implement proper cleanup with AbortController
- Optimize dependency arrays to prevent unnecessary re-renders
## Data Fetching Patterns
### Server Actions
The app uses Next.js server actions for data fetching:
```typescript
// Server action
export async function getResponsesAction(params: GetResponsesParams) {
const responses = await getResponses(params);
return { data: responses };
}
// Client usage
const { data } = await getResponsesAction(params);
```
### Error Handling
Consistent error handling across the application:
```typescript
try {
const result = await apiCall();
return { data: result };
} catch (error) {
console.error("Operation failed:", error);
return { error: error.message };
}
```
## Type Safety
### Type Organization
Types are organized in packages:
- `@formbricks/types` - Shared type definitions
- Local types in component/hook files
### Common Types
```typescript
import { TSurvey } from "@formbricks/types/surveys/types";
import { TResponse } from "@formbricks/types/responses";
import { TEnvironment } from "@formbricks/types/environment";
```
## State Management
### Local State
- Use `useState` for component-specific state
- Use `useReducer` for complex state logic
- Use refs for mutable values that don't trigger re-renders
### Global State
- React Context for feature-specific shared state
- URL state for filters and pagination
- Server state through server actions
## Performance Considerations
### Code Splitting
- Dynamic imports for heavy components
- Route-based code splitting with app router
- Lazy loading for non-critical features
### Caching Strategy
- Server-side caching for database queries
- Client-side caching with React Query (where applicable)
- Static generation for public pages
## Testing Strategy
### Test Organization
```
component/
├── Component.tsx
├── Component.test.tsx
└── hooks/
├── useHook.ts
└── useHook.test.tsx
```
### Test Patterns
- Unit tests for utilities and services
- Integration tests for components with context
- Hook tests with proper mocking
## Build & Deployment
### Build Process
- TypeScript compilation
- Next.js build optimization
- Asset optimization and bundling
### Environment Configuration
- Environment-specific configurations
- Feature flags for gradual rollouts
- Database connection management
## Security Patterns
### Authentication
- Session-based authentication
- Environment-based access control
- API route protection
### Data Validation
- Input validation on both client and server
- Type-safe API contracts
- Sanitization of user inputs
## Monitoring & Observability
### Error Tracking
- Client-side error boundaries
- Server-side error logging
- Performance monitoring
### Analytics
- User interaction tracking
- Performance metrics
- Database query monitoring
## Best Practices Summary
### Code Organization
- ✅ Follow the established directory structure
- ✅ Use consistent naming conventions
- ✅ Separate concerns (UI, logic, data)
- ✅ Keep components focused and small
### Performance
- ✅ Implement proper loading states
- ✅ Use AbortController for async operations
- ✅ Optimize database queries
- ✅ Implement proper caching strategies
### Type Safety
- ✅ Use TypeScript throughout
- ✅ Define proper interfaces for props
- ✅ Use type guards for runtime validation
- ✅ Leverage shared type packages
### Testing
- ✅ Write tests for critical functionality
- ✅ Mock external dependencies properly
- ✅ Test error scenarios and edge cases
- ✅ Maintain good test coverage

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -0,0 +1,52 @@
---
description:
globs:
alwaysApply: false
---
# React Context & Provider Patterns
## Context Provider Best Practices
### Provider Implementation
- Use TypeScript interfaces for provider props with optional `initialCount` for testing
- Implement proper cleanup in `useEffect` to avoid React hooks warnings
- Reference: [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx)
### Cleanup Pattern for Refs
```typescript
useEffect(() => {
const currentPendingRequests = pendingRequests.current;
const currentAbortController = abortController.current;
return () => {
if (currentAbortController) {
currentAbortController.abort();
}
currentPendingRequests.clear();
};
}, []);
```
### Testing Context Providers
- Always wrap components using context in the provider during tests
- Use `initialCount` prop for predictable test scenarios
- Mock context dependencies like `useParams`, `useResponseFilter`
- Example from [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx):
```typescript
render(
<ResponseCountProvider survey={dummySurvey} initialCount={5}>
<ComponentUnderTest />
</ResponseCountProvider>
);
```
### Required Mocks for Context Testing
- Mock `next/navigation` with `useParams` returning environment and survey IDs
- Mock response filter context and actions
- Mock API actions that the provider depends on
### Context Hook Usage
- Create custom hooks like `useResponseCountContext()` for consuming context
- Provide meaningful error messages when context is used outside provider
- Use context for shared state that multiple components need to access

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -0,0 +1,327 @@
---
description:
globs:
alwaysApply: false
---
# Testing Patterns & Best Practices
## Running Tests
### Test Commands
From the **root directory** (formbricks/):
- `npm test` - Run all tests across all packages (recommended for CI/full testing)
- `npm run test:coverage` - Run all tests with coverage reports
- `npm run test:e2e` - Run end-to-end tests with Playwright
From the **apps/web directory** (apps/web/):
- `npm run test` - Run only web app tests (fastest for development)
- `npm run test:coverage` - Run web app tests with coverage
- `npm run test -- <file-pattern>` - Run specific test files
### Examples
```bash
# Run all tests from root (takes ~3 minutes, runs 790 test files with 5334+ tests)
npm test
# Run specific test file from apps/web (fastest for development)
npm run test -- modules/cache/lib/service.test.ts
# Run tests matching pattern from apps/web
npm run test -- modules/ee/license-check/lib/license.test.ts
# Run with coverage from root
npm run test:coverage
# Run specific test with watch mode from apps/web (for development)
npm run test -- --watch modules/cache/lib/service.test.ts
# Run tests for a specific directory from apps/web
npm run test -- modules/cache/
```
### Performance Tips
- **For development**: Use `apps/web` directory commands to run only web app tests
- **For CI/validation**: Use root directory commands to run all packages
- **For specific features**: Use file patterns to target specific test files
- **For debugging**: Use `--watch` mode for continuous testing during development
### Test File Organization
- Place test files in the **same directory** as the source file
- Use `.test.ts` for utility/service tests (Node environment)
- Use `.test.tsx` for React component tests (jsdom environment)
## Test File Naming & Environment
### File Extensions
- Use `.test.tsx` for React component/hook tests (runs in jsdom environment)
- Use `.test.ts` for utility/service tests (runs in Node environment)
- The vitest config uses `environmentMatchGlobs` to automatically set jsdom for `.tsx` files
### Test Structure
```typescript
// Import the mocked functions first
import { useHook } from "@/path/to/hook";
import { serviceFunction } from "@/path/to/service";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock dependencies
vi.mock("@/path/to/hook", () => ({
useHook: vi.fn(),
}));
describe("ComponentName", () => {
beforeEach(() => {
vi.clearAllMocks();
// Setup default mocks
});
test("descriptive test name", async () => {
// Test implementation
});
});
```
## React Hook Testing
### Context Mocking
When testing hooks that use React Context:
```typescript
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [],
onlyComplete: false,
},
setSelectedFilter: vi.fn(),
selectedOptions: {
questionOptions: [],
questionFilterOptions: [],
},
setSelectedOptions: vi.fn(),
dateRange: { from: new Date(), to: new Date() },
setDateRange: vi.fn(),
resetState: vi.fn(),
});
```
### Testing Async Hooks
- Always use `waitFor` for async operations
- Test both loading and completed states
- Verify API calls with correct parameters
```typescript
test("fetches data on mount", async () => {
const { result } = renderHook(() => useHook());
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBe(expectedData);
expect(vi.mocked(apiCall)).toHaveBeenCalledWith(expectedParams);
});
```
### Testing Hook Dependencies
To test useEffect dependencies, ensure mocks return different values:
```typescript
// First render
mockGetFormattedFilters.mockReturnValue(mockFilters);
// Change dependency and trigger re-render
const newMockFilters = { ...mockFilters, finished: true };
mockGetFormattedFilters.mockReturnValue(newMockFilters);
rerender();
```
## Performance Testing
### Race Condition Testing
Test AbortController implementation:
```typescript
test("cancels previous request when new request is made", async () => {
let resolveFirst: (value: any) => void;
let resolveSecond: (value: any) => void;
const firstPromise = new Promise((resolve) => {
resolveFirst = resolve;
});
const secondPromise = new Promise((resolve) => {
resolveSecond = resolve;
});
vi.mocked(apiCall)
.mockReturnValueOnce(firstPromise as any)
.mockReturnValueOnce(secondPromise as any);
const { result } = renderHook(() => useHook());
// Trigger second request
result.current.refetch();
// Resolve in order - first should be cancelled
resolveFirst!({ data: 100 });
resolveSecond!({ data: 200 });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Should have result from second request
expect(result.current.data).toBe(200);
});
```
### Cleanup Testing
```typescript
test("cleans up on unmount", () => {
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
const { unmount } = renderHook(() => useHook());
unmount();
expect(abortSpy).toHaveBeenCalled();
abortSpy.mockRestore();
});
```
## Error Handling Testing
### API Error Testing
```typescript
test("handles API errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(apiCall).mockRejectedValue(new Error("API Error"));
const { result } = renderHook(() => useHook());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(consoleSpy).toHaveBeenCalledWith("Error message:", expect.any(Error));
expect(result.current.data).toBe(fallbackValue);
consoleSpy.mockRestore();
});
```
### Cancelled Request Testing
```typescript
test("does not update state for cancelled requests", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
let rejectFirst: (error: any) => void;
const firstPromise = new Promise((_, reject) => {
rejectFirst = reject;
});
vi.mocked(apiCall)
.mockReturnValueOnce(firstPromise as any)
.mockResolvedValueOnce({ data: 42 });
const { result } = renderHook(() => useHook());
result.current.refetch();
const abortError = new Error("Request cancelled");
rejectFirst!(abortError);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Should not log error for cancelled request
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
```
## Type Safety in Tests
### Mock Type Assertions
Use type assertions for edge cases:
```typescript
vi.mocked(apiCall).mockResolvedValue({
data: null as any, // For testing null handling
});
vi.mocked(apiCall).mockResolvedValue({
data: undefined as any, // For testing undefined handling
});
```
### Proper Mock Typing
Ensure mocks match the actual interface:
```typescript
const mockSurvey: TSurvey = {
id: "survey-123",
name: "Test Survey",
// ... other required properties
} as unknown as TSurvey; // Use when partial mocking is needed
```
## Common Test Patterns
### Testing State Changes
```typescript
test("updates state correctly", async () => {
const { result } = renderHook(() => useHook());
// Initial state
expect(result.current.value).toBe(initialValue);
// Trigger change
result.current.updateValue(newValue);
// Verify change
expect(result.current.value).toBe(newValue);
});
```
### Testing Multiple Scenarios
```typescript
test("handles different modes", async () => {
// Test regular mode
vi.mocked(useParams).mockReturnValue({ surveyId: "123" });
const { rerender } = renderHook(() => useHook());
await waitFor(() => {
expect(vi.mocked(regularApi)).toHaveBeenCalled();
});
// Test sharing mode
vi.mocked(useParams).mockReturnValue({
surveyId: "123",
sharingKey: "share-123"
});
rerender();
await waitFor(() => {
expect(vi.mocked(sharingApi)).toHaveBeenCalled();
});
});
```
## Test Organization
### Comprehensive Test Coverage
For hooks, ensure you test:
- ✅ Initialization (with/without initial values)
- ✅ Data fetching (success/error cases)
- ✅ State updates and refetching
- ✅ Dependency changes triggering effects
- ✅ Manual actions (refetch, reset)
- ✅ Race condition prevention
- ✅ Cleanup on unmount
- ✅ Mode switching (if applicable)
- ✅ Edge cases (null/undefined data)
### Test Naming
Use descriptive test names that explain the scenario:
- ✅ "initializes with initial count"
- ✅ "fetches response count on mount for regular survey"
- ✅ "cancels previous request when new request is made"
- ❌ "test hook"
- ❌ "it works"

View File

@@ -0,0 +1,7 @@
---
description: Whenever the user asks to write or update a test file for .tsx or .ts files.
globs:
alwaysApply: false
---
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.

View File

@@ -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 #
@@ -190,7 +190,7 @@ 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:
@@ -211,5 +211,13 @@ UNKEY_ROOT_KEY=
# It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN=
# Disable the user management from UI
# DISABLE_USER_MANAGEMENT=1
# 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. Requires REDIS_URL env varibale. 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

View File

@@ -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

View File

@@ -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

1
.gitignore vendored
View File

@@ -73,3 +73,4 @@ infra/terraform/.terraform/
/.idea/
/*.iml
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
.cursorrules

View File

@@ -11,9 +11,7 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.20",
"react": "19.1.0",
"react-dom": "19.1.0"
"eslint-plugin-react-refresh": "0.4.20"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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,
};

View File

@@ -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}",
});
}

View File

@@ -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}
/>

View File

@@ -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",
@@ -85,6 +85,9 @@ vi.mock("@/lib/constants", () => ({
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next/navigation", () => ({

View File

@@ -12,20 +12,6 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/cache", () => ({
cache: (fn: any) => fn,
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
tag: { byOrganizationId: vi.fn((id: string) => `organization-${id}-teams`) },
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
describe("getTeamsByOrganizationId", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -1,8 +1,6 @@
"use server";
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
import { cache } from "@/lib/cache";
import { teamCache } from "@/lib/cache/team";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
@@ -11,38 +9,31 @@ import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const getTeamsByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationTeam[] | null> =>
cache(
async () => {
validateInputs([organizationId, ZId]);
try {
const teams = await prisma.team.findMany({
where: {
organizationId,
},
select: {
id: true,
name: true,
},
});
async (organizationId: string): Promise<TOrganizationTeam[] | null> => {
validateInputs([organizationId, ZId]);
try {
const teams = await prisma.team.findMany({
where: {
organizationId,
},
select: {
id: true,
name: true,
},
});
const projectTeams = teams.map((team) => ({
id: team.id,
name: team.name,
}));
const projectTeams = teams.map((team) => ({
id: team.id,
name: team.name,
}));
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getTeamsByOrganizationId-${organizationId}`],
{
tags: [teamCache.tag.byOrganizationId(organizationId)],
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
)()
throw error;
}
}
);

View File

@@ -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,12 @@ 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",
});
});
});

View File

@@ -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,13 @@ 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",
});
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -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",
@@ -88,6 +88,9 @@ vi.mock("@/lib/constants", () => ({
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/environment/service");

View File

@@ -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",
@@ -97,6 +96,9 @@ vi.mock("@/lib/constants", () => ({
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({

View File

@@ -34,6 +34,9 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next-auth", () => ({

View File

@@ -33,6 +33,9 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
// Mock dependencies

View File

@@ -25,6 +25,15 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: "redis://localhost:6379",
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("Contact Page Re-export", () => {

View File

@@ -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;
});
)
);

View File

@@ -1,11 +1,12 @@
"use server";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { cache } from "@/lib/cache";
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";
@@ -15,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,
@@ -104,31 +122,24 @@ export const getActiveInactiveSurveysAction = authenticatedActionClient
return response;
});
const getLatestStableFbRelease = async (): Promise<string | null> =>
cache(
async () => {
try {
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
const releases = await res.json();
const getLatestStableFbRelease = async (): Promise<string | null> => {
try {
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
const releases = await res.json();
if (Array.isArray(releases)) {
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
?.tag_name as string;
if (latestStableReleaseTag) {
return latestStableReleaseTag;
}
}
return null;
} catch (err) {
return null;
if (Array.isArray(releases)) {
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
?.tag_name as string;
if (latestStableReleaseTag) {
return latestStableReleaseTag;
}
},
["latest-fb-release"],
{
revalidate: 60 * 60 * 24, // 24 hours
}
)();
return null;
} catch (err) {
return null;
}
};
export const getLatestStableFbReleaseAction = actionClient.action(async () => {
return await getLatestStableFbRelease();

View File

@@ -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,12 @@ 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
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
render(<MainNavigation {...defaultProps} />);
// Find the avatar and get its parent div which acts as the trigger
@@ -224,10 +243,23 @@ describe("MainNavigation", () => {
const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton);
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
// Verify localStorage.removeItem is called with the correct key
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org1",
redirect: false,
callbackUrl: "/auth/login",
});
await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
});
// Clean up spy
removeItemSpy.mockRestore();
});
test("handles organization switching", async () => {

View File

@@ -4,8 +4,10 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -42,7 +44,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 +91,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 +391,16 @@ export const MainNavigation = ({
<DropdownMenuItem
onClick={async () => {
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
router.push(route.url);
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: false,
callbackUrl: "/auth/login",
});
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")}

View File

@@ -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;
}
)
);

View File

@@ -48,6 +48,9 @@ vi.mock("@/lib/constants", () => ({
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/integration/service");

View File

@@ -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";

View File

@@ -1,10 +1,8 @@
import { cache } from "@/lib/cache";
import { surveyCache } from "@/lib/survey/cache";
import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
@@ -12,14 +10,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { getSurveys } from "./surveys";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
tag: {
byEnvironmentId: vi.fn((environmentId) => `survey_environment_${environmentId}`),
},
},
}));
vi.mock("@/lib/survey/service", () => ({
selectSurvey: { id: true, name: true, status: true, updatedAt: true }, // Expanded mock based on usage
}));
@@ -46,11 +36,11 @@ vi.mock("react", async (importOriginal) => {
});
const environmentId = "test-environment-id";
// Ensure mockPrismaSurveys includes all fields used in selectSurvey mock
// Use 'as any' to bypass complex type matching for mock data
const mockPrismaSurveys = [
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() },
{ id: "survey2", name: "Survey 2", status: "draft", updatedAt: new Date() },
];
] as any; // Use 'as any' to bypass complex type matching
const mockTransformedSurveys: TSurvey[] = [
{
id: "survey1",
@@ -99,14 +89,8 @@ const mockTransformedSurveys: TSurvey[] = [
];
describe("getSurveys", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("should fetch and transform surveys successfully", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys);
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys as any);
vi.mocked(transformPrismaSurvey).mockImplementation((survey) => {
const found = mockTransformedSurveys.find((ts) => ts.id === survey.id);
if (!found) throw new Error("Survey not found in mock transformed data");
@@ -134,39 +118,29 @@ describe("getSurveys", () => {
expect(transformPrismaSurvey).toHaveBeenCalledTimes(mockPrismaSurveys.length);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[0]);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[1]);
// Check if the inner cache function was called with the correct arguments
expect(cache).toHaveBeenCalledWith(
expect.any(Function), // The async function passed to cache
[`getSurveys-${environmentId}`], // The cache key
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)], // Cache tags
}
);
// Remove the assertion for reactCache being called within the test execution
// expect(reactCache).toHaveBeenCalled(); // Removed this line
// React cache is already mocked globally - no need to check it here
});
test("should throw DatabaseError on Prisma known request error", async () => {
// No need to mock cache here again as beforeEach handles it
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2025",
clientVersion: "5.0.0",
meta: {}, // Added meta property
const prismaError = new Prisma.PrismaClientKnownRequestError("Database connection error", {
code: "P2002",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(prismaError);
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
// React cache is already mocked globally - no need to check it here
});
test("should throw original error on other errors", async () => {
// No need to mock cache here again as beforeEach handles it
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
const genericError = new Error("Some other error");
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(genericError);
await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
// React cache is already mocked globally - no need to check it here
});
});

View File

@@ -1,6 +1,4 @@
import "server-only";
import { cache } from "@/lib/cache";
import { surveyCache } from "@/lib/survey/cache";
import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
@@ -12,38 +10,29 @@ import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
export const getSurveys = reactCache(
async (environmentId: string): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
export const getSurveys = reactCache(async (environmentId: string): Promise<TSurvey[]> => {
validateInputs([environmentId, ZId]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
status: {
not: "completed",
},
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
});
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error }, "getSurveys: Could not fetch surveys");
throw new DatabaseError(error.message);
}
throw error;
}
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
status: {
not: "completed",
},
},
[`getSurveys-${environmentId}`],
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
}
)()
);
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
});
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error }, "getSurveys: Could not fetch surveys");
throw new DatabaseError(error.message);
}
throw error;
}
});

View File

@@ -1,21 +1,10 @@
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getWebhookCountBySource } from "./webhook";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
tag: {
byEnvironmentIdAndSource: vi.fn((envId, source) => `webhook_${envId}_${source ?? "all"}`),
},
},
}));
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -29,12 +18,6 @@ const environmentId = "test-environment-id";
const sourceZapier = "zapier";
describe("getWebhookCountBySource", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});
@@ -56,13 +39,6 @@ describe("getWebhookCountBySource", () => {
source: sourceZapier,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getWebhookCountBySource-${environmentId}-${sourceZapier}`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, sourceZapier)],
}
);
});
test("should return total webhook count when source is undefined", async () => {
@@ -82,13 +58,6 @@ describe("getWebhookCountBySource", () => {
source: undefined,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getWebhookCountBySource-${environmentId}-undefined`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, undefined)],
}
);
});
test("should throw DatabaseError on Prisma known request error", async () => {
@@ -100,7 +69,6 @@ describe("getWebhookCountBySource", () => {
await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should throw original error on other errors", async () => {
@@ -109,6 +77,5 @@ describe("getWebhookCountBySource", () => {
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,3 @@
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma, Webhook } from "@prisma/client";
import { z } from "zod";
@@ -7,29 +5,25 @@ import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const getWebhookCountBySource = (environmentId: string, source?: Webhook["source"]): Promise<number> =>
cache(
async () => {
validateInputs([environmentId, ZId], [source, z.string().optional()]);
export const getWebhookCountBySource = async (
environmentId: string,
source?: Webhook["source"]
): Promise<number> => {
validateInputs([environmentId, ZId], [source, z.string().optional()]);
try {
const count = await prisma.webhook.count({
where: {
environmentId,
source,
},
});
return count;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getWebhookCountBySource-${environmentId}-${source}`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, source)],
try {
const count = await prisma.webhook.count({
where: {
environmentId,
source,
},
});
return count;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
)();
throw error;
}
};

View File

@@ -31,6 +31,9 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-redis-url",
AUDIT_LOG_ENABLED: true,
}));
// Mock child components

View File

@@ -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";

View File

@@ -24,6 +24,15 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://example.com",
},
}));
describe("AppConnectionPage Re-export", () => {

View File

@@ -24,6 +24,15 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("GeneralSettingsPage re-export", () => {

View File

@@ -24,6 +24,9 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("LanguagesPage re-export", () => {

View File

@@ -24,6 +24,15 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("ProjectLookSettingsPage re-export", () => {

View File

@@ -24,6 +24,9 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("TagsPage re-export", () => {

View File

@@ -24,6 +24,9 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
describe("ProjectTeams re-export", () => {

View File

@@ -40,6 +40,9 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);

View File

@@ -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;
}
)
);

View File

@@ -1,49 +1,164 @@
"use server";
import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
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 { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZUserUpdateInput } from "@formbricks/types/user";
import {
AuthenticationError,
AuthorizationError,
OperationNotAllowedError,
TooManyRequestsError,
} from "@formbricks/types/errors";
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
const limiter = rateLimit({
interval: 60 * 60, // 1 hour
allowedPerInterval: 3, // max 3 calls for email verification per hour
});
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, locale: true }))
.action(async ({ parsedInput, ctx }) => {
return await updateUser(ctx.user.id, parsedInput);
});
.schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
}
)
);
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),
});
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 });
});
)
);

View File

@@ -50,11 +50,10 @@ 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} />);
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />);
const nameInput = screen.getByPlaceholderText("common.full_name");
expect(nameInput).toHaveValue(mockUser.name);
expect(screen.getByDisplayValue(mockUser.email)).toBeDisabled();
// Check initial language (English)
expect(screen.getByText("English (US)")).toBeInTheDocument();
@@ -72,7 +71,11 @@ describe("EditProfileDetailsForm", () => {
await userEvent.click(updateButton);
await waitFor(() => {
expect(updateUserAction).toHaveBeenCalledWith({ name: "New Name", locale: "de-DE" });
expect(updateUserAction).toHaveBeenCalledWith({
name: "New Name",
locale: "de-DE",
email: mockUser.email,
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(
@@ -88,7 +91,7 @@ describe("EditProfileDetailsForm", () => {
const errorMessage = "Update failed";
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
render(<EditProfileDetailsForm user={mockUser} />);
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
const nameInput = screen.getByPlaceholderText("common.full_name");
await userEvent.clear(nameInput);
@@ -106,7 +109,7 @@ describe("EditProfileDetailsForm", () => {
});
test("update button is disabled initially and enables on change", async () => {
render(<EditProfileDetailsForm user={mockUser} />);
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled();

View File

@@ -1,136 +1,228 @@
"use client";
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,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
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 { SubmitHandler, useForm } from "react-hook-form";
import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { TUser, ZUser } from "@formbricks/types/user";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { updateUserAction } from "../actions";
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true });
// Schema & types
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
email: ZUserEmail.transform((val) => val?.trim().toLowerCase()),
});
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
export const EditProfileDetailsForm = ({ user }: { user: TUser }) => {
export const EditProfileDetailsForm = ({
user,
emailVerificationDisabled,
}: {
user: TUser;
emailVerificationDisabled: boolean;
}) => {
const { t } = useTranslate();
const form = useForm<TEditProfileNameForm>({
defaultValues: { name: user.name, locale: user.locale || "en" },
defaultValues: {
name: user.name,
locale: user.locale,
email: user.email,
},
mode: "onChange",
resolver: zodResolver(ZEditProfileNameFormSchema),
});
const { isSubmitting, isDirty } = form.formState;
const { t } = useTranslate();
const [showModal, setShowModal] = useState(false);
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const handleConfirmPassword = async (password: string) => {
const values = form.getValues();
const dirtyFields = form.formState.dirtyFields;
const emailChanged = "email" in dirtyFields;
const nameChanged = "name" in dirtyFields;
const localeChanged = "locale" in dirtyFields;
const name = values.name.trim();
const email = values.email.trim().toLowerCase();
const locale = values.locale;
const data: TUserUpdateInput = {};
if (emailChanged) {
data.email = email;
data.password = password;
}
if (nameChanged) {
data.name = name;
}
if (localeChanged) {
data.locale = locale;
}
const updatedUserResult = await updateUserAction(data);
if (updatedUserResult?.data) {
if (!emailVerificationDisabled) {
toast.success(t("auth.verification-requested.new_email_verification_success"));
} else {
toast.success(t("environments.settings.profile.email_change_initiated"));
await signOutWithAudit({
reason: "email_change",
redirectUrl: "/email-change-without-verification-success",
redirect: true,
callbackUrl: "/email-change-without-verification-success",
});
return;
}
} else {
const errorMessage = getFormattedErrorMessage(updatedUserResult);
toast.error(errorMessage);
return;
}
window.location.reload();
setShowModal(false);
};
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
try {
const name = data.name.trim();
const locale = data.locale;
await updateUserAction({ name, locale });
toast.success(t("environments.settings.profile.profile_updated_successfully"));
window.location.reload();
form.reset({ name, locale });
} catch (error) {
toast.error(`${t("common.error")}: ${error.message}`);
if (data.email !== user.email) {
setShowModal(true);
} else {
try {
await updateUserAction({
...data,
name: data.name.trim(),
});
toast.success(t("environments.settings.profile.profile_updated_successfully"));
window.location.reload();
form.reset(data);
} catch (error: any) {
toast.error(`${t("common.error")}: ${error.message}`);
}
}
};
return (
<FormProvider {...form}>
<form className="w-full max-w-sm items-center" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.full_name")}</FormLabel>
<FormControl>
<Input
{...field}
type="text"
placeholder={t("common.full_name")}
required
isInvalid={!!form.formState.errors.name}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<>
<FormProvider {...form}>
<form className="w-full max-w-sm" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.full_name")}</FormLabel>
<FormControl>
<Input
{...field}
type="text"
required
placeholder={t("common.full_name")}
isInvalid={!!form.formState.errors.name}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
{/* disabled email field */}
<div className="mt-4 space-y-2">
<Label htmlFor="email">{t("common.email")}</Label>
<Input type="email" id="email" defaultValue={user.email} disabled />
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("common.email")}</FormLabel>
<FormControl>
<Input
{...field}
type="email"
required
isInvalid={!!form.formState.errors.email}
disabled={user.identityProvider !== "email"}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="locale"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
className="h-10 w-full border border-slate-300 px-3 text-left"
variant="ghost">
<div className="flex w-full items-center justify-between">
{appLanguages.find((language) => language.code === field.value)?.label[field.value] ||
"NA"}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-40 bg-slate-50 text-slate-700"
align="start"
side="bottom">
{appLanguages.map((language) => (
<DropdownMenuItem
key={language.code}
onClick={() => field.onChange(language.code)}
className="min-h-8 cursor-pointer">
{language.label[field.value]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="locale"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
)}
/>
<Button
type="submit"
className="mt-4"
size="sm"
loading={isSubmitting}
disabled={isSubmitting || !isDirty}>
{t("common.update")}
</Button>
</form>
</FormProvider>
<Button
type="submit"
className="mt-4"
size="sm"
loading={isSubmitting}
disabled={isSubmitting || !isDirty}>
{t("common.update")}
</Button>
</form>
</FormProvider>
<PasswordConfirmationModal
open={showModal}
setOpen={setShowModal}
oldEmail={user.email}
newEmail={form.getValues("email") || user.email}
onConfirm={handleConfirmPassword}
/>
</>
);
};

View File

@@ -0,0 +1,132 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
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) =>
open ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
{children}
<button data-testid="modal-close" onClick={() => setOpen(false)}>
Close
</button>
</div>
) : null,
}));
// Mock the PasswordInput component
vi.mock("@/modules/ui/components/password-input", () => ({
PasswordInput: ({ onChange, value, placeholder }: any) => (
<input
type="password"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
data-testid="password-input"
/>
),
}));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
describe("PasswordConfirmationModal", () => {
const defaultProps = {
open: true,
setOpen: vi.fn(),
oldEmail: "old@example.com",
newEmail: "new@example.com",
onConfirm: vi.fn(),
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders nothing when open is false", () => {
render(<PasswordConfirmationModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("renders modal content when open is true", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
});
test("displays old and new email addresses", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByText("old@example.com")).toBeInTheDocument();
expect(screen.getByText("new@example.com")).toBeInTheDocument();
});
test("shows password input field", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
expect(passwordInput).toBeInTheDocument();
expect(passwordInput).toHaveAttribute("placeholder", "*******");
});
test("disables confirm button when form is not dirty", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
const confirmButton = screen.getByText("common.confirm");
expect(confirmButton).toBeDisabled();
});
test("disables confirm button when old and new emails are the same", () => {
render(
<PasswordConfirmationModal {...defaultProps} oldEmail="same@example.com" newEmail="same@example.com" />
);
const confirmButton = screen.getByText("common.confirm");
expect(confirmButton).toBeDisabled();
});
test("enables confirm button when password is entered and emails are different", async () => {
const user = userEvent.setup();
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
await user.type(passwordInput, "password123");
const confirmButton = screen.getByText("common.confirm");
expect(confirmButton).not.toBeDisabled();
});
test("shows error message when password is too short", async () => {
const user = userEvent.setup();
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
await user.type(passwordInput, "short");
const confirmButton = screen.getByText("common.confirm");
await user.click(confirmButton);
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
});
test("handles cancel button click and resets form", async () => {
const user = userEvent.setup();
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
await user.type(passwordInput, "password123");
const cancelButton = screen.getByText("common.cancel");
await user.click(cancelButton);
expect(defaultProps.setOpen).toHaveBeenCalledWith(false);
await waitFor(() => {
expect(passwordInput).toHaveValue("");
});
});
});

View File

@@ -0,0 +1,117 @@
"use client";
import { Button } from "@/modules/ui/components/button";
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";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import { ZUserPassword } from "@formbricks/types/user";
interface PasswordConfirmationModalProps {
open: boolean;
setOpen: (open: boolean) => void;
oldEmail: string;
newEmail: string;
onConfirm: (password: string) => Promise<void>;
}
const PasswordConfirmationSchema = z.object({
password: ZUserPassword,
});
type FormValues = z.infer<typeof PasswordConfirmationSchema>;
export const PasswordConfirmationModal = ({
open,
setOpen,
oldEmail,
newEmail,
onConfirm,
}: PasswordConfirmationModalProps) => {
const { t } = useTranslate();
const form = useForm<FormValues>({
resolver: zodResolver(PasswordConfirmationSchema),
});
const { isSubmitting, isDirty } = form.formState;
const onSubmit: SubmitHandler<FormValues> = async (data) => {
try {
await onConfirm(data.password);
form.reset();
} catch (error) {
form.setError("password", {
message: error instanceof Error ? error.message : "Authentication failed",
});
}
};
const handleCancel = () => {
form.reset();
setOpen(false);
};
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>
<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>
);
};

View File

@@ -0,0 +1,133 @@
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getIsEmailUnique, verifyUserPassword } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("verifyUserPassword", () => {
const userId = "test-user-id";
const password = "test-password";
test("should return true for correct password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should return false for incorrect password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(false);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw ResourceNotFoundError if user not found", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
});
describe("getIsEmailUnique", () => {
const email = "test@example.com";
test("should return false if user exists", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
id: "some-user-id",
} as any);
const result = await getIsEmailUnique(email);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
test("should return true if user does not exist", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
const result = await getIsEmailUnique(email);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
});
});

View File

@@ -0,0 +1,52 @@
import { verifyPassword } from "@/modules/auth/lib/utils";
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserById(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
const isCorrectPassword = await verifyPassword(password, user.password);
if (!isCorrectPassword) {
return false;
}
return true;
};
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: {
id: true,
},
});
return !user;
});

View File

@@ -13,6 +13,7 @@ import Page from "./page";
// Mock services and utils
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
EMAIL_VERIFICATION_DISABLED: true,
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(),

View File

@@ -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 { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } 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";
@@ -42,7 +42,7 @@ 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 user={user} />
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
</SettingsCard>
<SettingsCard
title={t("common.avatar")}

View File

@@ -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);
}
)
);

View File

@@ -29,6 +29,15 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: 587,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("TeamsPage re-export", () => {

View File

@@ -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";
@@ -75,7 +75,6 @@ export const getSurveySummaryAction = authenticatedActionClient
},
],
});
return getSurveySummary(parsedInput.surveyId, parsedInput.filterCriteria);
});

View File

@@ -5,7 +5,6 @@ import {
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
@@ -45,13 +44,21 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: 587,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
vi.mock("@/app/lib/surveys/surveys");
vi.mock("@/app/share/[sharingKey]/actions");
vi.mock("@/lib/utils/hooks/useIntervalWhenFocused");
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
}));
@@ -68,7 +75,6 @@ const mockUseResponseFilter = vi.mocked(useResponseFilter);
const mockGetResponseCountAction = vi.mocked(getResponseCountAction);
const mockRevalidateSurveyIdPath = vi.mocked(revalidateSurveyIdPath);
const mockGetFormattedFilters = vi.mocked(getFormattedFilters);
const mockUseIntervalWhenFocused = vi.mocked(useIntervalWhenFocused);
const MockSecondaryNavigation = vi.mocked(SecondaryNavigation);
const mockSurveyLanguages: TSurveyLanguage[] = [
@@ -119,7 +125,6 @@ const mockSurvey = {
const defaultProps = {
environmentId: "testEnvId",
survey: mockSurvey,
initialTotalResponseCount: 10,
activeId: "summary",
};
@@ -166,23 +171,20 @@ describe("SurveyAnalysisNavigation", () => {
);
});
test("passes correct runWhen flag to useIntervalWhenFocused based on share embed modal", () => {
test("renders navigation correctly for sharing page", () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
);
mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id });
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
mockGetFormattedFilters.mockReturnValue([] as any);
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue("true") } as any);
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, false, false);
cleanup();
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, true, false);
expect(MockSecondaryNavigation).toHaveBeenCalled();
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
});
test("displays correct response count string in label for various scenarios", async () => {
@@ -195,8 +197,8 @@ describe("SurveyAnalysisNavigation", () => {
mockGetFormattedFilters.mockReturnValue([] as any);
// Scenario 1: total = 10, filtered = null (initial state)
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses (10)");
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses");
cleanup();
vi.resetAllMocks(); // Reset mocks for next case
@@ -212,11 +214,11 @@ describe("SurveyAnalysisNavigation", () => {
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
return { data: 15, error: null, success: true };
});
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={15} />);
render(<SurveyAnalysisNavigation {...defaultProps} />);
await waitFor(() => {
const lastCallArgs =
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
});
cleanup();
vi.resetAllMocks();
@@ -233,11 +235,11 @@ describe("SurveyAnalysisNavigation", () => {
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
return { data: 10, error: null, success: true };
});
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
render(<SurveyAnalysisNavigation {...defaultProps} />);
await waitFor(() => {
const lastCallArgs =
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
});
});
});

View File

@@ -1,105 +1,30 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
getResponseCountAction,
revalidateSurveyIdPath,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getResponseCountBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { useTranslate } from "@tolgee/react";
import { InboxIcon, PresentationIcon } from "lucide-react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, usePathname } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyAnalysisNavigationProps {
environmentId: string;
survey: TSurvey;
initialTotalResponseCount: number | null;
activeId: string;
}
export const SurveyAnalysisNavigation = ({
environmentId,
survey,
initialTotalResponseCount,
activeId,
}: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslate();
const params = useParams();
const [filteredResponseCount, setFilteredResponseCount] = useState<number | null>(null);
const [totalResponseCount, setTotalResponseCount] = useState<number | null>(initialTotalResponseCount);
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const searchParams = useSearchParams();
const isShareEmbedModalOpen = searchParams.get("share") === "true";
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
const { selectedFilter, dateRange } = useResponseFilter();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
[selectedFilter, dateRange, survey]
);
const latestFiltersRef = useRef(filters);
latestFiltersRef.current = filters;
const getResponseCount = () => {
if (isSharingPage) return getResponseCountBySurveySharingKeyAction({ sharingKey });
return getResponseCountAction({ surveyId: survey.id });
};
const fetchResponseCount = async () => {
const count = await getResponseCount();
const responseCount = count?.data ?? 0;
setTotalResponseCount(responseCount);
};
const getFilteredResponseCount = useCallback(() => {
if (isSharingPage)
return getResponseCountBySurveySharingKeyAction({
sharingKey,
filterCriteria: latestFiltersRef.current,
});
return getResponseCountAction({ surveyId: survey.id, filterCriteria: latestFiltersRef.current });
}, [isSharingPage, sharingKey, survey.id]);
const fetchFilteredResponseCount = useCallback(async () => {
const count = await getFilteredResponseCount();
const responseCount = count?.data ?? 0;
setFilteredResponseCount(responseCount);
}, [getFilteredResponseCount]);
useEffect(() => {
fetchFilteredResponseCount();
}, [filters, isSharingPage, sharingKey, survey.id, fetchFilteredResponseCount]);
useIntervalWhenFocused(
() => {
fetchResponseCount();
fetchFilteredResponseCount();
},
10000,
!isShareEmbedModalOpen,
false
);
const getResponseCountString = () => {
if (totalResponseCount === null) return "";
if (filteredResponseCount === null) return `(${totalResponseCount})`;
const totalCount = Math.max(totalResponseCount, filteredResponseCount);
if (totalCount === filteredResponseCount) return `(${totalCount})`;
return `(${filteredResponseCount} of ${totalCount})`;
};
const navigation = [
{
@@ -114,7 +39,7 @@ export const SurveyAnalysisNavigation = ({
},
{
id: "responses",
label: `${t("common.responses")} ${getResponseCountString()}`,
label: t("common.responses"),
icon: <InboxIcon className="h-5 w-5" />,
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),

View File

@@ -162,7 +162,6 @@ describe("ResponsePage", () => {
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
});
expect(mockGetResponseCountAction).toHaveBeenCalled();
expect(mockGetResponsesAction).toHaveBeenCalled();
});
@@ -179,7 +178,6 @@ describe("ResponsePage", () => {
await waitFor(() => {
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
expect(mockGetResponseCountBySurveySharingKeyAction).toHaveBeenCalled();
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
});
@@ -297,8 +295,7 @@ describe("ResponsePage", () => {
rerender(<ResponsePage {...defaultProps} />);
await waitFor(() => {
// Should fetch count and responses again due to filter change
expect(mockGetResponseCountAction).toHaveBeenCalledTimes(2);
// Should fetch responses again due to filter change
expect(mockGetResponsesAction).toHaveBeenCalledTimes(2);
// Check if it fetches with offset 0 (first page)
expect(mockGetResponsesAction).toHaveBeenLastCalledWith(

View File

@@ -1,18 +1,12 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
getResponseCountAction,
getResponsesAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import {
getResponseCountBySurveySharingKeyAction,
getResponsesBySurveySharingKeyAction,
} from "@/app/share/[sharingKey]/actions";
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -26,7 +20,7 @@ interface ResponsePageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
webAppUrl: string;
publicDomain: string;
user?: TUser;
environmentTags: TTag[];
responsesPerPage: number;
@@ -38,7 +32,7 @@ export const ResponsePage = ({
environment,
survey,
surveyId,
webAppUrl,
publicDomain,
user,
environmentTags,
responsesPerPage,
@@ -49,7 +43,6 @@ export const ResponsePage = ({
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const [responseCount, setResponseCount] = useState<number | null>(null);
const [responses, setResponses] = useState<TResponse[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
@@ -97,9 +90,6 @@ export const ResponsePage = ({
const deleteResponses = (responseIds: string[]) => {
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
if (responseCount) {
setResponseCount(responseCount - responseIds.length);
}
};
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
@@ -118,29 +108,6 @@ export const ResponsePage = ({
}
}, [searchParams, resetState]);
useEffect(() => {
const handleResponsesCount = async () => {
let responseCount = 0;
if (isSharingPage) {
const responseCountActionResponse = await getResponseCountBySurveySharingKeyAction({
sharingKey,
filterCriteria: filters,
});
responseCount = responseCountActionResponse?.data || 0;
} else {
const responseCountActionResponse = await getResponseCountAction({
surveyId,
filterCriteria: filters,
});
responseCount = responseCountActionResponse?.data || 0;
}
setResponseCount(responseCount);
};
handleResponsesCount();
}, [filters, isSharingPage, sharingKey, surveyId]);
useEffect(() => {
const fetchInitialResponses = async () => {
try {
@@ -188,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}

View File

@@ -1,487 +1,494 @@
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import type { DragEndEvent } from "@dnd-kit/core";
import { act, cleanup, render, screen } from "@testing-library/react";
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { ResponseTable } from "./ResponseTable";
import { TUserLocale } from "@formbricks/types/user";
// Hoist variables used in mock factories
const { DndContextMock, SortableContextMock, arrayMoveMock } = vi.hoisted(() => {
const dndMock = vi.fn(({ children, onDragEnd }) => {
// Store the onDragEnd prop to allow triggering it in tests
(dndMock as any).lastOnDragEnd = onDragEnd;
return <div data-testid="dnd-context">{children}</div>;
});
const sortableMock = vi.fn(({ children }) => <>{children}</>);
const moveMock = vi.fn((array, from, to) => {
const newArray = [...array];
const [item] = newArray.splice(from, 1);
newArray.splice(to, 0, item);
return newArray;
});
return {
DndContextMock: dndMock,
SortableContextMock: sortableMock,
arrayMoveMock: moveMock,
};
});
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
success: vi.fn(),
dismiss: vi.fn(),
},
}));
vi.mock("@dnd-kit/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dnd-kit/core")>();
return {
...actual,
DndContext: DndContextMock,
useSensor: vi.fn(),
useSensors: vi.fn(),
closestCenter: vi.fn(),
};
});
// Mock components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} data-testid="button" {...props}>
{children}
</button>
),
}));
// Mock DndContext/SortableContext
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: any) => <div>{children}</div>,
useSensor: vi.fn(),
useSensors: vi.fn(() => "sensors"),
closestCenter: vi.fn(),
MouseSensor: vi.fn(),
TouchSensor: vi.fn(),
KeyboardSensor: vi.fn(),
}));
vi.mock("@dnd-kit/modifiers", () => ({
restrictToHorizontalAxis: vi.fn(),
restrictToHorizontalAxis: "restrictToHorizontalAxis",
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: SortableContextMock,
arrayMove: arrayMoveMock,
horizontalListSortingStrategy: vi.fn(),
SortableContext: ({ children }: any) => <>{children}</>,
horizontalListSortingStrategy: "horizontalListSortingStrategy",
arrayMove: vi.fn((arr, oldIndex, newIndex) => {
const result = [...arr];
const [removed] = result.splice(oldIndex, 1);
result.splice(newIndex, 0, removed);
return result;
}),
}));
// Mock AutoAnimate
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [vi.fn()],
}));
// Mock UI components
vi.mock("@/modules/ui/components/data-table", () => ({
DataTableHeader: ({ header }: any) => <th data-testid={`header-${header.id}`}>{header.id}</th>,
DataTableSettingsModal: ({ open, setOpen }: any) =>
open ? (
<div data-testid="settings-modal">
Settings Modal <button onClick={() => setOpen(false)}>Close</button>
</div>
) : null,
DataTableToolbar: ({
table,
deleteRowsAction,
downloadRowsAction,
setIsTableSettingsModalOpen,
setIsExpanded,
isExpanded,
}: any) => (
<div data-testid="table-toolbar">
<button
data-testid="toggle-expand"
onClick={() => setIsExpanded(!isExpanded)}
aria-pressed={isExpanded}>
Toggle Expand
</button>
<button data-testid="open-settings" onClick={() => setIsTableSettingsModalOpen(true)}>
Open Settings
</button>
<button
data-testid="delete-rows"
onClick={() => deleteRowsAction(Object.keys(table.getState().rowSelection))}>
Delete Selected
</button>
<button
data-testid="download-csv"
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "csv")}>
Download CSV
</button>
<button
data-testid="download-xlsx"
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "xlsx")}>
Download XLSX
</button>
</div>
),
}));
// Mock child components and hooks
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal",
() => ({
ResponseCardModal: vi.fn(({ open, setOpen, selectedResponseId }) =>
ResponseCardModal: ({ open, setOpen }: any) =>
open ? (
<div data-testid="response-card-modal">
Selected Response ID: {selectedResponseId}
<button onClick={() => setOpen(false)}>Close ResponseCardModal</button>
<div data-testid="response-modal">
Response Modal <button onClick={() => setOpen(false)}>Close</button>
</div>
) : null
),
) : null,
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell",
() => ({
ResponseTableCell: vi.fn(({ cell, row, setSelectedResponseId }) => (
<td data-testid={`cell-${cell.id}`} onClick={() => setSelectedResponseId(row.original.responseId)}>
{typeof cell.getValue === "function" ? cell.getValue() : JSON.stringify(cell.getValue())}
ResponseTableCell: ({ cell, row, setSelectedResponseId }: any) => (
<td data-testid={`cell-${cell.id}-${row.id}`} onClick={() => setSelectedResponseId(row.id)}>
Cell Content
</td>
)),
),
})
);
const mockGeneratedColumns = [
{
id: "select",
header: () => "Select",
cell: vi.fn(() => "SelectCell"),
enableSorting: false,
meta: { type: "select", questionType: null, hidden: false },
},
{
id: "createdAt",
header: () => "Created At",
cell: vi.fn(({ row }) => new Date(row.original.createdAt).toISOString()),
enableSorting: true,
meta: { type: "createdAt", questionType: null, hidden: false },
},
{
id: "q1",
header: () => "Question 1",
cell: vi.fn(({ row }) => row.original.responseData.q1),
enableSorting: true,
meta: { type: "question", questionType: "openText", hidden: false },
},
];
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns",
() => ({
generateResponseTableColumns: vi.fn(() => mockGeneratedColumns),
generateResponseTableColumns: vi.fn(() => [
{ id: "select", accessorKey: "select", header: "Select" },
{ id: "createdAt", accessorKey: "createdAt", header: "Created At" },
{ id: "person", accessorKey: "person", header: "Person" },
{ id: "status", accessorKey: "status", header: "Status" },
]),
})
);
vi.mock("@/modules/ui/components/table", () => ({
Table: ({ children, ...props }: any) => <table {...props}>{children}</table>,
TableBody: ({ children, ...props }: any) => <tbody {...props}>{children}</tbody>,
TableCell: ({ children, ...props }: any) => <td {...props}>{children}</td>,
TableHeader: ({ children, ...props }: any) => <thead {...props}>{children}</thead>,
TableRow: ({ children, ...props }: any) => <tr {...props}>{children}</tr>,
}));
vi.mock("@/modules/ui/components/skeleton", () => ({
Skeleton: ({ children }: any) => <div data-testid="skeleton">{children}</div>,
}));
// Mock the actions
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
getResponsesDownloadUrlAction: vi.fn(),
}));
vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({
deleteResponseAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/data-table", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/ui/components/data-table")>();
return {
...actual,
DataTableToolbar: vi.fn((props) => (
<div data-testid="data-table-toolbar">
<button data-testid="toolbar-expand-toggle" onClick={() => props.setIsExpanded(!props.isExpanded)}>
Toggle Expand
</button>
<button data-testid="toolbar-open-settings" onClick={() => props.setIsTableSettingsModalOpen(true)}>
Open Settings
</button>
<button
data-testid="toolbar-delete-selected"
onClick={() => props.deleteRows(props.table.getSelectedRowModel().rows.map((r) => r.id))}>
Delete Selected
</button>
<button data-testid="toolbar-delete-single" onClick={() => props.deleteAction("single_response_id")}>
Delete Single Action
</button>
</div>
)),
DataTableHeader: vi.fn(({ header }) => (
<th
data-testid={`header-${header.id}`}
onClick={() => header.column.getToggleSortingHandler()?.(new MouseEvent("click"))}>
{typeof header.column.columnDef.header === "function"
? header.column.columnDef.header(header.getContext())
: header.column.columnDef.header}
<button
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
data-testid={`resize-${header.id}`}>
Resize
</button>
</th>
)),
DataTableSettingsModal: vi.fn(({ open, setOpen }) =>
open ? (
<div data-testid="data-table-settings-modal">
<button onClick={() => setOpen(false)}>Close Settings</button>
</div>
) : null
),
};
});
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: vi.fn(() => [vi.fn()]),
// Mock helper functions
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: vi.fn((key) => key), // Simple pass-through mock
}),
}));
const localStorageMock = (() => {
// Mock localStorage
const mockLocalStorage = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value.toString();
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = String(value);
}),
clear: () => {
clear: vi.fn(() => {
store = {};
},
removeItem: vi.fn((key: string) => {
}),
removeItem: vi.fn((key) => {
delete store[key];
}),
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
Object.defineProperty(window, "localStorage", { value: mockLocalStorage });
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
} as unknown as TSurveyQuestion,
],
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
variables: [{ id: "var1", name: "Variable 1", type: "text", value: "default" }],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
welcomeCard: {
enabled: false,
headline: { default: "" },
html: { default: "" },
timeToFinish: false,
showResponseCount: false,
},
autoClose: null,
delay: 0,
autoComplete: null,
closeOnDate: null,
displayOption: "displayOnce",
recontactDays: null,
singleUse: { enabled: false, isEncrypted: true },
triggers: [],
languages: [],
styling: null,
surveyClosedMessage: null,
resultShareKey: null,
displayPercentage: null,
} as unknown as TSurvey;
// Mock Tolgee
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
const mockResponses: TResponse[] = [
{
id: "res1",
surveyId: "survey1",
finished: true,
data: { q1: "Response 1 Text" },
createdAt: new Date("2023-01-01T10:00:00.000Z"),
// Define mock data for tests
const mockProps = {
data: [
{ responseId: "resp1", createdAt: new Date().toISOString(), status: "completed", person: "Person 1" },
{ responseId: "resp2", createdAt: new Date().toISOString(), status: "completed", person: "Person 2" },
] as any[],
survey: {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
meta: {},
singleUseId: null,
ttc: {},
tags: [],
notes: [],
variables: {},
language: "en",
contact: null,
contactAttributes: null,
},
{
id: "res2",
surveyId: "survey1",
finished: false,
data: { q1: "Response 2 Text" },
createdAt: new Date("2023-01-02T10:00:00.000Z"),
updatedAt: new Date(),
meta: {},
singleUseId: null,
ttc: {},
tags: [],
notes: [],
variables: {},
language: "en",
contact: null,
contactAttributes: null,
},
];
const mockResponseTableData: TResponseTableData[] = [
{
responseId: "res1",
responseData: { q1: "Response 1 Text" },
createdAt: new Date("2023-01-01T10:00:00.000Z"),
status: "Completed",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "en",
person: null,
contactAttributes: null,
},
{
responseId: "res2",
responseData: { q1: "Response 2 Text" },
createdAt: new Date("2023-01-02T10:00:00.000Z"),
status: "Not Completed",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "en",
person: null,
contactAttributes: null,
},
];
const mockEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockUser = {
id: "user1",
name: "Test User",
email: "user@test.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
objective: "other",
notificationSettings: { alert: {}, weeklySummary: {} },
} as unknown as TUser;
const mockEnvironmentTags: TTag[] = [
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
];
const mockLocale: TUserLocale = "en-US";
const defaultProps = {
data: mockResponseTableData,
survey: mockSurvey,
responses: mockResponses,
environment: mockEnvironment,
user: mockUser,
environmentTags: mockEnvironmentTags,
name: "name",
type: "link",
environmentId: "env-1",
createdBy: null,
status: "draft",
} as TSurvey,
responses: [
{ id: "resp1", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
{ id: "resp2", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
] as TResponse[],
environment: { id: "env1" } as TEnvironment,
environmentTags: [] as TTag[],
isReadOnly: false,
fetchNextPage: vi.fn(),
hasMore: true,
hasMore: false,
deleteResponses: vi.fn(),
updateResponse: vi.fn(),
isFetchingFirstPage: false,
locale: mockLocale,
locale: "en" as TUserLocale,
};
// Setup a container for React Testing Library before each test
beforeEach(() => {
const container = document.createElement("div");
container.id = "test-container";
document.body.appendChild(container);
// Reset all toast mocks before each test
vi.mocked(toast.error).mockClear();
vi.mocked(toast.success).mockClear();
// Create a mock anchor element for download tests
const mockAnchor = {
href: "",
click: vi.fn(),
style: {},
};
// Update how we mock the document methods to avoid infinite recursion
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (tagName === "a") return mockAnchor as any;
return originalCreateElement(tagName);
});
vi.spyOn(document.body, "appendChild").mockReturnValue(null as any);
vi.spyOn(document.body, "removeChild").mockReturnValue(null as any);
});
// Cleanup after each test
afterEach(() => {
const container = document.getElementById("test-container");
if (container) {
document.body.removeChild(container);
}
cleanup();
vi.restoreAllMocks(); // Restore mocks after each test
});
describe("ResponseTable", () => {
afterEach(() => {
cleanup();
localStorageMock.clear();
vi.clearAllMocks();
cleanup(); // Keep cleanup within describe as per instructions
});
test("renders skeleton when isFetchingFirstPage is true", () => {
render(<ResponseTable {...defaultProps} isFetchingFirstPage={true} />);
// Check for skeleton elements (implementation detail, might need adjustment)
// For now, check that data is not directly rendered
expect(screen.queryByText("Response 1 Text")).not.toBeInTheDocument();
// Check if table headers are still there
expect(screen.getByText("Created At")).toBeInTheDocument();
test("renders the table with data", () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.getByTestId("table-toolbar")).toBeInTheDocument();
});
test("loads settings from localStorage on mount", () => {
const savedOrder = ["q1", "createdAt", "select"];
const savedVisibility = { createdAt: false };
const savedExpanded = true;
localStorageMock.setItem(`${mockSurvey.id}-columnOrder`, JSON.stringify(savedOrder));
localStorageMock.setItem(`${mockSurvey.id}-columnVisibility`, JSON.stringify(savedVisibility));
localStorageMock.setItem(`${mockSurvey.id}-rowExpand`, JSON.stringify(savedExpanded));
render(<ResponseTable {...defaultProps} />);
// Check if generateResponseTableColumns was called with the loaded expanded state
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
mockSurvey,
savedExpanded,
false,
expect.any(Function)
);
});
test("saves settings to localStorage when they change", async () => {
const { rerender } = render(<ResponseTable {...defaultProps} />);
// Simulate column order change via DND
const dragEvent: DragEndEvent = {
active: { id: "createdAt" },
over: { id: "q1" },
delta: { x: 0, y: 0 },
activators: { x: 0, y: 0 },
collisions: null,
overNode: null,
activeNode: null,
} as any;
act(() => {
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
});
rerender(<ResponseTable {...defaultProps} />); // Rerender to reflect state change if necessary for useEffect
expect(localStorageMock.setItem).toHaveBeenCalledWith(
`${mockSurvey.id}-columnOrder`,
JSON.stringify(["select", "q1", "createdAt"])
);
// Simulate visibility change (e.g. via settings modal - direct state change for test)
// This would typically happen via table.setColumnVisibility, which is internal to useReactTable
// For this test, we'll assume a mechanism changes columnVisibility state
// This part is hard to test without deeper mocking of useReactTable or exposing setColumnVisibility
// Simulate row expansion change
await userEvent.click(screen.getByTestId("toolbar-expand-toggle")); // Toggle to true
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
});
test("handles column drag and drop", () => {
render(<ResponseTable {...defaultProps} />);
const dragEvent: DragEndEvent = {
active: { id: "createdAt" },
over: { id: "q1" },
delta: { x: 0, y: 0 },
activators: { x: 0, y: 0 },
collisions: null,
overNode: null,
activeNode: null,
} as any;
act(() => {
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
});
expect(arrayMoveMock).toHaveBeenCalledWith(expect.arrayContaining(["createdAt", "q1"]), 1, 2); // Example indices
expect(localStorageMock.setItem).toHaveBeenCalledWith(
`${mockSurvey.id}-columnOrder`,
JSON.stringify(["select", "q1", "createdAt"]) // Based on initial ['select', 'createdAt', 'q1']
);
});
test("interacts with DataTableToolbar: toggle expand, open settings, delete", async () => {
const deleteResponsesMock = vi.fn();
const deleteResponseActionMock = vi.mocked(deleteResponseAction);
render(<ResponseTable {...defaultProps} deleteResponses={deleteResponsesMock} />);
// Toggle expand
await userEvent.click(screen.getByTestId("toolbar-expand-toggle"));
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
mockSurvey,
true,
false,
expect.any(Function)
);
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
// Open settings
await userEvent.click(screen.getByTestId("toolbar-open-settings"));
expect(screen.getByTestId("data-table-settings-modal")).toBeInTheDocument();
await userEvent.click(screen.getByText("Close Settings"));
expect(screen.queryByTestId("data-table-settings-modal")).not.toBeInTheDocument();
// Delete selected (mock table selection)
// This requires mocking table.getSelectedRowModel().rows
// For simplicity, we assume the toolbar button calls deleteRows correctly
// The mock for DataTableToolbar calls props.deleteRows with hardcoded IDs for now.
// To test properly, we'd need to mock table.getSelectedRowModel
// For now, let's assume the mock toolbar calls it.
// await userEvent.click(screen.getByTestId("toolbar-delete-selected"));
// expect(deleteResponsesMock).toHaveBeenCalledWith(["row1_id", "row2_id"]); // From mock toolbar
// Delete single action
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
expect(deleteResponseActionMock).toHaveBeenCalledWith({ responseId: "single_response_id" });
});
test("calls fetchNextPage when 'Load More' is clicked", async () => {
const fetchNextPageMock = vi.fn();
render(<ResponseTable {...defaultProps} fetchNextPage={fetchNextPageMock} />);
await userEvent.click(screen.getByText("common.load_more"));
expect(fetchNextPageMock).toHaveBeenCalled();
});
test("does not show 'Load More' if hasMore is false", () => {
render(<ResponseTable {...defaultProps} hasMore={false} />);
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
});
test("shows 'No results' when data is empty", () => {
render(<ResponseTable {...defaultProps} data={[]} responses={[]} />);
test("renders no results message when data is empty", () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} data={[]} responses={[]} />, { container: container! });
expect(screen.getByText("common.no_results")).toBeInTheDocument();
});
test("deleteResponse function calls deleteResponseAction", async () => {
render(<ResponseTable {...defaultProps} />);
// This function is called by DataTableToolbar's deleteAction prop
// We can trigger it via the mocked DataTableToolbar
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
expect(vi.mocked(deleteResponseAction)).toHaveBeenCalledWith({ responseId: "single_response_id" });
test("renders load more button when hasMore is true", () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
expect(screen.getByText("common.load_more")).toBeInTheDocument();
});
test("calls fetchNextPage when load more button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
const loadMoreButton = screen.getByText("common.load_more");
await userEvent.click(loadMoreButton);
expect(mockProps.fetchNextPage).toHaveBeenCalledTimes(1);
});
test("opens settings modal when toolbar button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const openSettingsButton = screen.getByTestId("open-settings");
await userEvent.click(openSettingsButton);
expect(screen.getByTestId("settings-modal")).toBeInTheDocument();
});
test("toggles expanded state when toolbar button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const toggleExpandButton = screen.getByTestId("toggle-expand");
// Initially might be null, first click should set it to true
await userEvent.click(toggleExpandButton);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith("survey1-rowExpand", expect.any(String));
});
test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.csv",
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "csv",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.csv");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.xlsx",
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadXlsxButton = screen.getByTestId("download-xlsx");
await userEvent.click(downloadXlsxButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "xlsx",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.xlsx");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
// Test response modal
test("opens and closes response modal when a cell is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const cell = screen.getByTestId("cell-resp1_select-resp1");
await userEvent.click(cell);
expect(screen.getByTestId("response-modal")).toBeInTheDocument();
// Close the modal
const closeButton = screen.getByText("Close");
await userEvent.click(closeButton);
// Modal should be closed now
expect(screen.queryByTestId("response-modal")).not.toBeInTheDocument();
});
test("shows error toast when download action returns error", async () => {
const errorMsg = "Download failed";
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
serverError: errorMsg,
});
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce(errorMsg);
// Reset document.createElement spy to fix the last test
vi.mocked(document.createElement).mockClear();
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("shows default error toast when download action returns no data", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
});
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce("");
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("shows error toast when download action throws exception", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockRejectedValueOnce(new Error("Network error"));
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("does not create download link when download action fails", async () => {
// Clear any previous calls to document.createElement
vi.mocked(document.createElement).mockClear();
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
serverError: "Download failed",
});
// Create a fresh spy for createElement for this test only
const createElementSpy = vi.spyOn(document, "createElement");
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
// Check specifically for "a" element creation, not any element
expect(createElementSpy).not.toHaveBeenCalledWith("a");
});
});
test("loads saved settings from localStorage on mount", () => {
const columnOrder = ["status", "person", "createdAt", "select"];
const columnVisibility = { status: false };
const isExpanded = true;
mockLocalStorage.getItem.mockImplementation((key) => {
if (key === "survey1-columnOrder") return JSON.stringify(columnOrder);
if (key === "survey1-columnVisibility") return JSON.stringify(columnVisibility);
if (key === "survey1-rowExpand") return JSON.stringify(isExpanded);
return null;
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
// Verify localStorage calls
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnOrder");
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnVisibility");
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-rowExpand");
// The mock for generateResponseTableColumns returns this order:
// ["select", "createdAt", "person", "status"]
// Only visible columns should be rendered, in this order
const expectedHeaders = ["select", "createdAt", "person"];
const headers = screen.getAllByTestId(/^header-/);
expect(headers).toHaveLength(expectedHeaders.length);
expectedHeaders.forEach((columnId, index) => {
expect(headers[index]).toHaveAttribute("data-testid", `header-${columnId}`);
});
// Verify column visibility is applied
const statusHeader = screen.queryByTestId("header-status");
expect(statusHeader).not.toBeInTheDocument();
// Verify row expansion is applied
const toggleExpandButton = screen.getByTestId("toggle-expand");
expect(toggleExpandButton).toHaveAttribute("aria-pressed", "true");
});
});

View File

@@ -3,6 +3,7 @@
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import { Button } from "@/modules/ui/components/button";
import {
@@ -25,15 +26,16 @@ import {
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Sentry from "@sentry/nextjs";
import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser } from "@formbricks/types/user";
import { TUserLocale } from "@formbricks/types/user";
import { TUser, TUserLocale } from "@formbricks/types/user";
interface ResponseTableProps {
data: TResponseTableData[];
@@ -180,6 +182,32 @@ export const ResponseTable = ({
await deleteResponseAction({ responseId });
};
// Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
try {
const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: format,
filterCriteria: { responseIds },
});
if (downloadResponse?.data) {
const link = document.createElement("a");
link.href = downloadResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
toast.error(t("environments.surveys.responses.error_downloading_responses"));
}
} catch (error) {
Sentry.captureException(error);
toast.error(t("environments.surveys.responses.error_downloading_responses"));
}
};
return (
<div>
<DndContext
@@ -193,9 +221,10 @@ export const ResponseTable = ({
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
isExpanded={isExpanded ?? false}
table={table}
deleteRows={deleteResponses}
deleteRowsAction={deleteResponses}
type="response"
deleteAction={deleteResponse}
downloadRowsAction={downloadSelectedRows}
/>
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
<div className="w-full overflow-x-auto">

View File

@@ -1,8 +1,9 @@
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
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 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";
@@ -61,10 +62,11 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
SESSION_MAX_AGE: 1000,
}));
vi.mock("@/lib/getSurveyUrl", () => ({
getSurveyDomain: vi.fn(),
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
@@ -109,6 +111,14 @@ vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
useParams: () => ({
environmentId: "test-env-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
const mockEnvironmentId = "test-env-id";
const mockSurveyId = "test-survey-id";
const mockUserId = "test-user-id";
@@ -150,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,
@@ -169,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(() => {
@@ -180,7 +190,7 @@ describe("ResponsesPage", () => {
test("renders correctly with all data", async () => {
const props = { params: mockParams };
const jsx = await Page(props);
render(jsx);
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
await screen.findByTestId("page-content-wrapper");
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -195,8 +205,7 @@ describe("ResponsesPage", () => {
survey: mockSurvey,
isReadOnly: false,
user: mockUser,
surveyDomain: mockSurveyDomain,
responseCount: 10,
publicDomain: mockPublicDomain,
}),
undefined
);
@@ -206,7 +215,6 @@ describe("ResponsesPage", () => {
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "responses",
initialTotalResponseCount: 10,
}),
undefined
);
@@ -216,7 +224,7 @@ describe("ResponsesPage", () => {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
webAppUrl: "http://localhost:3000",
publicDomain: mockPublicDomain,
environmentTags: mockTags,
user: mockUser,
responsesPerPage: 10,

View File

@@ -1,8 +1,8 @@
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 { 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";
@@ -33,10 +33,11 @@ const Page = async (props) => {
const tags = await getTagsByEnvironmentId(params.environmentId);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
// 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>
@@ -48,22 +49,17 @@ const Page = async (props) => {
survey={survey}
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
responseCount={totalResponseCount}
publicDomain={publicDomain}
responseCount={responseCount}
/>
}>
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}
activeId="responses"
initialTotalResponseCount={totalResponseCount}
/>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
</PageHeader>
<ResponsePage
environment={environment}
survey={survey}
surveyId={params.surveyId}
webAppUrl={WEBAPP_URL}
publicDomain={publicDomain}
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}

View File

@@ -3,8 +3,10 @@
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
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 { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
@@ -63,37 +65,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 +152,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,

View File

@@ -41,6 +41,36 @@ const mockSurveyWeb = {
styling: null,
} as unknown as TSurvey;
vi.mock("@/lib/constants", () => ({
INTERCOM_SECRET_KEY: "test-secret-key",
IS_INTERCOM_CONFIGURED: true,
INTERCOM_APP_ID: "test-app-id",
ENCRYPTION_KEY: "test-encryption-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
GITHUB_ID: "test-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
IS_FORMBRICKS_CLOUD: false,
}));
const mockSurveyLink = {
...mockSurveyWeb,
id: "survey2",
@@ -119,7 +149,7 @@ describe("ShareEmbedSurvey", () => {
const defaultProps = {
survey: mockSurveyWeb,
surveyDomain: "test.com",
publicDomain: "https://public-domain.com",
open: true,
modalView: "start" as "start" | "embed" | "panel",
setOpen: mockSetOpen,
@@ -128,7 +158,7 @@ describe("ShareEmbedSurvey", () => {
beforeEach(() => {
mockEmbedViewComponent.mockImplementation(
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, surveyDomain, locale }) => (
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
<div>
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
@@ -136,7 +166,7 @@ describe("ShareEmbedSurvey", () => {
<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-publicDomain">{publicDomain}</div>
<div data-testid="embedview-locale">{locale}</div>
</div>
)
@@ -174,20 +204,32 @@ describe("ShareEmbedSurvey", () => {
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("calls setOpen(false) when handleInitialPageButton is triggered from EmbedView", async () => {
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("calls setOpen(false) when handleInitialPageButton is triggered from PanelInfoView", async () => {
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {

View File

@@ -1,6 +1,7 @@
"use client";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Badge } from "@/modules/ui/components/badge";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
@@ -23,7 +24,7 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps {
survey: TSurvey;
surveyDomain: string;
publicDomain: string;
open: boolean;
modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -32,7 +33,7 @@ interface ShareEmbedSurveyProps {
export const ShareEmbedSurvey = ({
survey,
surveyDomain,
publicDomain,
open,
modalView,
setOpen,
@@ -62,6 +63,20 @@ export const ShareEmbedSurvey = ({
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
const url = await getSurveyUrl(survey, publicDomain, "default");
setSurveyUrl(url);
} catch (error) {
console.error("Failed to fetch survey URL:", error);
// Fallback to a default URL if fetching fails
setSurveyUrl(`${publicDomain}/s/${survey.id}`);
}
};
fetchSurveyUrl();
}, [survey, publicDomain]);
useEffect(() => {
if (survey.type !== "link") {
setActiveId(tabs[3].id);
@@ -86,7 +101,7 @@ export const ShareEmbedSurvey = ({
};
const handleInitialPageButton = () => {
setOpen(false);
setShowView("start");
};
return (
@@ -105,7 +120,7 @@ export const ShareEmbedSurvey = ({
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
@@ -159,7 +174,7 @@ export const ShareEmbedSurvey = ({
survey={survey}
email={email}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>

View File

@@ -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", () => {

View File

@@ -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>
))}

View File

@@ -38,18 +38,10 @@ interface SummaryListProps {
responseCount: number | null;
environment: TEnvironment;
survey: TSurvey;
totalResponseCount: number;
locale: TUserLocale;
}
export const SummaryList = ({
summary,
environment,
responseCount,
survey,
totalResponseCount,
locale,
}: SummaryListProps) => {
export const SummaryList = ({ summary, environment, responseCount, survey, locale }: SummaryListProps) => {
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslate();
const setFilter = (
@@ -115,11 +107,7 @@ export const SummaryList = ({
type="response"
environment={environment}
noWidgetRequired={survey.type === "link"}
emptyMessage={
totalResponseCount === 0
? undefined
: t("environments.surveys.summary.no_response_matches_filter")
}
emptyMessage={t("environments.surveys.summary.no_responses_found")}
/>
) : (
summary.map((questionSummary) => {

View File

@@ -11,7 +11,11 @@ vi.mock("lucide-react", () => ({
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipProvider: ({ children }) => <>{children}</>,
Tooltip: ({ children }) => <>{children}</>,
TooltipTrigger: ({ children }) => <>{children}</>,
TooltipTrigger: ({ children, onClick }) => (
<button tabIndex={0} onClick={onClick} style={{ display: "inline-block" }}>
{children}
</button>
),
TooltipContent: ({ children }) => <>{children}</>,
}));
@@ -67,8 +71,10 @@ describe("SummaryMetadata", () => {
expect(screen.getByText("25%")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
const btn = screen.getByRole("button");
expect(screen.queryByTestId("down")).toBeInTheDocument();
const btn = screen
.getAllByRole("button")
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
if (!btn) throw new Error("DropOffs toggle button not found");
await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument();
});
@@ -101,8 +107,10 @@ describe("SummaryMetadata", () => {
};
render(<Wrapper />);
expect(screen.getAllByText("-")).toHaveLength(1);
const btn = screen.getByRole("button");
expect(screen.queryByTestId("down")).toBeInTheDocument();
const btn = screen
.getAllByRole("button")
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
if (!btn) throw new Error("DropOffs toggle button not found");
await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument();
});

View File

@@ -100,8 +100,8 @@ export const SummaryMetadata = ({
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<TooltipTrigger onClick={() => setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle">
<div className="flex h-full cursor-pointer flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
@@ -117,15 +117,13 @@ export const SummaryMetadata = ({
)}
</span>
{!isLoading && (
<button
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"
onClick={() => setShowDropOffs(!showDropOffs)}>
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</button>
</span>
)}
</div>
</div>

View File

@@ -1,30 +1,23 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
getResponseCountAction,
getSurveySummaryAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import {
getResponseCountBySurveySharingKeyAction,
getSummaryBySurveySharingKeyAction,
} from "@/app/share/[sharingKey]/actions";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { getSummaryBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { TUserLocale } from "@formbricks/types/user";
import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata";
const initialSurveySummary: TSurveySummary = {
const defaultSurveySummary: TSurveySummary = {
meta: {
completedPercentage: 0,
completedResponses: 0,
@@ -43,111 +36,80 @@ interface SummaryPageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
webAppUrl: string;
user?: TUser;
totalResponseCount: number;
documentsPerPage?: number;
publicDomain: string;
locale: TUserLocale;
isReadOnly: boolean;
initialSurveySummary?: TSurveySummary;
}
export const SummaryPage = ({
environment,
survey,
surveyId,
webAppUrl,
totalResponseCount,
publicDomain,
locale,
isReadOnly,
initialSurveySummary,
}: SummaryPageProps) => {
const params = useParams();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const searchParams = useSearchParams();
const isShareEmbedModalOpen = searchParams.get("share") === "true";
const [responseCount, setResponseCount] = useState<number | null>(null);
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(initialSurveySummary);
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary
);
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
[selectedFilter, dateRange, survey]
);
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
const hasNoFilters =
(!selectedFilter ||
Object.keys(selectedFilter).length === 0 ||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
(!dateRange || (!dateRange.from && !dateRange.to));
// Use a ref to keep the latest state and props
const latestFiltersRef = useRef(filters);
latestFiltersRef.current = filters;
if (initialSurveySummary && hasNoFilters) {
setIsLoading(false);
return;
}
const getResponseCount = useCallback(() => {
if (isSharingPage)
return getResponseCountBySurveySharingKeyAction({
sharingKey,
filterCriteria: latestFiltersRef.current,
});
return getResponseCountAction({
surveyId,
filterCriteria: latestFiltersRef.current,
});
}, [isSharingPage, sharingKey, surveyId]);
const getSummary = useCallback(() => {
if (isSharingPage)
return getSummaryBySurveySharingKeyAction({
sharingKey,
filterCriteria: latestFiltersRef.current,
});
return getSurveySummaryAction({
surveyId,
filterCriteria: latestFiltersRef.current,
});
}, [isSharingPage, sharingKey, surveyId]);
const handleInitialData = useCallback(
async (isInitialLoad = false) => {
if (isInitialLoad) {
setIsLoading(true);
}
const fetchSummary = async () => {
setIsLoading(true);
try {
const [updatedResponseCountData, updatedSurveySummary] = await Promise.all([
getResponseCount(),
getSummary(),
]);
// Recalculate filters inside the effect to ensure we have the latest values
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
let updatedSurveySummary;
const responseCount = updatedResponseCountData?.data ?? 0;
const surveySummary = updatedSurveySummary?.data ?? initialSurveySummary;
if (isSharingPage) {
updatedSurveySummary = await getSummaryBySurveySharingKeyAction({
sharingKey,
filterCriteria: currentFilters,
});
} else {
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
}
setResponseCount(responseCount);
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
setSurveySummary(surveySummary);
} catch (error) {
console.error(error);
} finally {
if (isInitialLoad) {
setIsLoading(false);
}
setIsLoading(false);
}
},
[getResponseCount, getSummary]
);
};
useEffect(() => {
handleInitialData(true);
}, [filters, isSharingPage, sharingKey, surveyId, handleInitialData]);
useIntervalWhenFocused(
() => {
handleInitialData(false);
},
10000,
!isShareEmbedModalOpen,
false
);
fetchSummary();
}, [selectedFilter, dateRange, survey, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
@@ -171,16 +133,15 @@ 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" />
<SummaryList
summary={surveySummary.summary}
responseCount={responseCount}
responseCount={surveySummary.meta.totalResponses}
survey={surveyMemoized}
environment={environment}
totalResponseCount={totalResponseCount}
locale={locale}
/>
</>

View File

@@ -7,6 +7,22 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { SurveyAnalysisCTA } from "./SurveyAnalysisCTA";
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/utils", () => ({
withAuditLogging: vi.fn((...args: any[]) => {
// Check if the last argument is a function and return it directly
if (typeof args[args.length - 1] === "function") {
return args[args.length - 1];
}
// Otherwise, return a new function that takes a function as an argument and returns it
return (fn: any) => fn;
}),
}));
const mockPublicDomain = "https://public-domain.com";
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
@@ -30,6 +46,15 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
AUDIT_LOG_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-url",
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
// Create a spy for refreshSingleUseId so we can override it in tests
@@ -50,11 +75,12 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
usePathname: () => "/current",
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
}));
// Mock copySurveyLink to return a predictable string
vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`),
copySurveyLink: vi.fn((url: string, suId: string) => `${url}?suId=${suId}`),
}));
// Mock the copy survey action
@@ -68,6 +94,27 @@ vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((response) => response?.error || "Unknown error"),
}));
// Mock ResponseCountProvider dependencies
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: vi.fn(() => ({ selectedFilter: "all", dateRange: {} })),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({
getResponseCountAction: vi.fn(() => Promise.resolve({ data: 5 })),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
getFormattedFilters: vi.fn(() => []),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getResponseCountBySurveySharingKeyAction: vi.fn(() => Promise.resolve({ data: 5 })),
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(() => mockPublicDomain),
}));
vi.spyOn(toast, "success");
vi.spyOn(toast, "error");
@@ -88,7 +135,6 @@ const dummySurvey = {
} as unknown as TSurvey;
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
const dummyUser = { id: "user123", name: "Test User" } as TUser;
const surveyDomain = "https://surveys.test.formbricks.com";
describe("SurveyAnalysisCTA - handleCopyLink", () => {
afterEach(() => {
@@ -101,7 +147,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -112,9 +158,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).toHaveBeenCalledWith(
"https://surveys.test.formbricks.com/s/survey123?id=newSingleUseId"
);
expect(writeTextMock).toHaveBeenCalledWith("https://public-domain.com/s/survey123?suId=newSingleUseId");
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
@@ -126,7 +170,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -159,7 +203,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -180,7 +224,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={0}
/>
@@ -202,7 +246,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -231,7 +275,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -274,7 +318,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -300,7 +344,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -334,7 +378,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>

View File

@@ -24,7 +24,7 @@ interface SurveyAnalysisCTAProps {
environment: TEnvironment;
isReadOnly: boolean;
user: TUser;
surveyDomain: string;
publicDomain: string;
responseCount: number;
}
@@ -40,7 +40,7 @@ export const SurveyAnalysisCTA = ({
environment,
isReadOnly,
user,
surveyDomain,
publicDomain,
responseCount,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
@@ -56,7 +56,7 @@ export const SurveyAnalysisCTA = ({
dropdown: false,
});
const surveyUrl = useMemo(() => `${surveyDomain}/s/${survey.id}`, [survey.id, surveyDomain]);
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -171,7 +171,7 @@ export const SurveyAnalysisCTA = ({
icon: SquarePenIcon,
tooltip: t("common.edit"),
onClick: () => {
responseCount && responseCount > 0
responseCount > 0
? setIsCautionDialogOpen(true)
: router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`);
},
@@ -202,7 +202,7 @@ export const SurveyAnalysisCTA = ({
<ShareEmbedSurvey
key={key}
survey={survey}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
open={modalState[key as keyof ModalState]}
setOpen={setOpen}
user={user}

View File

@@ -64,7 +64,7 @@ const defaultProps = {
survey: mockSurveyLink,
email: "test@example.com",
surveyUrl: "http://example.com/survey1",
surveyDomain: "http://example.com",
publicDomain: "http://example.com",
setSurveyUrl: vi.fn(),
locale: "en" as any,
disableBack: false,

View File

@@ -20,7 +20,7 @@ interface EmbedViewProps {
survey: any;
email: string;
surveyUrl: string;
surveyDomain: string;
publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
locale: TUserLocale;
}
@@ -35,7 +35,7 @@ export const EmbedView = ({
survey,
email,
surveyUrl,
surveyDomain,
publicDomain,
setSurveyUrl,
locale,
}: EmbedViewProps) => {
@@ -83,7 +83,7 @@ export const EmbedView = ({
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>

View File

@@ -6,12 +6,12 @@ import { LinkTab } from "./LinkTab";
// Mock ShareSurveyLink
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: vi.fn(({ survey, surveyUrl, surveyDomain, locale }) => (
ShareSurveyLink: vi.fn(({ survey, surveyUrl, publicDomain, locale }) => (
<div data-testid="share-survey-link">
Mocked ShareSurveyLink
<span data-testid="survey-id">{survey.id}</span>
<span data-testid="survey-url">{surveyUrl}</span>
<span data-testid="survey-domain">{surveyDomain}</span>
<span data-testid="public-domain">{publicDomain}</span>
<span data-testid="locale">{locale}</span>
</div>
)),
@@ -49,7 +49,7 @@ const mockSurvey: TSurvey = {
} as unknown as TSurvey;
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
const mockSurveyDomain = "https://app.formbricks.com";
const mockPublicDomain = "https://app.formbricks.com";
const mockSetSurveyUrl = vi.fn();
const mockLocale: TUserLocale = "en-US";
@@ -82,7 +82,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
@@ -97,7 +97,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
@@ -105,7 +105,7 @@ describe("LinkTab", () => {
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
expect(screen.getByTestId("survey-domain")).toHaveTextContent(mockSurveyDomain);
expect(screen.getByTestId("public-domain")).toHaveTextContent(mockPublicDomain);
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
});
@@ -114,7 +114,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
@@ -129,7 +129,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>

View File

@@ -9,12 +9,12 @@ import { TUserLocale } from "@formbricks/types/user";
interface LinkTabProps {
survey: TSurvey;
surveyUrl: string;
surveyDomain: string;
publicDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}
export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => {
export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate();
const docsLinks = [
@@ -44,7 +44,7 @@ export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>

View File

@@ -1,9 +1,8 @@
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { getTranslate } from "@/tolgee/server";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
@@ -35,7 +34,16 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/getSurveyUrl");
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn().mockReturnValue("https://public-domain.com"),
}));
vi.mock("@/lib/project/service");
vi.mock("@/lib/survey/service");
vi.mock("@/lib/utils/styling");
@@ -121,7 +129,7 @@ const mockComputedStyling = {
thankYouCardIconBgColor: "#DDDDDD",
} as any;
const mockSurveyDomain = "https://app.formbricks.com";
const mockPublicDomain = "https://app.formbricks.com";
const mockRawHtml = `${doctype}<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
const mockCleanedHtml = `<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
@@ -136,7 +144,7 @@ describe("getEmailTemplateHtml", () => {
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
vi.mocked(getStyling).mockReturnValue(mockComputedStyling);
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
vi.mocked(getPreviewEmailTemplateHtml).mockResolvedValue(mockRawHtml);
});
@@ -147,8 +155,8 @@ describe("getEmailTemplateHtml", () => {
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockSurvey.environmentId);
expect(getStyling).toHaveBeenCalledWith(mockProject, mockSurvey);
expect(getSurveyDomain).toHaveBeenCalledTimes(1);
const expectedSurveyUrl = `${mockSurveyDomain}/s/${mockSurvey.id}`;
expect(getPublicDomain).toHaveBeenCalledTimes(1);
const expectedSurveyUrl = `${mockPublicDomain}/s/${mockSurvey.id}`;
expect(getPreviewEmailTemplateHtml).toHaveBeenCalledWith(
mockSurvey,
expectedSurveyUrl,

View File

@@ -1,4 +1,4 @@
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
@@ -17,7 +17,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
}
const styling = getStyling(project, survey);
const surveyUrl = getSurveyDomain() + "/s/" + survey.id;
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

View File

@@ -28,7 +28,7 @@ export const useSurveyQRCode = (surveyUrl: string) => {
} catch (error) {
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
}
}, [surveyUrl]);
}, [surveyUrl, t]);
const downloadQRCode = () => {
try {

View File

@@ -1,4 +1,3 @@
import { cache } from "@/lib/cache";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service";
@@ -26,23 +25,6 @@ import {
// Ensure this path is correct
import { convertFloatTo2Decimal } from "./utils";
// Mock dependencies
vi.mock("@/lib/cache", async () => {
const actual = await vi.importActual("@/lib/cache");
return {
...(actual as any),
cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function
};
});
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn().mockImplementation((fn) => fn),
};
});
vi.mock("@/lib/display/service", () => ({
getDisplayCountBySurveyId: vi.fn(),
}));
@@ -162,10 +144,6 @@ describe("getSurveySummaryMeta", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("calculates meta correctly", () => {
@@ -226,9 +204,6 @@ describe("getSurveySummaryDropOff", () => {
requiredQuestionIds: [],
calculations: {},
});
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("calculates dropOff correctly with welcome card disabled", () => {
@@ -367,9 +342,7 @@ describe("getQuestionSummary", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
// React cache is already mocked globally - no need to mock it again
});
test("summarizes OpenText questions", async () => {
@@ -746,9 +719,7 @@ describe("getSurveySummary", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
// React cache is already mocked globally - no need to mock it again
});
test("returns survey summary successfully", async () => {
@@ -758,7 +729,6 @@ describe("getSurveySummary", () => {
expect(summary.dropOff).toBeDefined();
expect(summary.summary).toBeDefined();
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, undefined);
expect(prisma.response.findMany).toHaveBeenCalled(); // Check if getResponsesForSummary was effectively called
expect(getDisplayCountBySurveyId).toHaveBeenCalled();
});
@@ -770,7 +740,6 @@ describe("getSurveySummary", () => {
test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true };
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(2); // Assume 2 finished responses
const finishedResponses = mockResponses
.filter((r) => r.finished)
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
@@ -778,7 +747,6 @@ describe("getSurveySummary", () => {
await getSurveySummary(mockSurveyId, filterCriteria);
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, filterCriteria);
expect(prisma.response.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ surveyId: mockSurveyId }), // buildWhereClause is mocked
@@ -798,9 +766,7 @@ describe("getResponsesForSummary", () => {
vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
// React cache is already mocked globally - no need to mock it again
});
test("fetches and transforms responses", async () => {
@@ -843,6 +809,16 @@ describe("getResponsesForSummary", () => {
language: "en",
ttc: {},
finished: true,
createdAt: new Date(),
meta: {},
variables: {},
surveyId: "survey-1",
contactId: null,
personAttributes: {},
singleUseId: null,
isFinished: true,
displayId: "display-1",
endingId: null,
};
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
@@ -876,6 +852,16 @@ describe("getResponsesForSummary", () => {
language: "en",
ttc: {},
finished: true,
createdAt: new Date(),
meta: {},
variables: {},
surveyId: "survey-1",
contactId: "contact-1",
personAttributes: {},
singleUseId: null,
isFinished: true,
displayId: "display-1",
endingId: null,
};
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
@@ -904,6 +890,16 @@ describe("getResponsesForSummary", () => {
language: "en",
ttc: {},
finished: true,
createdAt: new Date(),
meta: {},
variables: {},
surveyId: "survey-1",
contactId: "contact-1",
personAttributes: {},
singleUseId: null,
isFinished: true,
displayId: "display-1",
endingId: null,
};
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);

View File

@@ -1,18 +1,14 @@
import "server-only";
import { cache } from "@/lib/cache";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { displayCache } from "@/lib/display/cache";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { responseCache } from "@/lib/response/cache";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { buildWhereClause } from "@/lib/response/utils";
import { surveyCache } from "@/lib/survey/cache";
import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -905,66 +901,57 @@ export const getQuestionSummary = async (
};
export const getSurveySummary = reactCache(
async (surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise<TSurveySummary> =>
cache(
async () => {
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
async (surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise<TSurveySummary> => {
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
try {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const batchSize = 5000;
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
const pages = Math.ceil(responseCount / batchSize);
// Create an array of batch fetch promises
const batchPromises = Array.from({ length: pages }, (_, i) =>
getResponsesForSummary(surveyId, batchSize, i * batchSize, filterCriteria)
);
// Fetch all batches in parallel
const batchResults = await Promise.all(batchPromises);
// Combine all batch results
const responses = batchResults.flat();
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
const displayCount = await getDisplayCountBySurveyId(surveyId, {
createdAt: filterCriteria?.createdAt,
...(hasFilter && { responseIds }),
});
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const [meta, questionWiseSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount),
getQuestionSummary(survey, responses, dropOff),
]);
return { meta, dropOff, summary: questionWiseSummary };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveySummary-${surveyId}-${JSON.stringify(filterCriteria)}`],
{
tags: [
surveyCache.tag.byId(surveyId),
responseCache.tag.bySurveyId(surveyId),
displayCache.tag.bySurveyId(surveyId),
],
try {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
)()
const batchSize = 5000;
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
// Use cursor-based pagination instead of count + offset to avoid expensive queries
const responses: TSurveySummaryResponse[] = [];
let cursor: string | undefined = undefined;
let hasMore = true;
while (hasMore) {
const batch = await getResponsesForSummary(surveyId, batchSize, 0, filterCriteria, cursor);
responses.push(...batch);
if (batch.length < batchSize) {
hasMore = false;
} else {
// Use the last response's ID as cursor for next batch
cursor = batch[batch.length - 1].id;
}
}
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
const displayCount = await getDisplayCountBySurveyId(surveyId, {
createdAt: filterCriteria?.createdAt,
...(hasFilter && { responseIds }),
});
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const [meta, questionWiseSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount),
getQuestionSummary(survey, responses, dropOff),
]);
return { meta, dropOff, summary: questionWiseSummary };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getResponsesForSummary = reactCache(
@@ -972,80 +959,87 @@ export const getResponsesForSummary = reactCache(
surveyId: string,
limit: number,
offset: number,
filterCriteria?: TResponseFilterCriteria
): Promise<TSurveySummaryResponse[]> =>
cache(
async () => {
validateInputs(
[surveyId, ZId],
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()]
);
filterCriteria?: TResponseFilterCriteria,
cursor?: string
): Promise<TSurveySummaryResponse[]> => {
validateInputs(
[surveyId, ZId],
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
);
const queryLimit = limit ?? RESPONSES_PER_PAGE;
const survey = await getSurvey(surveyId);
if (!survey) return [];
try {
const responses = await prisma.response.findMany({
where: {
surveyId,
...buildWhereClause(survey, filterCriteria),
},
const queryLimit = limit ?? RESPONSES_PER_PAGE;
const survey = await getSurvey(surveyId);
if (!survey) return [];
try {
const whereClause: Prisma.ResponseWhereInput = {
surveyId,
...buildWhereClause(survey, filterCriteria),
};
// Add cursor condition for cursor-based pagination
if (cursor) {
whereClause.id = {
lt: cursor, // Get responses with ID less than cursor (for desc order)
};
}
const responses = await prisma.response.findMany({
where: whereClause,
select: {
id: true,
data: true,
updatedAt: true,
contact: {
select: {
id: true,
data: true,
updatedAt: true,
contact: {
select: {
id: true,
attributes: {
select: { attributeKey: true, value: true },
},
},
attributes: {
select: { attributeKey: true, value: true },
},
contactAttributes: true,
language: true,
ttc: true,
finished: true,
},
orderBy: [
{
createdAt: "desc",
},
],
take: queryLimit,
skip: offset,
});
},
contactAttributes: true,
language: true,
ttc: true,
finished: true,
},
orderBy: [
{
createdAt: "desc",
},
{
id: "desc", // Secondary sort by ID for consistent pagination
},
],
take: queryLimit,
skip: offset,
});
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id as string,
userId: responsePrisma.contact.attributes.find(
(attribute) => attribute.attributeKey.key === "userId"
)?.value as string,
}
: null,
};
})
);
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id as string,
userId: responsePrisma.contact.attributes.find(
(attribute) => attribute.attributeKey.key === "userId"
)?.value as string,
}
: null,
};
})
);
return transformedResponses;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
return transformedResponses;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
)()
throw error;
}
}
);

View File

@@ -1,8 +1,10 @@
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import SurveyPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
@@ -36,9 +38,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
SESSION_MAX_AGE: 1000,
}));
vi.mock(
@@ -62,8 +63,8 @@ vi.mock(
})
);
vi.mock("@/lib/getSurveyUrl", () => ({
getSurveyDomain: vi.fn(),
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
@@ -78,6 +79,13 @@ vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary",
() => ({
getSurveySummary: vi.fn(),
})
);
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
@@ -100,6 +108,11 @@ vi.mock("@/tolgee/server", () => ({
vi.mock("next/navigation", () => ({
notFound: vi.fn(),
useParams: () => ({
environmentId: "test-environment-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
const mockEnvironmentId = "test-environment-id";
@@ -172,6 +185,21 @@ const mockSession = {
expires: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
} as any;
const mockSurveySummary = {
meta: {
completedPercentage: 75,
completedResponses: 15,
displayCount: 20,
dropOffPercentage: 25,
dropOffCount: 5,
startsPercentage: 80,
totalResponses: 20,
ttcAverage: 120,
},
dropOff: [],
summary: [],
};
describe("SurveyPage", () => {
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
@@ -182,7 +210,8 @@ describe("SurveyPage", () => {
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getSurveyDomain).mockReturnValue("test.domain.com");
vi.mocked(getPublicDomain).mockReturnValue("http://localhost:3000");
vi.mocked(getSurveySummary).mockResolvedValue(mockSurveySummary);
vi.mocked(notFound).mockClear();
});
@@ -193,7 +222,8 @@ describe("SurveyPage", () => {
test("renders correctly with valid data", async () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
render(await SurveyPage({ params }));
const jsx = await SurveyPage({ params });
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -204,15 +234,13 @@ describe("SurveyPage", () => {
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId);
expect(vi.mocked(getResponseCountBySurveyId)).toHaveBeenCalledWith(mockSurveyId);
expect(vi.mocked(getSurveyDomain)).toHaveBeenCalled();
expect(vi.mocked(getPublicDomain)).toHaveBeenCalled();
expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual(
expect.objectContaining({
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "summary",
initialTotalResponseCount: 10,
})
);
@@ -221,19 +249,18 @@ describe("SurveyPage", () => {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
webAppUrl: WEBAPP_URL,
user: mockUser,
totalResponseCount: 10,
documentsPerPage: DOCUMENTS_PER_PAGE,
publicDomain: "http://localhost:3000",
isReadOnly: false,
locale: mockUser.locale ?? DEFAULT_LOCALE,
initialSurveySummary: mockSurveySummary,
})
);
});
test("calls notFound if surveyId is not present in params", async () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: undefined }) as any;
render(await SurveyPage({ params }));
const jsx = await SurveyPage({ params });
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
expect(vi.mocked(notFound)).toHaveBeenCalled();
});
@@ -243,7 +270,7 @@ describe("SurveyPage", () => {
try {
// We need to await the component itself because it's an async component
const SurveyPageComponent = await SurveyPage({ params });
render(SurveyPageComponent);
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
} catch (e: any) {
expect(e.message).toBe("common.survey_not_found");
}
@@ -256,7 +283,7 @@ describe("SurveyPage", () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
try {
const SurveyPageComponent = await SurveyPage({ params });
render(SurveyPageComponent);
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
} catch (e: any) {
expect(e.message).toBe("common.user_not_found");
}

View File

@@ -1,9 +1,9 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -37,12 +37,10 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
throw new Error(t("common.user_not_found"));
}
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
// I took this out cause it's cloud only right?
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const surveyDomain = getSurveyDomain();
const publicDomain = getPublicDomain();
return (
<PageContentWrapper>
@@ -54,30 +52,23 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
survey={survey}
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
responseCount={totalResponseCount}
publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
/>
}>
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}
activeId="summary"
initialTotalResponseCount={totalResponseCount}
/>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
</PageHeader>
<SummaryPage
environment={environment}
survey={survey}
surveyId={params.surveyId}
webAppUrl={WEBAPP_URL}
user={user}
totalResponseCount={totalResponseCount}
documentsPerPage={DOCUMENTS_PER_PAGE}
publicDomain={publicDomain}
isReadOnly={isReadOnly}
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
/>
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
<SettingsId title={t("common.survey_id")} id={surveyId} />
</PageContentWrapper>
);
};

View File

@@ -5,8 +5,10 @@ import { getResponseDownloadUrl, getResponseFilteringValues } from "@/lib/respon
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/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 { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
@@ -14,7 +16,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
const ZGetResponsesDownloadUrlAction = z.object({
surveyId: ZId,
@@ -102,39 +104,54 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
}
};
export const updateSurveyAction = authenticatedActionClient
.schema(ZSurvey)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const { followUps } = parsedInput;
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
return await updateSurvey(parsedInput);
});
)
);

View File

@@ -250,6 +250,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a");
link.href = responsesDownloadUrlResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);

View File

@@ -138,7 +138,7 @@ describe("ResultsShareButton", () => {
test("renders initial state and fetches sharing key (no existing key)", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
expect(screen.getByTestId("dropdown-menu-trigger")).toBeInTheDocument();
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
@@ -150,7 +150,7 @@ describe("ResultsShareButton", () => {
test("handles copy private link to clipboard", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
const copyLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -166,7 +166,9 @@ describe("ResultsShareButton", () => {
test("handles copy public link to clipboard", async () => {
const shareKey = "publicShareKey";
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
render(
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
const copyPublicLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -184,7 +186,7 @@ describe("ResultsShareButton", () => {
test("handles publish to web successfully", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
mockGenerateResultShareUrlAction.mockResolvedValue({ data: "newShareKey" });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -210,7 +212,9 @@ describe("ResultsShareButton", () => {
const shareKey = "toUnpublishKey";
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
mockDeleteResultShareUrlAction.mockResolvedValue({ data: { id: mockSurvey.id } });
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
render(
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const unpublishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -234,7 +238,7 @@ describe("ResultsShareButton", () => {
test("opens and closes ShareSurveyResults modal", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>

View File

@@ -21,10 +21,10 @@ import { ShareSurveyResults } from "../(analysis)/summary/components/ShareSurvey
interface ResultsShareButtonProps {
survey: TSurvey;
webAppUrl: string;
publicDomain: string;
}
export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProps) => {
export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonProps) => {
const { t } = useTranslate();
const [showResultsLinkModal, setShowResultsLinkModal] = useState(false);
@@ -34,7 +34,7 @@ export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProp
const handlePublish = async () => {
const resultShareKeyResponse = await generateResultShareUrlAction({ surveyId: survey.id });
if (resultShareKeyResponse?.data) {
setSurveyUrl(webAppUrl + "/share/" + resultShareKeyResponse.data);
setSurveyUrl(publicDomain + "/share/" + resultShareKeyResponse.data);
setShowPublishModal(true);
} else {
const errorMessage = getFormattedErrorMessage(resultShareKeyResponse);
@@ -58,13 +58,13 @@ export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProp
const fetchSharingKey = async () => {
const resultShareUrlResponse = await getResultShareUrlAction({ surveyId: survey.id });
if (resultShareUrlResponse?.data) {
setSurveyUrl(webAppUrl + "/share/" + resultShareUrlResponse.data);
setSurveyUrl(publicDomain + "/share/" + resultShareUrlResponse.data);
setShowPublishModal(true);
}
};
fetchSharingKey();
}, [survey.id, webAppUrl]);
}, [survey.id, publicDomain]);
const copyUrlToClipboard = () => {
if (typeof window !== "undefined") {

View File

@@ -11,6 +11,7 @@ import {
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -28,6 +29,7 @@ export const SurveyStatusDropdown = ({
survey,
}: SurveyStatusDropdownProps) => {
const { t } = useTranslate();
const router = useRouter();
const isCloseOnDateEnabled = survey.closeOnDate !== null;
const closeOnDate = survey.closeOnDate ? new Date(survey.closeOnDate) : null;
const isStatusChangeDisabled =
@@ -47,6 +49,8 @@ export const SurveyStatusDropdown = ({
? t("common.survey_completed")
: ""
);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
toast.error(errorMessage);

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