mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
237 Commits
chore/iden
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cb3881cd0 | ||
|
|
ec78038caa | ||
|
|
908614b4e2 | ||
|
|
c584901337 | ||
|
|
087699fb57 | ||
|
|
58213969e8 | ||
|
|
ef973c8995 | ||
|
|
bea02ba3b5 | ||
|
|
1c1e2ee09c | ||
|
|
2bf7fe6c54 | ||
|
|
9639402c39 | ||
|
|
53213b41ee | ||
|
|
b8b5eead7a | ||
|
|
a0044ce376 | ||
|
|
b3a1f24683 | ||
|
|
f06d48698a | ||
|
|
acd508ba19 | ||
|
|
e5591686b4 | ||
|
|
7be7466eee | ||
|
|
8af6c15998 | ||
|
|
17d60eb1e7 | ||
|
|
d6ecafbc23 | ||
|
|
599e847686 | ||
|
|
4e52556f7e | ||
|
|
492a59e7de | ||
|
|
e0be53805e | ||
|
|
5c2860d1a4 | ||
|
|
18ba5bbd8a | ||
|
|
572b613034 | ||
|
|
a9c7140ba6 | ||
|
|
7fa95cd74a | ||
|
|
8c7f36d496 | ||
|
|
42dcbd3e7e | ||
|
|
1c1cd99510 | ||
|
|
b0a7e212dd | ||
|
|
0c1f6f3c3a | ||
|
|
9399b526b8 | ||
|
|
cd60032bc9 | ||
|
|
a941f994ea | ||
|
|
75d170bce5 | ||
|
|
16caae6dd6 | ||
|
|
a490600479 | ||
|
|
be28641722 | ||
|
|
4fdea3221b | ||
|
|
fef30c54b2 | ||
|
|
75362eac7a | ||
|
|
6e3b224944 | ||
|
|
ef1be219b4 | ||
|
|
ba9b01a969 | ||
|
|
e810e38333 | ||
|
|
dab8ad00d5 | ||
|
|
2c34f43c83 | ||
|
|
979fd71a11 | ||
|
|
1be23eebbb | ||
|
|
d10cff917d | ||
|
|
da72101320 | ||
|
|
5f02ad49c1 | ||
|
|
6644bba6ea | ||
|
|
0b7734f725 | ||
|
|
1536bf6907 | ||
|
|
e81190214f | ||
|
|
48c8906a89 | ||
|
|
717b30115b | ||
|
|
1f3962d2d5 | ||
|
|
619f6e408f | ||
|
|
4a8719abaa | ||
|
|
7b59eb3b26 | ||
|
|
8ac280268d | ||
|
|
34e8f4931d | ||
|
|
ac46850a24 | ||
|
|
6328be220a | ||
|
|
882ad99ed7 | ||
|
|
ce47b4c2d8 | ||
|
|
ce8f9de8ec | ||
|
|
ed3c2d2b58 | ||
|
|
9ae226329b | ||
|
|
12c3899b85 | ||
|
|
ccb1353eb5 | ||
|
|
22eb0b79ee | ||
|
|
5eb7a496da | ||
|
|
7ea55e199f | ||
|
|
83eb472acd | ||
|
|
d9fe6ee4f4 | ||
|
|
51b58be079 | ||
|
|
397643330a | ||
|
|
e5fa4328e1 | ||
|
|
4b777f1907 | ||
|
|
c3547ccb36 | ||
|
|
a0f334b300 | ||
|
|
a9f635b768 | ||
|
|
d385b4a0d6 | ||
|
|
5e825413d2 | ||
|
|
8c3e816ccd | ||
|
|
6ddc91ee85 | ||
|
|
14023ca8a9 | ||
|
|
385e8a4262 | ||
|
|
e358104f7c | ||
|
|
c8e9194ab6 | ||
|
|
bebe29815d | ||
|
|
7f40502c94 | ||
|
|
5fb5215680 | ||
|
|
19b80ff042 | ||
|
|
2dfdba2acf | ||
|
|
f7842789de | ||
|
|
59bdd5f065 | ||
|
|
8da1bc71a6 | ||
|
|
0e0259691c | ||
|
|
ac7831fa3d | ||
|
|
db32cb392f | ||
|
|
e5cb01bd88 | ||
|
|
cbef4c2a69 | ||
|
|
86948b70de | ||
|
|
dfe955ca7c | ||
|
|
eb4b2dde05 | ||
|
|
f2dae67813 | ||
|
|
3ffc9bd290 | ||
|
|
a9946737df | ||
|
|
ece3d508a2 | ||
|
|
0d1d227e6a | ||
|
|
c0b8edfdf2 | ||
|
|
45fec0e184 | ||
|
|
2c2ba919c6 | ||
|
|
6d8adc6168 | ||
|
|
ec208960e8 | ||
|
|
b9505158b4 | ||
|
|
ad0c3421f0 | ||
|
|
916c00344b | ||
|
|
459cdee17e | ||
|
|
bb26a64dbb | ||
|
|
29a3fa532a | ||
|
|
738b8f9012 | ||
|
|
c95272288e | ||
|
|
919febd166 | ||
|
|
10ccc20b53 | ||
|
|
d9ca64da54 | ||
|
|
ce00ec97d1 | ||
|
|
2b9cd37c6c | ||
|
|
f8f14eb6f3 | ||
|
|
645fc863aa | ||
|
|
c53f030b24 | ||
|
|
45d74f9ba0 | ||
|
|
87870919ca | ||
|
|
ce2fdde474 | ||
|
|
6e2f30c6ed | ||
|
|
5c8040008a | ||
|
|
639e25d679 | ||
|
|
f7e5ef96d2 | ||
|
|
745f5487e9 | ||
|
|
0e7f3adf53 | ||
|
|
342d2b1fc4 | ||
|
|
15279685f7 | ||
|
|
12aa959f50 | ||
|
|
9478946c7a | ||
|
|
8560bbf28b | ||
|
|
df7afe1b64 | ||
|
|
df52b60d61 | ||
|
|
65b051f0eb | ||
|
|
7678084061 | ||
|
|
022d33d06f | ||
|
|
4d157bf8dc | ||
|
|
9fcbe4e8c5 | ||
|
|
5aeb92eb4f | ||
|
|
00dfa629b5 | ||
|
|
3ca471b6a2 | ||
|
|
a525589186 | ||
|
|
59ed10398d | ||
|
|
25a86e31df | ||
|
|
7d6743a81a | ||
|
|
6616f62da5 | ||
|
|
a3cbc05e12 | ||
|
|
97095a627a | ||
|
|
910d257c56 | ||
|
|
0c0a008b28 | ||
|
|
9879458353 | ||
|
|
d44f1f3b4b | ||
|
|
c5d387a7e5 | ||
|
|
a6aacd5c55 | ||
|
|
57e7485564 | ||
|
|
42a38a6f47 | ||
|
|
34bb9c2127 | ||
|
|
6442b5e4aa | ||
|
|
dde5a55446 | ||
|
|
13e615a798 | ||
|
|
9c81961b0b | ||
|
|
c1a35e2d75 | ||
|
|
13415c75c2 | ||
|
|
300557a0e6 | ||
|
|
fcbb97010c | ||
|
|
6be46b16b2 | ||
|
|
35b2356a31 | ||
|
|
53ef756723 | ||
|
|
0f0b743a10 | ||
|
|
3f7dafb65c | ||
|
|
9df791b5ff | ||
|
|
dea40d9757 | ||
|
|
dd12a589d6 | ||
|
|
af6e5ba31e | ||
|
|
2b57b2080b | ||
|
|
154c85a0f7 | ||
|
|
3f465d4594 | ||
|
|
94e883f4c3 | ||
|
|
38622101f1 | ||
|
|
0eb64c0084 | ||
|
|
409f5b1791 | ||
|
|
14398a9c4f | ||
|
|
d1cdf6e216 | ||
|
|
65da25a626 | ||
|
|
ce8b019e93 | ||
|
|
67d7fe016d | ||
|
|
47583b5a32 | ||
|
|
03c9a6aaae | ||
|
|
4dcf9b093b | ||
|
|
5ba5ebf63d | ||
|
|
115bea2792 | ||
|
|
b0495a8a42 | ||
|
|
faabd371f5 | ||
|
|
f0be6de0b3 | ||
|
|
b338c6d28d | ||
|
|
07e9a7c007 | ||
|
|
928bb3f8bc | ||
|
|
b9d62f6af2 | ||
|
|
f7ac38953b | ||
|
|
6441c0aa31 | ||
|
|
16479eb6cf | ||
|
|
69472c21c2 | ||
|
|
c270688e8f | ||
|
|
00c86c7082 | ||
|
|
e95e9f9fda | ||
|
|
1588c2f47b | ||
|
|
53850c96db | ||
|
|
ae2cb15055 | ||
|
|
8bf1e096c0 | ||
|
|
0052dc88f0 | ||
|
|
d67d62df45 | ||
|
|
5d45de6bc4 | ||
|
|
cf5bc51e94 | ||
|
|
9a7d24ea4e |
61
.cursor/rules/build-and-deployment.mdc
Normal file
61
.cursor/rules/build-and-deployment.mdc
Normal 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
|
||||
414
.cursor/rules/cache-optimization.mdc
Normal file
414
.cursor/rules/cache-optimization.mdc
Normal 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)
|
||||
216
.cursor/rules/component-migration.mdc
Normal file
216
.cursor/rules/component-migration.mdc
Normal file
@@ -0,0 +1,216 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Component Migration Automation Rule
|
||||
|
||||
## Overview
|
||||
This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases.
|
||||
|
||||
## Trigger
|
||||
When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration").
|
||||
|
||||
## Process
|
||||
|
||||
### Step 1: Discovery and Planning
|
||||
1. **Identify migration parameters:**
|
||||
- Ask user for deprecated component name (e.g., "Modal")
|
||||
- Ask user for new component name(s) (e.g., "Dialog")
|
||||
- Ask for any components to exclude (e.g., "ModalWithTabs")
|
||||
- Ask for specific import paths if needed
|
||||
|
||||
2. **Scan codebase** for deprecated components:
|
||||
- Search for `import.*[DeprecatedComponent]` patterns
|
||||
- Exclude specified components that should not be migrated
|
||||
- List all found components with file paths
|
||||
- Present numbered list to user for confirmation
|
||||
|
||||
### Step 2: Component-by-Component Migration
|
||||
For each component, follow this exact sequence:
|
||||
|
||||
#### 2.1 Component Migration
|
||||
- **Import changes:**
|
||||
- Ask user to provide the new import structure
|
||||
- Example transformation pattern:
|
||||
```typescript
|
||||
// FROM:
|
||||
import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]"
|
||||
|
||||
// TO:
|
||||
import {
|
||||
[NewComponent],
|
||||
[NewComponentPart1],
|
||||
[NewComponentPart2],
|
||||
// ... other parts
|
||||
} from "@/components/ui/[NewComponent]"
|
||||
```
|
||||
|
||||
- **Props transformation:**
|
||||
- Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`)
|
||||
- Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`)
|
||||
- Apply transformations based on user specifications
|
||||
|
||||
- **Structure transformation:**
|
||||
- Ask user for the new component structure pattern
|
||||
- Apply the transformation maintaining all functionality
|
||||
- Preserve all existing logic, state management, and event handlers
|
||||
|
||||
#### 2.2 Wait for User Approval
|
||||
- Present the migration changes
|
||||
- Wait for explicit user approval before proceeding
|
||||
- If rejected, ask for specific feedback and iterate
|
||||
#### 2.3 Re-read and Apply Additional Changes
|
||||
- Re-read the component file to capture any user modifications
|
||||
- Apply any additional improvements the user made
|
||||
- Ensure all changes are incorporated
|
||||
|
||||
#### 2.4 Test File Updates
|
||||
- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`)
|
||||
- **Update test mocks:**
|
||||
- Ask user for new component mock structure
|
||||
- Replace old component mocks with new ones
|
||||
- Example pattern:
|
||||
```typescript
|
||||
// Add to test setup:
|
||||
jest.mock("@/components/ui/[NewComponent]", () => ({
|
||||
[NewComponent]: ({ children, [props] }: any) => ([mock implementation]),
|
||||
[NewComponentPart1]: ({ children }: any) => <div data-testid="[new-component-part1]">{children}</div>,
|
||||
[NewComponentPart2]: ({ children }: any) => <div data-testid="[new-component-part2]">{children}</div>,
|
||||
// ... other parts
|
||||
}));
|
||||
```
|
||||
- **Update test expectations:**
|
||||
- Change test IDs from old component to new component
|
||||
- Update any component-specific assertions
|
||||
- Ensure all new component parts used in the component are mocked
|
||||
|
||||
#### 2.5 Run Tests and Optimize
|
||||
- Execute `Node package manager test -- ComponentName.test.tsx`
|
||||
- Fix any failing tests
|
||||
- Optimize code quality (imports, formatting, etc.)
|
||||
- Re-run tests until all pass
|
||||
- **Maximum 3 iterations** - if still failing, ask user for guidance
|
||||
|
||||
#### 2.6 Wait for Final Approval
|
||||
- Present test results and any optimizations made
|
||||
- Wait for user approval of the complete migration
|
||||
- If rejected, iterate based on feedback
|
||||
|
||||
#### 2.7 Git Commit
|
||||
- Run: `git add .`
|
||||
- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"`
|
||||
- Confirm commit was successful
|
||||
|
||||
### Step 3: Final Report Generation
|
||||
After all components are migrated, generate a comprehensive GitHub PR report:
|
||||
|
||||
#### PR Title
|
||||
```
|
||||
feat: migrate [DeprecatedComponent] components to [NewComponent] system
|
||||
```
|
||||
|
||||
#### PR Description Template
|
||||
```markdown
|
||||
## 🔄 [DeprecatedComponent] to [NewComponent] Migration
|
||||
|
||||
### Overview
|
||||
Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency.
|
||||
|
||||
### Components Migrated
|
||||
[List each component with file path]
|
||||
|
||||
### Technical Changes
|
||||
- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]`
|
||||
- **Props:** [List prop transformations]
|
||||
- **Structure:** Implemented proper [NewComponent] component hierarchy
|
||||
- **Styling:** [Describe styling changes]
|
||||
- **Tests:** Updated all test mocks and expectations
|
||||
|
||||
### Migration Pattern
|
||||
```typescript
|
||||
// Before
|
||||
<[DeprecatedComponent] [oldProps]>
|
||||
[oldStructure]
|
||||
</[DeprecatedComponent]>
|
||||
|
||||
// After
|
||||
<[NewComponent] [newProps]>
|
||||
[newStructure]
|
||||
</[NewComponent]>
|
||||
```
|
||||
|
||||
### Testing
|
||||
- ✅ All existing tests updated and passing
|
||||
- ✅ Component functionality preserved
|
||||
- ✅ UI/UX behavior maintained
|
||||
|
||||
### How to Test This PR
|
||||
1. **Functional Testing:**
|
||||
- Navigate to each migrated component's usage
|
||||
- Verify [component] opens and closes correctly
|
||||
- Test all interactive elements within [components]
|
||||
- Confirm styling and layout are preserved
|
||||
|
||||
2. **Automated Testing:**
|
||||
```bash
|
||||
Node package manager test
|
||||
```
|
||||
|
||||
3. **Visual Testing:**
|
||||
- Check that all [components] maintain proper styling
|
||||
- Verify responsive behavior
|
||||
- Test keyboard navigation and accessibility
|
||||
|
||||
### Breaking Changes
|
||||
[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."]
|
||||
|
||||
### Notes
|
||||
- [Any excluded components] were preserved as they already use [NewComponent] internally
|
||||
- All form validation and complex state management preserved
|
||||
- Enhanced code quality with better imports and formatting
|
||||
```
|
||||
|
||||
## Special Considerations
|
||||
|
||||
### Excluded Components
|
||||
- **DO NOT MIGRATE** components specified by user as exclusions
|
||||
- They may already use the new component internally or have other reasons
|
||||
- Inform user these are skipped and why
|
||||
|
||||
### Complex Components
|
||||
- Preserve all existing functionality (forms, validation, state management)
|
||||
- Maintain prop interfaces
|
||||
- Keep all event handlers and callbacks
|
||||
- Preserve accessibility features
|
||||
|
||||
### Test Coverage
|
||||
- Ensure all new component parts are mocked when used
|
||||
- Mock all new component parts that appear in the component
|
||||
- Update test IDs from old component to new component
|
||||
- Maintain all existing test scenarios
|
||||
|
||||
### Error Handling
|
||||
- If tests fail after 3 iterations, stop and ask user for guidance
|
||||
- If component is too complex, ask user for specific guidance
|
||||
- If unsure about functionality preservation, ask for clarification
|
||||
|
||||
### Migration Patterns
|
||||
- Always ask user for specific migration patterns before starting
|
||||
- Confirm import structures, prop mappings, and component hierarchies
|
||||
- Adapt to different component architectures (simple replacements, complex restructuring, etc.)
|
||||
|
||||
## Success Criteria
|
||||
- All deprecated components successfully migrated to new components
|
||||
- All tests passing
|
||||
- No functionality lost
|
||||
- Code quality maintained or improved
|
||||
- User approval on each component
|
||||
- Successful git commits for each migration
|
||||
- Comprehensive PR report generated
|
||||
|
||||
## Usage Examples
|
||||
- "migrate Modal to Dialog"
|
||||
- "migrate Button to NewButton"
|
||||
- "migrate Card to ModernCard"
|
||||
- "component migration" (will prompt for details)
|
||||
41
.cursor/rules/database-performance.mdc
Normal file
41
.cursor/rules/database-performance.mdc
Normal 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
101
.cursor/rules/database.mdc
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
description: >
|
||||
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
||||
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
||||
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
||||
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||
globs: []
|
||||
alwaysApply: agent-requested
|
||||
---
|
||||
# Formbricks Database Schema Reference
|
||||
|
||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||
|
||||
## Database Overview
|
||||
|
||||
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
||||
|
||||
### Core Hierarchy
|
||||
```
|
||||
Organization
|
||||
└── Project
|
||||
└── Environment (production/development)
|
||||
├── Survey
|
||||
├── Contact
|
||||
├── ActionClass
|
||||
└── Integration
|
||||
```
|
||||
|
||||
## Schema Reference
|
||||
|
||||
For the complete and up-to-date database schema, please refer to:
|
||||
- Main schema: `packages/database/schema.prisma`
|
||||
- JSON type definitions: `packages/database/json-types.ts`
|
||||
|
||||
The schema.prisma file contains all model definitions, relationships, enums, and field types. The json-types.ts file contains TypeScript type definitions for JSON fields.
|
||||
|
||||
## Data Access Patterns
|
||||
|
||||
### Multi-tenancy
|
||||
- All data is scoped by Organization
|
||||
- Environment-level isolation for surveys and contacts
|
||||
- Project-level grouping for related surveys
|
||||
|
||||
### Soft Deletion
|
||||
Some models use soft deletion patterns:
|
||||
- Check `isActive` fields where present
|
||||
- Use proper filtering in queries
|
||||
|
||||
### Cascading Deletes
|
||||
Configured cascade relationships:
|
||||
- Organization deletion cascades to all child entities
|
||||
- Survey deletion removes responses, displays, triggers
|
||||
- Contact deletion removes attributes and responses
|
||||
|
||||
## Common Query Patterns
|
||||
|
||||
### Survey with Responses
|
||||
```typescript
|
||||
// Include response count and latest responses
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: { id: surveyId },
|
||||
include: {
|
||||
responses: {
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
},
|
||||
_count: {
|
||||
select: { responses: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Scoping
|
||||
```typescript
|
||||
// Always scope by environment
|
||||
const surveys = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
// Additional filters...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Contact with Attributes
|
||||
```typescript
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
include: {
|
||||
attributes: {
|
||||
include: {
|
||||
attributeKey: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
||||
|
||||
|
||||
23
.cursor/rules/documentations.mdc
Normal file
23
.cursor/rules/documentations.mdc
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
||||
|
||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
||||
|
||||
---
|
||||
title: "FEATURE NAME"
|
||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
||||
icon: "link"
|
||||
---
|
||||
|
||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
|
||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
|
||||
- If a feature is part of the Enterprise Edition, use this note:
|
||||
|
||||
<Note>
|
||||
FEATURE NAME is part of the @Enterprise Edition.
|
||||
</Note>
|
||||
152
.cursor/rules/eks-alb-optimization.mdc
Normal file
152
.cursor/rules/eks-alb-optimization.mdc
Normal 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
|
||||
334
.cursor/rules/formbricks-architecture.mdc
Normal file
334
.cursor/rules/formbricks-architecture.mdc
Normal 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
|
||||
5
.cursor/rules/performance-optimization.mdc
Normal file
5
.cursor/rules/performance-optimization.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
52
.cursor/rules/react-context-patterns.mdc
Normal file
52
.cursor/rules/react-context-patterns.mdc
Normal 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
|
||||
5
.cursor/rules/react-context-providers.mdc
Normal file
5
.cursor/rules/react-context-providers.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
327
.cursor/rules/testing-patterns.mdc
Normal file
327
.cursor/rules/testing-patterns.mdc
Normal 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"
|
||||
7
.cursor/rules/testing.mdc
Normal file
7
.cursor/rules/testing.mdc
Normal 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.
|
||||
24
.env.example
24
.env.example
@@ -80,8 +80,8 @@ S3_ENDPOINT_URL=
|
||||
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
||||
S3_FORCE_PATH_STYLE=0
|
||||
|
||||
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
|
||||
# SURVEY_URL=https://survey.example.com
|
||||
# Set this URL to add a public domain for all your client facing routes(default is WEBAPP_URL)
|
||||
# PUBLIC_URL=https://survey.example.com
|
||||
|
||||
#####################
|
||||
# Disable Features #
|
||||
@@ -172,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Automatically assign new users to a specific organization and role within that organization
|
||||
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
||||
# (Role Management is an Enterprise feature)
|
||||
# DEFAULT_ORGANIZATION_ROLE=owner
|
||||
# AUTH_SSO_DEFAULT_TEAM_ID=
|
||||
# AUTH_SKIP_INVITE_FOR_SSO=
|
||||
|
||||
@@ -190,9 +189,7 @@ ENTERPRISE_LICENSE_KEY=
|
||||
UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_DEFAULT_TTL=86400 # 1 day
|
||||
|
||||
# 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:
|
||||
@@ -200,9 +197,6 @@ REDIS_DEFAULT_TTL=86400 # 1 day
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||
# CUSTOM_CACHE_DISABLED=1
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
@@ -215,6 +209,16 @@ UNKEY_ROOT_KEY=
|
||||
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
|
||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard
|
||||
# SENTRY_ENVIRONMENT=
|
||||
|
||||
# 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. Default 0.
|
||||
# AUDIT_LOG_ENABLED=0
|
||||
# If the ip should be added in the log or not. Default 0
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Feature request
|
||||
description: "Suggest an idea for this project \U0001F680"
|
||||
type: feature
|
||||
projects: "formbricks/21"
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/task.yml
vendored
11
.github/ISSUE_TEMPLATE/task.yml
vendored
@@ -1,11 +0,0 @@
|
||||
name: Task (internal)
|
||||
description: "Template for creating a task. Used by the Formbricks Team only \U0001f4e5"
|
||||
type: task
|
||||
body:
|
||||
- type: textarea
|
||||
id: task-summary
|
||||
attributes:
|
||||
label: Task description
|
||||
description: A clear detailed-rich description of the task.
|
||||
validations:
|
||||
required: true
|
||||
2
.github/actions/cache-build-web/action.yml
vendored
2
.github/actions/cache-build-web/action.yml
vendored
@@ -49,7 +49,7 @@ runs:
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
121
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
Normal file
121
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
name: 'Upload Sentry Sourcemaps'
|
||||
description: 'Extract sourcemaps from Docker image and upload to Sentry'
|
||||
|
||||
inputs:
|
||||
docker_image:
|
||||
description: 'Docker image to extract sourcemaps from'
|
||||
required: true
|
||||
release_version:
|
||||
description: 'Sentry release version (e.g., v1.2.3)'
|
||||
required: true
|
||||
sentry_auth_token:
|
||||
description: 'Sentry authentication token'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate Sentry auth token
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "🔐 Validating Sentry authentication token..."
|
||||
|
||||
# Assign token to local variable for secure handling
|
||||
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
|
||||
|
||||
# Test the token by making a simple API call to Sentry
|
||||
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
|
||||
-H "Authorization: Bearer $SENTRY_TOKEN" \
|
||||
"https://sentry.io/api/0/organizations/formbricks/")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
|
||||
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
|
||||
if [ -f /tmp/sentry_response.json ]; then
|
||||
echo "Response body:"
|
||||
cat /tmp/sentry_response.json
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Sentry auth token validated successfully"
|
||||
|
||||
# Clean up temp file
|
||||
rm -f /tmp/sentry_response.json
|
||||
|
||||
- name: Extract sourcemaps from Docker image
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
|
||||
|
||||
# Create temporary container from the image and capture its ID
|
||||
echo "Creating temporary container..."
|
||||
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
|
||||
echo "Container created with ID: $CONTAINER_ID"
|
||||
|
||||
# Set up cleanup function to ensure container is removed on script exit
|
||||
cleanup_container() {
|
||||
# Capture the current exit code to preserve it
|
||||
local original_exit_code=$?
|
||||
|
||||
echo "🧹 Cleaning up Docker container..."
|
||||
|
||||
# Remove the container if it exists (ignore errors if already removed)
|
||||
if [ -n "$CONTAINER_ID" ]; then
|
||||
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
|
||||
echo "Container $CONTAINER_ID removed"
|
||||
fi
|
||||
|
||||
# Exit with the original exit code to preserve script success/failure status
|
||||
exit $original_exit_code
|
||||
}
|
||||
|
||||
# Register cleanup function to run on script exit (success or failure)
|
||||
trap cleanup_container EXIT
|
||||
|
||||
# Extract .next directory containing sourcemaps
|
||||
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
|
||||
|
||||
# Verify sourcemaps exist
|
||||
if [ ! -d "./extracted-next/static/chunks" ]; then
|
||||
echo "❌ Error: .next/static/chunks directory not found in Docker image"
|
||||
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
|
||||
echo "✅ Found $sourcemap_count sourcemap files"
|
||||
|
||||
if [ "$sourcemap_count" -eq 0 ]; then
|
||||
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Sentry release and upload sourcemaps
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
|
||||
SENTRY_ORG: formbricks
|
||||
SENTRY_PROJECT: formbricks-cloud
|
||||
with:
|
||||
environment: production
|
||||
version: ${{ inputs.release_version }}
|
||||
sourcemaps: './extracted-next/'
|
||||
|
||||
- name: Clean up extracted files
|
||||
shell: bash
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Clean up extracted files
|
||||
rm -rf ./extracted-next
|
||||
echo "🧹 Cleaned up extracted files"
|
||||
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@@ -11,7 +11,7 @@ When generating test files inside the "/app/web" path, follow these rules:
|
||||
- Follow the same test pattern used for other files in the package where the file is located
|
||||
- All imports should be at the top of the file, not inside individual tests
|
||||
- For mocking inside "test" blocks use "vi.mocked"
|
||||
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
|
||||
- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react"
|
||||
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
||||
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
|
||||
- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase.
|
||||
@@ -28,4 +28,5 @@ afterEach(() => {
|
||||
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
|
||||
- For click events, import userEvent from "@testing-library/user-event"
|
||||
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
|
||||
- You don't need to mock @tolgee/react
|
||||
- You don't need to mock @tolgee/react
|
||||
- Use "import "@testing-library/jest-dom/vitest";"
|
||||
84
.github/dependabot.yml
vendored
84
.github/dependabot.yml
vendored
@@ -1,84 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
|
||||
directory: "/" # Root package.json
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
versioning-strategy: increase
|
||||
|
||||
# Apps directory packages
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo-react-native"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/storybook"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Packages directory
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/database"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/lib"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/types"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-eslint"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-prettier"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-typescript"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/js-core"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/surveys"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/logger"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
5
.github/workflows/chromatic.yml
vendored
5
.github/workflows/chromatic.yml
vendored
@@ -10,6 +10,11 @@ jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
|
||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0
|
||||
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0
|
||||
|
||||
64
.github/workflows/deploy-formbricks-cloud.yml
vendored
64
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -4,16 +4,16 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Docker image to release'
|
||||
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
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Prod
|
||||
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
|
||||
if: inputs.ENVIRONMENT == 'prod'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -75,6 +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"
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
@@ -84,13 +85,14 @@ jobs:
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Stage
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
|
||||
if: inputs.ENVIRONMENT == 'stage'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helmfile-version: "v1.0.0"
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
@@ -98,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
|
||||
|
||||
40
.github/workflows/e2e.yml
vendored
40
.github/workflows/e2e.yml
vendored
@@ -11,6 +11,8 @@ on:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
required: false
|
||||
ENTERPRISE_LICENSE_KEY:
|
||||
required: true
|
||||
# Add other secrets if necessary
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -23,7 +25,6 @@ permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -44,19 +45,31 @@ 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
|
||||
with:
|
||||
egress-policy: audit
|
||||
egress-policy: allow
|
||||
allowed-endpoints: |
|
||||
ee.formbricks.com:443
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
@@ -75,7 +88,8 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
@@ -89,8 +103,24 @@ jobs:
|
||||
# pnpm prisma migrate deploy
|
||||
pnpm db:migrate:dev
|
||||
|
||||
- name: Run Rate Limiter Load Tests
|
||||
run: |
|
||||
echo "Running rate limiter load tests with Redis/Valkey..."
|
||||
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
|
||||
shell: bash
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
if [ -z "$LICENSE_KEY" ]; then
|
||||
echo "::error::ENTERPRISE_LICENSE_KEY in .env is empty. Please check your secret configuration."
|
||||
exit 1
|
||||
fi
|
||||
echo "License key length: ${#LICENSE_KEY}"
|
||||
|
||||
- name: Run App
|
||||
run: |
|
||||
echo "Starting app with enterprise license..."
|
||||
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
|
||||
sleep 10 # Optional: gives some buffer for the app to start
|
||||
for attempt in {1..10}; do
|
||||
|
||||
24
.github/workflows/formbricks-release.yml
vendored
24
.github/workflows/formbricks-release.yml
vendored
@@ -30,5 +30,27 @@ jobs:
|
||||
- docker-build
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: ${{ needs.docker-build.outputs.VERSION }}
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- docker-build
|
||||
- deploy-formbricks-cloud
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Upload Sentry Sourcemaps
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
27
.github/workflows/labeler.yml
vendored
27
.github/workflows/labeler.yml
vendored
@@ -1,27 +0,0 @@
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
name: Pull Request Labeler
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481
|
||||
sync-labels: ""
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -10,8 +10,6 @@ permissions:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
5
.github/workflows/release-docker-github.yml
vendored
5
.github/workflows/release-docker-github.yml
vendored
@@ -20,18 +20,15 @@ env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
outputs:
|
||||
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
|
||||
|
||||
2
.github/workflows/release-helm-chart.yml
vendored
2
.github/workflows/release-helm-chart.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Helm chart to release'
|
||||
description: "The version of the Helm chart to release"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
||||
4
.github/workflows/semantic-pull-requests.yml
vendored
4
.github/workflows/semantic-pull-requests.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
revert
|
||||
ossgg
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
# When the previous steps fails, the workflow would stop. By adding this
|
||||
# condition you can continue the execution with the populated error message.
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
|
||||
5
.github/workflows/sonarqube.yml
vendored
5
.github/workflows/sonarqube.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
@@ -43,12 +43,13 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
pnpm test:coverage
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
|
||||
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
18
.github/workflows/terraform-plan-and-apply.yml
vendored
18
.github/workflows/terraform-plan-and-apply.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: 'Terraform'
|
||||
name: "Terraform"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# TODO: enable it back when migration is completed.
|
||||
# TODO: enable it back when migration is completed.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -14,14 +14,13 @@ on:
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
@@ -41,7 +40,7 @@ jobs:
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
@@ -71,7 +70,7 @@ jobs:
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Post PR comment
|
||||
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
||||
uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1
|
||||
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
@@ -83,4 +82,3 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: terraform apply .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -41,6 +41,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
46
.github/workflows/upload-sentry-sourcemaps.yml
vendored
Normal file
46
.github/workflows/upload-sentry-sourcemaps.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Upload Sentry Sourcemaps (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
docker_image:
|
||||
description: "Docker image to extract sourcemaps from"
|
||||
required: true
|
||||
type: string
|
||||
release_version:
|
||||
description: "Release version (e.g., v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
tag_version:
|
||||
description: "Docker image tag (leave empty to use release_version)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload-sourcemaps:
|
||||
name: Upload Sourcemaps to Sentry
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set Docker Image
|
||||
run: |
|
||||
if [ -n "${{ inputs.tag_version }}" ]; then
|
||||
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Upload Sourcemaps to Sentry
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
with:
|
||||
docker_image: ${{ env.DOCKER_IMAGE }}
|
||||
release_version: ${{ inputs.release_version }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -73,3 +73,4 @@ infra/terraform/.terraform/
|
||||
/.idea/
|
||||
/*.iml
|
||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||
.cursorrules
|
||||
|
||||
@@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
|
||||
|
||||
## 🛠 Crafting Pull Requests
|
||||
|
||||
Ready to dive into the code and make a real impact? Here's your path:
|
||||
|
||||
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
|
||||
|
||||
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
|
||||
|
||||
1. **Tweak and Transform:** Work your coding magic and apply your changes.
|
||||
|
||||
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
|
||||
|
||||
Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
|
||||
For the time being, we don't have the capacity to properly facilitate community contributions. It's a lot of engineering attention often spent on issues which don't follow our prioritization, so we've decided to only facilitate community code contributions in rare exceptions in the coming months.
|
||||
|
||||
## 🚀 Aspiring Features
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ Here are a few options:
|
||||
|
||||
- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap.
|
||||
|
||||
Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.
|
||||
- Note: For the time being, we can only facilitate code contributions as an exception.
|
||||
|
||||
## All Thanks To Our Contributors
|
||||
|
||||
|
||||
@@ -14,10 +14,9 @@ const config: StorybookConfig = {
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-onboarding"),
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
getAbsolutePath("@storybook/addon-essentials"),
|
||||
getAbsolutePath("@chromatic-com/storybook"),
|
||||
getAbsolutePath("@storybook/addon-interactions"),
|
||||
getAbsolutePath("@storybook/addon-a11y"),
|
||||
getAbsolutePath("@storybook/addon-docs"),
|
||||
],
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/react-vite"),
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { Preview } from "@storybook/react";
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TolgeeProvider } from "@tolgee/react";
|
||||
import React from "react";
|
||||
import "../../web/modules/ui/globals.css";
|
||||
import { TolgeeBase } from "../../web/tolgee/shared";
|
||||
|
||||
// Create a Storybook-specific Tolgee decorator
|
||||
const withTolgee = (Story: any) => {
|
||||
const tolgee = TolgeeBase().init({
|
||||
tagNewKeys: [], // No branch tagging in Storybook
|
||||
});
|
||||
|
||||
return React.createElement(
|
||||
TolgeeProvider,
|
||||
{ tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } },
|
||||
React.createElement(Story)
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
@@ -10,6 +26,7 @@ const preview: Preview = {
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [withTolgee],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
||||
@@ -11,28 +11,22 @@
|
||||
"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",
|
||||
"@storybook/addon-a11y": "8.6.12",
|
||||
"@storybook/addon-essentials": "8.6.12",
|
||||
"@storybook/addon-interactions": "8.6.12",
|
||||
"@storybook/addon-links": "8.6.12",
|
||||
"@storybook/addon-onboarding": "8.6.12",
|
||||
"@storybook/blocks": "8.6.12",
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||
"@typescript-eslint/parser": "8.31.1",
|
||||
"@chromatic-com/storybook": "^4.0.1",
|
||||
"@storybook/addon-a11y": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/react-vite": "9.0.15",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
||||
"@typescript-eslint/parser": "8.32.0",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"esbuild": "0.25.2",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"esbuild": "0.25.4",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.6.12",
|
||||
"vite": "6.3.5"
|
||||
"storybook": "9.0.15",
|
||||
"vite": "6.3.5",
|
||||
"@storybook/addon-docs": "9.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta } from "@storybook/blocks";
|
||||
import { Meta } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import Accessibility from "./assets/accessibility.png";
|
||||
import AddonLibrary from "./assets/addon-library.png";
|
||||
|
||||
@@ -18,27 +18,16 @@ FROM node:22-alpine3.21 AS base
|
||||
FROM base AS installer
|
||||
|
||||
# Enable corepack and prepare pnpm
|
||||
RUN npm install -g corepack@latest
|
||||
RUN npm install --ignore-scripts -g corepack@latest
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@9.15.9 --activate
|
||||
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
|
||||
# BuildKit secret handling without hardcoded fallback values
|
||||
# This approach relies entirely on secrets passed from GitHub Actions
|
||||
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
|
||||
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
|
||||
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
|
||||
echo 'else' >> /tmp/read-secrets.sh && \
|
||||
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
|
||||
echo 'fi' >> /tmp/read-secrets.sh && \
|
||||
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
|
||||
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
|
||||
echo 'else' >> /tmp/read-secrets.sh && \
|
||||
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
|
||||
echo 'fi' >> /tmp/read-secrets.sh && \
|
||||
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
|
||||
chmod +x /tmp/read-secrets.sh
|
||||
# Copy the secrets handling script
|
||||
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
|
||||
RUN chmod +x /tmp/read-secrets.sh
|
||||
|
||||
# Increase Node.js memory limit as a regular build argument
|
||||
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||
@@ -59,7 +48,10 @@ COPY . .
|
||||
RUN touch apps/web/.env
|
||||
|
||||
# Install the dependencies
|
||||
RUN pnpm install
|
||||
RUN pnpm install --ignore-scripts
|
||||
|
||||
# Build the database package first
|
||||
RUN pnpm build --filter=@formbricks/database
|
||||
|
||||
# Build the project using our secret reader script
|
||||
# This mounts the secrets only during this build step without storing them in layers
|
||||
@@ -75,7 +67,7 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
|
||||
#
|
||||
FROM base AS runner
|
||||
|
||||
RUN npm install -g corepack@latest
|
||||
RUN npm install --ignore-scripts -g corepack@latest
|
||||
RUN corepack enable
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
@@ -105,20 +97,8 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
|
||||
|
||||
COPY --from=installer /app/packages/database/migration ./packages/database/migration
|
||||
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
|
||||
|
||||
COPY --from=installer /app/packages/database/src ./packages/database/src
|
||||
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
|
||||
|
||||
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
|
||||
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
|
||||
|
||||
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
|
||||
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
|
||||
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
||||
|
||||
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
||||
@@ -141,12 +121,15 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
RUN npm install -g tsx typescript prisma pino-pretty
|
||||
RUN npm install -g prisma
|
||||
|
||||
# Create a startup script to handle the conditional logic
|
||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||
RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh
|
||||
|
||||
EXPOSE 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV NODE_ENV="production"
|
||||
# USER nextjs
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
# Prepare volume for uploads
|
||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
||||
@@ -156,12 +139,4 @@ VOLUME /home/nextjs/apps/web/uploads/
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
|
||||
echo "Starting cron jobs..."; \
|
||||
supercronic -quiet /app/docker/cronjobs & \
|
||||
else \
|
||||
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
|
||||
fi; \
|
||||
(cd packages/database && npm run db:migrate:deploy) && \
|
||||
(cd packages/database && npm run db:create-saml-database:deploy) && \
|
||||
exec node apps/web/server.js
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
@@ -0,0 +1,79 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ConnectWithFormbricks } from "./ConnectWithFormbricks";
|
||||
|
||||
// Mocks before import
|
||||
const pushMock = vi.fn();
|
||||
const refreshMock = vi.fn();
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock, refresh: refreshMock })) }));
|
||||
vi.mock("./OnboardingSetupInstructions", () => ({
|
||||
OnboardingSetupInstructions: () => <div data-testid="instructions" />,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConnectWithFormbricks", () => {
|
||||
const environment = { id: "env1" } as any;
|
||||
const webAppUrl = "http://app";
|
||||
const channel = {} as any;
|
||||
|
||||
test("renders waiting state when widgetSetupCompleted is false", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("instructions")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success state when widgetSetupCompleted is true", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.connect.congrats")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.connect.connection_successful_message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking finish button navigates to surveys", async () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "environments.connect.finish_onboarding" });
|
||||
await userEvent.click(button);
|
||||
expect(pushMock).toHaveBeenCalledWith(`/environments/${environment.id}/surveys`);
|
||||
});
|
||||
|
||||
test("refresh is called on visibilitychange to visible", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(refreshMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,14 +12,14 @@ import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||
|
||||
interface ConnectWithFormbricksProps {
|
||||
environment: TEnvironment;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
widgetSetupCompleted: boolean;
|
||||
channel: TProjectConfigChannel;
|
||||
}
|
||||
|
||||
export const ConnectWithFormbricks = ({
|
||||
environment,
|
||||
webAppUrl,
|
||||
publicDomain,
|
||||
widgetSetupCompleted,
|
||||
channel,
|
||||
}: ConnectWithFormbricksProps) => {
|
||||
@@ -49,7 +49,7 @@ export const ConnectWithFormbricks = ({
|
||||
<div className="flex w-1/2 flex-col space-y-4">
|
||||
<OnboardingSetupInstructions
|
||||
environmentId={environment.id}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={publicDomain}
|
||||
channel={channel}
|
||||
widgetSetupCompleted={widgetSetupCompleted}
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("OnboardingSetupInstructions", () => {
|
||||
// Provide some default props for testing
|
||||
const defaultProps = {
|
||||
environmentId: "env-123",
|
||||
webAppUrl: "https://example.com",
|
||||
publicDomain: "https://example.com",
|
||||
channel: "app" as const, // Assuming channel is either "app" or "website"
|
||||
widgetSetupCompleted: false,
|
||||
};
|
||||
|
||||
@@ -18,14 +18,14 @@ const tabs = [
|
||||
|
||||
interface OnboardingSetupInstructionsProps {
|
||||
environmentId: string;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
channel: TProjectConfigChannel;
|
||||
widgetSetupCompleted: boolean;
|
||||
}
|
||||
|
||||
export const OnboardingSetupInstructions = ({
|
||||
environmentId,
|
||||
webAppUrl,
|
||||
publicDomain,
|
||||
channel,
|
||||
widgetSetupCompleted,
|
||||
}: OnboardingSetupInstructionsProps) => {
|
||||
@@ -34,7 +34,7 @@ export const OnboardingSetupInstructions = ({
|
||||
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${webAppUrl}";
|
||||
var appUrl = "${publicDomain}";
|
||||
var environmentId = "${environmentId}";
|
||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||
</script>
|
||||
@@ -44,7 +44,7 @@ export const OnboardingSetupInstructions = ({
|
||||
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${webAppUrl}";
|
||||
var appUrl = "${publicDomain}";
|
||||
var environmentId = "${environmentId}";
|
||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||
</script>
|
||||
@@ -57,7 +57,7 @@ export const OnboardingSetupInstructions = ({
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
environmentId: "${environmentId}",
|
||||
appUrl: "${webAppUrl}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const OnboardingSetupInstructions = ({
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
environmentId: "${environmentId}",
|
||||
appUrl: "${webAppUrl}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
@@ -30,6 +30,8 @@ const Page = async (props: ConnectPageProps) => {
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
||||
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
|
||||
@@ -39,12 +41,12 @@ const Page = async (props: ConnectPageProps) => {
|
||||
</div>
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
publicDomain={publicDomain}
|
||||
widgetSetupCompleted={environment.appSetupCompleted}
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import OnboardingLayout from "./layout";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
PUBLIC_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
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: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("OnboardingLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if session is missing", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
|
||||
await OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div>Test Content</div>,
|
||||
});
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws AuthorizationError if user lacks access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div>Test Content</div>,
|
||||
})
|
||||
).rejects.toThrow("User is not authorized to access this environment");
|
||||
});
|
||||
|
||||
test("renders children if user has access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
|
||||
const result = await OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div data-testid="child">Test Content</div>,
|
||||
});
|
||||
|
||||
render(result);
|
||||
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ const OnboardingLayout = async (props) => {
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!isAuthorized) {
|
||||
throw AuthorizationError;
|
||||
throw new AuthorizationError("User is not authorized to access this environment");
|
||||
}
|
||||
|
||||
return <div className="flex-1 bg-slate-50">{children}</div>;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { XMTemplateList } from "./XMTemplateList";
|
||||
|
||||
// Prepare push mock and module mocks before importing component
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock })) }));
|
||||
vi.mock("react-hot-toast", () => ({ default: { error: vi.fn() } }));
|
||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates", () => ({
|
||||
getXMTemplates: (t: any) => [
|
||||
{ id: 1, name: "tmpl1" },
|
||||
{ id: 2, name: "tmpl2" },
|
||||
],
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils", () => ({
|
||||
replacePresetPlaceholders: (template: any, project: any) => ({ ...template, projectId: project.id }),
|
||||
}));
|
||||
vi.mock("@/modules/survey/components/template-list/actions", () => ({ createSurveyAction: vi.fn() }));
|
||||
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
||||
<div>
|
||||
{options.map((opt, idx) => (
|
||||
<button key={idx} data-testid={`option-${idx}`} onClick={opt.onClick}>
|
||||
{opt.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Reset mocks between tests
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("XMTemplateList component", () => {
|
||||
const project = { id: "proj1" } as any;
|
||||
const user = { id: "user1" } as any;
|
||||
const environmentId = "env1";
|
||||
|
||||
test("creates survey and navigates on success", async () => {
|
||||
// Mock successful survey creation
|
||||
vi.mocked(createSurveyAction).mockResolvedValue({ data: { id: "survey1" } } as any);
|
||||
|
||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
||||
|
||||
const option0 = screen.getByTestId("option-0");
|
||||
await userEvent.click(option0);
|
||||
|
||||
expect(createSurveyAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
surveyBody: expect.objectContaining({ id: 1, projectId: "proj1", type: "link", createdBy: "user1" }),
|
||||
});
|
||||
expect(pushMock).toHaveBeenCalledWith(`/environments/${environmentId}/surveys/survey1/edit?mode=cx`);
|
||||
});
|
||||
|
||||
test("shows error toast on failure", async () => {
|
||||
// Mock failed survey creation
|
||||
vi.mocked(createSurveyAction).mockResolvedValue({ error: "err" } as any);
|
||||
|
||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
||||
|
||||
const option1 = screen.getByTestId("option-1");
|
||||
await userEvent.click(option1);
|
||||
|
||||
expect(createSurveyAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("formatted-error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { replacePresetPlaceholders } from "./utils";
|
||||
|
||||
// Mock data
|
||||
const mockProject: TProject = {
|
||||
id: "project1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Project",
|
||||
organizationId: "org1",
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: { light: "#FFFFFF" },
|
||||
},
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
config: {
|
||||
channel: "link" as const,
|
||||
industry: "eCommerce" as "eCommerce" | "saas" | "other" | null,
|
||||
},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
languages: [],
|
||||
logo: null,
|
||||
};
|
||||
const mockTemplate: TXMTemplate = {
|
||||
name: "$[projectName] Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
inputType: "text",
|
||||
type: "email" as any,
|
||||
headline: { default: "$[projectName] Question" },
|
||||
required: false,
|
||||
charLimit: { enabled: true, min: 400, max: 1000 },
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{
|
||||
id: "e1",
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you for completing the survey!" },
|
||||
},
|
||||
],
|
||||
styling: {
|
||||
brandColor: { light: "#0000FF" },
|
||||
questionColor: { light: "#00FF00" },
|
||||
inputColor: { light: "#FF0000" },
|
||||
},
|
||||
};
|
||||
|
||||
describe("replacePresetPlaceholders", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in template name", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.name).toBe("Test Project Survey");
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in question headline", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.questions[0].headline.default).toBe("Test Project Question");
|
||||
});
|
||||
|
||||
test("returns a new object without mutating the original template", () => {
|
||||
const originalTemplate = structuredClone(mockTemplate);
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result).not.toBe(mockTemplate);
|
||||
expect(mockTemplate).toEqual(originalTemplate);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/preact";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { getXMSurveyDefault, getXMTemplates } from "./xm-templates";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("xm-templates", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("getXMSurveyDefault returns default survey template", () => {
|
||||
const tMock = vi.fn((key) => key) as TFnType;
|
||||
const result = getXMSurveyDefault(tMock);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: "",
|
||||
endings: expect.any(Array),
|
||||
questions: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
});
|
||||
expect(result.endings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("getXMTemplates returns all templates", () => {
|
||||
const tMock = vi.fn((key) => key) as TFnType;
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result[0].name).toBe("templates.nps_survey_name");
|
||||
expect(result[1].name).toBe("templates.star_rating_survey_name");
|
||||
expect(result[2].name).toBe("templates.csat_survey_name");
|
||||
expect(result[3].name).toBe("templates.cess_survey_name");
|
||||
expect(result[4].name).toBe("templates.smileys_survey_name");
|
||||
expect(result[5].name).toBe("templates.enps_survey_name");
|
||||
});
|
||||
|
||||
test("getXMTemplates handles errors gracefully", async () => {
|
||||
const tMock = vi.fn(() => {
|
||||
throw new Error("Test error");
|
||||
}) as TFnType;
|
||||
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
// Dynamically import the mocked logger
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Unable to load XM templates, returning empty array"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||
{projects.length >= 2 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
44
apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts
Normal file
44
apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getTeamsByOrganizationId } from "./onboarding";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
team: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getTeamsByOrganizationId", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns mapped teams", async () => {
|
||||
const mockTeams = [
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
];
|
||||
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
|
||||
const result = await getTeamsByOrganizationId("org1");
|
||||
expect(result).toEqual([
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||
);
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error on unknown error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("fail"));
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow("fail");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
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 }),
|
||||
}));
|
||||
|
||||
// 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 }) => (
|
||||
<div data-testid={open ? "modal-open" : "modal-closed"} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
ProfileAvatar: ({ userId }: { userId: string }) => <div data-testid="avatar">{userId}</div>,
|
||||
}));
|
||||
|
||||
// Ensure mocks are reset between tests
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("LandingSidebar component", () => {
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
|
||||
const organization = { id: "o1", name: "orgOne" } as any;
|
||||
const organizations = [
|
||||
{ id: "o2", name: "betaOrg" },
|
||||
{ id: "o1", name: "alphaOrg" },
|
||||
] as any;
|
||||
|
||||
test("renders logo, avatar, and initial modal closed", () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
|
||||
// Formbricks logo
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
// Profile avatar
|
||||
expect(screen.getByTestId("avatar")).toHaveTextContent("u1");
|
||||
// CreateOrganizationModal should be closed initially
|
||||
expect(screen.getByTestId("modal-closed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking logout triggers signOut", async () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open user dropdown by clicking on avatar trigger
|
||||
const trigger = screen.getByTestId("avatar").parentElement;
|
||||
if (trigger) await userEvent.click(trigger);
|
||||
|
||||
// Click logout menu item
|
||||
const logoutItem = await screen.findByText("common.logout");
|
||||
await userEvent.click(logoutItem);
|
||||
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "o1",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import {
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -44,6 +44,7 @@ export const LandingSidebar = ({
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
||||
|
||||
const { t } = useTranslate();
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -123,7 +124,14 @@ export const LandingSidebar = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOut({ callbackUrl: "/auth/login" });
|
||||
await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/preact";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import LandingLayout from "./layout";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
PUBLIC_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
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: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/service");
|
||||
vi.mock("@/lib/membership/service");
|
||||
vi.mock("@/lib/project/service");
|
||||
vi.mock("next-auth");
|
||||
vi.mock("next/navigation");
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("LandingLayout", () => {
|
||||
test("redirects to login if no session exists", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("returns notFound if no membership is found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(notFound)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects to production environment if available", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org-123",
|
||||
userId: "user-123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
vi.mocked(getUserProjects).mockResolvedValue([
|
||||
{
|
||||
id: "proj-123",
|
||||
organizationId: "org-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-02"),
|
||||
name: "Project 1",
|
||||
styling: { allowStyleOverwrite: true },
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
} as any,
|
||||
]);
|
||||
vi.mocked(getEnvironments).mockResolvedValue([
|
||||
{
|
||||
id: "env-123",
|
||||
type: "production",
|
||||
projectId: "proj-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-02"),
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/");
|
||||
});
|
||||
|
||||
test("renders children if no projects or production environment exist", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org-123",
|
||||
userId: "user-123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
const result = await LandingLayout(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
<>
|
||||
<div>Child Content</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
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: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
||||
}));
|
||||
vi.mock("@/modules/organization/lib/utils");
|
||||
vi.mock("@/lib/user/service");
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/tolgee/server");
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||
}));
|
||||
|
||||
// Mock the React cache function
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: (fn: any) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
|
||||
test("returns notFound if user does not exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: {},
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
expect(result).toBe("NOT_FOUND_STUB");
|
||||
});
|
||||
|
||||
test("renders header and sidebar for authenticated user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: { id: "org1" },
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||
typeof props === "string" ? props : props.key || ""
|
||||
);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const element = await Page({ params: { organizationId: "org1" } });
|
||||
render(element as React.ReactElement);
|
||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
|
||||
@@ -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: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
// Module mocks must be declared before importing the component
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn(() => "REDIRECT_STUB") }));
|
||||
vi.mock("@/modules/ui/components/header", () => ({
|
||||
Header: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
||||
<div data-testid="options">{options.map((o) => o.title).join(",")}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children }: { href: string; children: React.ReactNode }) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {} } as any);
|
||||
|
||||
const result = await Page({ params });
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
|
||||
test("renders header, options, and close button when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([{ id: 1 }] as any);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
// Header title and subtitle
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.channel.channel_select_title"
|
||||
);
|
||||
expect(
|
||||
screen.getByText("organizations.projects.new.channel.channel_select_subtitle")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Options container with correct titles
|
||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
||||
"organizations.projects.new.channel.link_and_email_surveys," +
|
||||
"organizations.projects.new.channel.in_product_surveys"
|
||||
);
|
||||
|
||||
// Close button link rendered when projects >=1
|
||||
const closeLink = screen.getByRole("link");
|
||||
expect(closeLink).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
test("does not render close button when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import OnboardingLayout from "./layout";
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-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_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getOrganizationProjectsCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("OnboardingLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if no session", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("returns not found if user is member or billing", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws error if organization is not found", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getOrganization).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
|
||||
test("redirects to home if project limit is reached", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
test("renders children when all conditions are met", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
const result = await OnboardingLayout(props);
|
||||
expect(result).toEqual(<>{props.children}</>);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
vi.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: any) => (
|
||||
<div data-testid="options">{options.map((o: any) => o.title).join(",")}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/header", () => ({ Header: ({ title }: any) => <h1>{title}</h1> }));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
describe("Mode Page", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
|
||||
test("redirects to login if no session user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
||||
await Page({ params });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("renders header and options without close link when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.mode.what_are_you_here_for"
|
||||
);
|
||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
||||
"organizations.projects.new.mode.formbricks_surveys," + "organizations.projects.new.mode.formbricks_cx"
|
||||
);
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders close link when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" } as any]);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/");
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ProjectSettings } from "./ProjectSettings";
|
||||
|
||||
// Mocks before imports
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("react-hot-toast", () => ({ toast: { error: vi.fn() } }));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions", () => ({ createProjectAction: vi.fn() }));
|
||||
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
|
||||
vi.mock("@/modules/ui/components/color-picker", () => ({
|
||||
ColorPicker: ({ color, onChange }: any) => (
|
||||
<button data-testid="color-picker" onClick={() => onChange("#000")}>
|
||||
{color}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
Input: ({ value, onChange, placeholder }: any) => (
|
||||
<input placeholder={placeholder} value={value} onChange={(e) => onChange((e.target as any).value)} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/multi-select", () => ({
|
||||
MultiSelect: ({ value, options, onChange }: any) => (
|
||||
<select
|
||||
data-testid="multi-select"
|
||||
multiple
|
||||
value={value}
|
||||
onChange={(e) => onChange(Array.from((e.target as any).selectedOptions).map((o: any) => o.value))}>
|
||||
{options.map((o: any) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/survey", () => ({
|
||||
SurveyInline: () => <div data-testid="survey-inline" />,
|
||||
}));
|
||||
vi.mock("@/lib/templates", () => ({ previewSurvey: () => ({}) }));
|
||||
vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({
|
||||
CreateTeamModal: ({ open }: any) => <div data-testid={open ? "team-modal-open" : "team-modal-closed"} />,
|
||||
}));
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("ProjectSettings component", () => {
|
||||
const baseProps = {
|
||||
organizationId: "org1",
|
||||
projectMode: "cx",
|
||||
industry: "ind",
|
||||
defaultBrandColor: "#fff",
|
||||
organizationTeams: [],
|
||||
canDoRoleManagement: false,
|
||||
userProjectsCount: 0,
|
||||
} as any;
|
||||
|
||||
const fillAndSubmit = async () => {
|
||||
const nameInput = screen.getByPlaceholderText("e.g. Formbricks");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "TestProject");
|
||||
const nextButton = screen.getByRole("button", { name: "common.next" });
|
||||
await userEvent.click(nextButton);
|
||||
};
|
||||
|
||||
test("successful createProject for link channel navigates to surveys and clears localStorage", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env123", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(createProjectAction).toHaveBeenCalledWith({
|
||||
organizationId: "org1",
|
||||
data: expect.objectContaining({ teamIds: [] }),
|
||||
});
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env123/surveys");
|
||||
expect(localStorage.getItem("FORMBRICKS_SURVEYS_FILTERS_KEY_LS")).toBeNull();
|
||||
});
|
||||
|
||||
test("successful createProject for app channel navigates to connect", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env456", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="app" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env456/connect");
|
||||
});
|
||||
|
||||
test("successful createProject for cx mode navigates to xm-templates when channel is neither link nor app", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env789", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="unknown" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env789/xm-templates");
|
||||
});
|
||||
|
||||
test("shows error toast on createProject error response", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({ error: "err" });
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(toast.error).toHaveBeenCalledWith("formatted-error");
|
||||
});
|
||||
|
||||
test("shows error toast on exception", async () => {
|
||||
(createProjectAction as any).mockImplementation(() => {
|
||||
throw new Error("fail");
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(toast.error).toHaveBeenCalledWith("organizations.projects.new.settings.project_creation_failed");
|
||||
});
|
||||
});
|
||||
@@ -225,7 +225,7 @@ export const ProjectSettings = ({
|
||||
alt="Logo"
|
||||
width={256}
|
||||
height={56}
|
||||
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
|
||||
// Mocks before component import
|
||||
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
vi.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/header", () => ({
|
||||
Header: ({ title, subtitle }: any) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings",
|
||||
() => ({
|
||||
ProjectSettings: (props: any) => <div data-testid="project-settings" data-mode={props.projectMode} />,
|
||||
})
|
||||
);
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ProjectSettingsPage", () => {
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
const searchParams = Promise.resolve({ channel: "link", industry: "other", mode: "cx" } as any);
|
||||
|
||||
test("redirects to login when no session user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
||||
await Page({ params, searchParams });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws when teams not found", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
|
||||
|
||||
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
||||
});
|
||||
|
||||
test("renders header, settings and close link when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
// Header
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.settings.project_settings_title"
|
||||
);
|
||||
// ProjectSettings stub receives mode prop
|
||||
expect(screen.getByTestId("project-settings")).toHaveAttribute("data-mode", "cx");
|
||||
// Close link for existing projects
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
test("renders without close link when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Home, Settings } from "lucide-react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OnboardingOptionsContainer } from "./OnboardingOptionsContainer";
|
||||
|
||||
describe("OnboardingOptionsContainer", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders options with links", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Test Option",
|
||||
description: "Test Description",
|
||||
icon: Home,
|
||||
href: "/test",
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Test Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with onClick handler", () => {
|
||||
const onClickMock = vi.fn();
|
||||
const options = [
|
||||
{
|
||||
title: "Click Option",
|
||||
description: "Click Description",
|
||||
icon: Home,
|
||||
onClick: onClickMock,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Click Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Click Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with iconText", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Icon Text Option",
|
||||
description: "Icon Text Description",
|
||||
icon: Home,
|
||||
iconText: "Custom Icon Text",
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Custom Icon Text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with loading state", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Loading Option",
|
||||
description: "Loading Description",
|
||||
icon: Home,
|
||||
isLoading: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Loading Option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders multiple options", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "First Option",
|
||||
description: "First Description",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: "Second Option",
|
||||
description: "Second Description",
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("First Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second Option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onClick handler when clicking an option", async () => {
|
||||
const onClickMock = vi.fn();
|
||||
const options = [
|
||||
{
|
||||
title: "Click Option",
|
||||
description: "Click Description",
|
||||
icon: Home,
|
||||
onClick: onClickMock,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
await userEvent.click(screen.getByText("Click Option"));
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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: undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Contact Page Re-export", () => {
|
||||
|
||||
@@ -4,7 +4,9 @@ import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import {
|
||||
getOrganizationProjectsLimit,
|
||||
getRoleManagementPermission,
|
||||
@@ -20,62 +22,69 @@ const ZCreateProjectAction = z.object({
|
||||
data: ZProjectUpdateInput,
|
||||
});
|
||||
|
||||
export const createProjectAction = authenticatedActionClient
|
||||
.schema(ZCreateProjectAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { user } = ctx;
|
||||
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"project",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const organizationId = parsedInput.organizationId;
|
||||
const organizationId = parsedInput.organizationId;
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZProjectUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await checkAuthorizationUpdated({
|
||||
userId: user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZProjectUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||
|
||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||
throw new OperationNotAllowedError("Organization project limit reached");
|
||||
}
|
||||
|
||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||
|
||||
if (!canDoRoleManagement) {
|
||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||
|
||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||
throw new OperationNotAllowedError("Organization project limit reached");
|
||||
}
|
||||
|
||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||
|
||||
if (!canDoRoleManagement) {
|
||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||
}
|
||||
}
|
||||
|
||||
const project = await createProject(parsedInput.organizationId, parsedInput.data);
|
||||
const updatedNotificationSettings = {
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...user.notificationSettings?.weeklySummary,
|
||||
[project.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = project.id;
|
||||
ctx.auditLoggingCtx.newObject = project;
|
||||
return project;
|
||||
}
|
||||
|
||||
const project = await createProject(parsedInput.organizationId, parsedInput.data);
|
||||
const updatedNotificationSettings = {
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...user.notificationSettings?.weeklySummary,
|
||||
[project.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
return project;
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -8,23 +8,40 @@ import { ActionDetailModal } from "./ActionDetailModal";
|
||||
// Import mocked components
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
|
||||
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
|
||||
<div data-testid="modal-with-tabs">
|
||||
<span data-testid="modal-label">{label}</span>
|
||||
<span data-testid="modal-description">{description}</span>
|
||||
<span data-testid="modal-open">{open.toString()}</span>
|
||||
<button onClick={() => setOpen(false)}>Close</button>
|
||||
{icon}
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.title}>
|
||||
<h2>{tab.title}</h2>
|
||||
{tab.children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
// Mock the Dialog components
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="dialog">
|
||||
{children}
|
||||
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-content">{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-header">{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<h2 data-testid="dialog-title">{children}</h2>
|
||||
),
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<p data-testid="dialog-description">{children}</p>
|
||||
),
|
||||
DialogBody: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-body">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./ActionActivityTab", () => ({
|
||||
@@ -44,6 +61,22 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useTranslate
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
const translations = {
|
||||
"common.activity": "Activity",
|
||||
"common.settings": "Settings",
|
||||
"common.no_code": "No Code",
|
||||
"common.action": "Action",
|
||||
"common.code": "Code",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
@@ -89,58 +122,68 @@ describe("ActionDetailModal", () => {
|
||||
vi.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
test("renders ModalWithTabs with correct props", () => {
|
||||
test("renders correctly when open", () => {
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action");
|
||||
expect(screen.getByTestId("code-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
// Only the first tab (Activity) should be active initially
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockedModalWithTabs).toHaveBeenCalled();
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
test("does not render when open is false", () => {
|
||||
render(<ActionDetailModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check basic props
|
||||
expect(props.open).toBe(true);
|
||||
expect(props.setOpen).toBe(mockSetOpen);
|
||||
expect(props.label).toBe(mockActionClass.name);
|
||||
expect(props.description).toBe(mockActionClass.description);
|
||||
test("switches tabs correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
// Check icon data-testid based on the mock for the default 'code' type
|
||||
expect(props.icon).toBeDefined();
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
|
||||
// Initially shows activity tab (first tab is active)
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
|
||||
// Check tabs structure
|
||||
expect(props.tabs).toHaveLength(2);
|
||||
expect(props.tabs[0].title).toBe("common.activity");
|
||||
expect(props.tabs[1].title).toBe("common.settings");
|
||||
// Click settings tab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
|
||||
// Check if the correct mocked components are used as children
|
||||
// Access the mocked functions directly
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
|
||||
// Now shows settings tab content
|
||||
expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
// Click activity tab again
|
||||
const activityTab = screen.getByText("Activity");
|
||||
await user.click(activityTab);
|
||||
|
||||
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
|
||||
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
|
||||
// Back to activity tab content
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check props passed to child components
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
|
||||
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
|
||||
expect(activityTabProps.isReadOnly).toBe(false);
|
||||
expect(activityTabProps.environment).toBe(mockEnvironment);
|
||||
expect(activityTabProps.actionClass).toBe(mockActionClass);
|
||||
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
|
||||
test("resets to first tab when modal is reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.actionClass).toBe(mockActionClass);
|
||||
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
|
||||
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
|
||||
expect(settingsTabProps.isReadOnly).toBe(false);
|
||||
// Switch to settings tab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
|
||||
|
||||
// Close modal
|
||||
rerender(<ActionDetailModal {...defaultProps} open={false} />);
|
||||
|
||||
// Reopen modal
|
||||
rerender(<ActionDetailModal {...defaultProps} open={true} />);
|
||||
|
||||
// Should be back to activity tab (first tab)
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correct icon based on action type", () => {
|
||||
@@ -148,33 +191,68 @@ describe("ActionDetailModal", () => {
|
||||
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
expect(screen.getByTestId("nocode-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expect the 'nocode-icon' based on the updated mock and action type
|
||||
expect(props.icon).toBeDefined();
|
||||
test("handles action without description", () => {
|
||||
const actionWithoutDescription = { ...mockActionClass, description: "" };
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
|
||||
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action");
|
||||
});
|
||||
|
||||
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
|
||||
test("passes correct props to ActionActivityTab", () => {
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
expect(mockedActionActivityTab).toHaveBeenCalledWith(
|
||||
{
|
||||
otherEnvActionClasses: mockOtherEnvActionClasses,
|
||||
otherEnvironment: mockOtherEnvironment,
|
||||
isReadOnly: false,
|
||||
environment: mockEnvironment,
|
||||
actionClass: mockActionClass,
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("passes correct props to ActionSettingsTab when tab is active", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
// ActionSettingsTab should not be called initially since first tab is active
|
||||
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
|
||||
expect(mockedActionSettingsTab).not.toHaveBeenCalled();
|
||||
|
||||
// Click the settings tab to activate ActionSettingsTab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
|
||||
// Now ActionSettingsTab should be called with correct props
|
||||
expect(mockedActionSettingsTab).toHaveBeenCalledWith(
|
||||
{
|
||||
actionClass: mockActionClass,
|
||||
actionClasses: mockActionClasses,
|
||||
setOpen: mockSetOpen,
|
||||
isReadOnly: false,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("passes isReadOnly prop correctly", () => {
|
||||
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
|
||||
// Access the mocked component directly
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.isReadOnly).toBe(true);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.isReadOnly).toBe(true);
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
expect(mockedActionActivityTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isReadOnly: true,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,16 @@ export const ActionDetailModal = ({
|
||||
},
|
||||
];
|
||||
|
||||
const typeDescription = () => {
|
||||
if (actionClass.description) return actionClass.description;
|
||||
else
|
||||
return (
|
||||
(actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) +
|
||||
" " +
|
||||
t("common.action").toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalWithTabs
|
||||
@@ -67,7 +77,7 @@ export const ActionDetailModal = ({
|
||||
tabs={tabs}
|
||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
label={actionClass.name}
|
||||
description={actionClass.description || ""}
|
||||
description={typeDescription()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,22 +11,21 @@ export const ActionClassDataRow = ({
|
||||
locale: TUserLocale;
|
||||
}) => {
|
||||
return (
|
||||
<div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
|
||||
<div className="m-2 grid grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-start py-3 pl-6 text-sm">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="mt-1 h-5 w-5 flex-shrink-0 text-slate-500">
|
||||
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-medium text-slate-900">{actionClass.name}</div>
|
||||
<div className="text-xs text-slate-400">{actionClass.description}</div>
|
||||
<div className="text-left">
|
||||
<div className="break-words font-medium text-slate-900">{actionClass.name}</div>
|
||||
<div className="break-words text-xs text-slate-400">{actionClass.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -210,14 +210,13 @@ export const ActionSettingsTab = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
||||
<div>
|
||||
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!isReadOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setOpenDeleteDialog(true)}
|
||||
className="mr-3"
|
||||
id="deleteActionModalTrigger">
|
||||
<TrashIcon />
|
||||
{t("common.delete")}
|
||||
|
||||
@@ -22,14 +22,29 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, ...props }: any) =>
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal" {...props}>
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
|
||||
DialogTitle: ({ children, className }: any) => (
|
||||
<h2 data-testid="dialog-title" className={className}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-description">{children}</div>
|
||||
),
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
@@ -70,17 +85,21 @@ describe("AddActionModal", () => {
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens the modal when the 'Add Action' button is clicked", async () => {
|
||||
test("opens the dialog when the 'Add Action' button is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -108,35 +127,35 @@ describe("AddActionModal", () => {
|
||||
expect(props.setActionClasses).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("closes the modal when the close button (simulated) is clicked", async () => {
|
||||
test("closes the dialog when the close button (simulated) is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked Modal's close button
|
||||
const closeModalButton = screen.getByText("Close Modal");
|
||||
await userEvent.click(closeModalButton);
|
||||
// Simulate closing via the mocked Dialog's close button
|
||||
const closeDialogButton = screen.getByText("Close Dialog");
|
||||
await userEvent.click(closeDialogButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
|
||||
test("closes the dialog when setOpen is called from CreateNewActionTab", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked CreateNewActionTab's button
|
||||
const closeFromTabButton = screen.getByText("Close from Tab");
|
||||
await userEvent.click(closeFromTabButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -26,36 +33,26 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
|
||||
{t("common.add_action")}
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<MousePointerClickIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.actions.track_new_user_action")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<MousePointerClickIcon />
|
||||
<DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ const Loading = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
@@ -49,7 +48,6 @@ vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||
@@ -176,7 +174,6 @@ describe("EnvironmentLayout", () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||
mockIsDevelopment = false;
|
||||
@@ -189,13 +186,19 @@ describe("EnvironmentLayout", () => {
|
||||
});
|
||||
|
||||
test("renders correctly with default props", async () => {
|
||||
// Ensure the default mockLicense has isPendingDowngrade: false and active: false
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
...mockLicense,
|
||||
isPendingDowngrade: false,
|
||||
active: false,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -203,20 +206,31 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Child Content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); // This should now pass
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DevEnvironmentBanner in development environment", async () => {
|
||||
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
||||
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
||||
mockIsDevelopment = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -224,13 +238,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -238,17 +263,21 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
|
||||
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
});
|
||||
|
||||
test("renders PendingDowngradeBanner when pending downgrade", async () => {
|
||||
// Ensure the license mock reflects the condition needed for the banner
|
||||
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense);
|
||||
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -256,12 +285,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.user_not_found"
|
||||
);
|
||||
@@ -269,6 +310,19 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.organization_not_found"
|
||||
);
|
||||
@@ -276,13 +330,39 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if environment not found", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.environment_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if projects, environments or organizations not found", async () => {
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"environments.projects_environments_organizations_not_found"
|
||||
);
|
||||
@@ -291,6 +371,19 @@ describe("EnvironmentLayout", () => {
|
||||
test("throws error if member has no project permission", async () => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.project_permission_not_found"
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
@@ -100,6 +101,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
isPendingDowngrade={isPendingDowngrade ?? false}
|
||||
active={active}
|
||||
environmentId={environment.id}
|
||||
locale={user.locale}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -10,6 +10,17 @@ import { TUser } from "@formbricks/types/user";
|
||||
import { getLatestStableFbReleaseAction } from "../actions/actions";
|
||||
import { MainNavigation } from "./MainNavigation";
|
||||
|
||||
// Mock constants that this test needs
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
// Mock server actions that this test needs
|
||||
vi.mock("@/modules/auth/actions/sign-out", () => ({
|
||||
logSignOutAction: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||||
@@ -18,6 +29,9 @@ vi.mock("next/navigation", () => ({
|
||||
vi.mock("next-auth/react", () => ({
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
||||
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
||||
getLatestStableFbReleaseAction: vi.fn(),
|
||||
}));
|
||||
@@ -203,7 +217,11 @@ describe("MainNavigation", () => {
|
||||
});
|
||||
|
||||
test("renders user dropdown and handles logout", async () => {
|
||||
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
|
||||
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
|
||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||
|
||||
// Set up localStorage spy on the mocked localStorage
|
||||
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
// Find the avatar and get its parent div which acts as the trigger
|
||||
@@ -224,7 +242,15 @@ describe("MainNavigation", () => {
|
||||
const logoutButton = screen.getByText("common.logout");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "org1",
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
@@ -90,6 +90,7 @@ export const MainNavigation = ({
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const project = projects.find((project) => project.id === environment.projectId);
|
||||
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
@@ -109,7 +110,7 @@ export const MainNavigation = ({
|
||||
|
||||
useEffect(() => {
|
||||
const toggleTextOpacity = () => {
|
||||
setIsTextVisible(isCollapsed ? true : false);
|
||||
setIsTextVisible(isCollapsed);
|
||||
};
|
||||
const timeoutId = setTimeout(toggleTextOpacity, 150);
|
||||
return () => clearTimeout(timeoutId);
|
||||
@@ -170,7 +171,7 @@ export const MainNavigation = ({
|
||||
name: t("common.actions"),
|
||||
href: `/environments/${environment.id}/actions`,
|
||||
icon: MousePointerClick,
|
||||
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
|
||||
isActive: pathname?.includes("/actions"),
|
||||
},
|
||||
{
|
||||
name: t("common.integrations"),
|
||||
@@ -264,7 +265,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
@@ -389,8 +390,15 @@ export const MainNavigation = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
|
||||
router.push(route.url);
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
<currentStatus.icon />
|
||||
</div>
|
||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||
{status === "notImplemented" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { EnvironmentContextWrapper, useEnvironment } from "./environment-context";
|
||||
|
||||
// Mock environment data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "test-project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
// Mock project data
|
||||
const mockProject = {
|
||||
id: "test-project-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "test-org-id",
|
||||
config: {
|
||||
channel: "app",
|
||||
industry: "saas",
|
||||
},
|
||||
linkSurveyBranding: true,
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: {
|
||||
light: "#ffffff",
|
||||
dark: "#000000",
|
||||
},
|
||||
questionColor: {
|
||||
light: "#000000",
|
||||
dark: "#ffffff",
|
||||
},
|
||||
inputColor: {
|
||||
light: "#000000",
|
||||
dark: "#ffffff",
|
||||
},
|
||||
inputBorderColor: {
|
||||
light: "#cccccc",
|
||||
dark: "#444444",
|
||||
},
|
||||
cardBackgroundColor: {
|
||||
light: "#ffffff",
|
||||
dark: "#000000",
|
||||
},
|
||||
cardBorderColor: {
|
||||
light: "#cccccc",
|
||||
dark: "#444444",
|
||||
},
|
||||
isDarkModeEnabled: false,
|
||||
isLogoHidden: false,
|
||||
hideProgressBar: false,
|
||||
roundness: 8,
|
||||
cardArrangement: {
|
||||
linkSurveys: "casual",
|
||||
appSurveys: "casual",
|
||||
},
|
||||
},
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
logo: {
|
||||
url: "test-logo.png",
|
||||
bgColor: "#ffffff",
|
||||
},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
} as TProject;
|
||||
|
||||
// Test component that uses the hook
|
||||
const TestComponent = () => {
|
||||
const { environment, project } = useEnvironment();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="environment-id">{environment.id}</div>
|
||||
<div data-testid="environment-type">{environment.type}</div>
|
||||
<div data-testid="project-id">{project.id}</div>
|
||||
<div data-testid="project-organization-id">{project.organizationId}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe("EnvironmentContext", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("provides environment and project data to child components", () => {
|
||||
render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
||||
expect(screen.getByTestId("project-organization-id")).toHaveTextContent("test-org-id");
|
||||
});
|
||||
|
||||
test("throws error when useEnvironment is used outside of provider", () => {
|
||||
const TestComponentWithoutProvider = () => {
|
||||
useEnvironment();
|
||||
return <div>Should not render</div>;
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponentWithoutProvider />);
|
||||
}).toThrow("useEnvironment must be used within an EnvironmentProvider");
|
||||
});
|
||||
|
||||
test("updates context value when environment or project changes", () => {
|
||||
const { rerender } = render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
||||
|
||||
const updatedEnvironment = {
|
||||
...mockEnvironment,
|
||||
type: "production" as const,
|
||||
};
|
||||
|
||||
rerender(
|
||||
<EnvironmentContextWrapper environment={updatedEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("production");
|
||||
});
|
||||
|
||||
test("memoizes context value correctly", () => {
|
||||
const { rerender } = render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
// Re-render with same props
|
||||
rerender(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
// Should still work correctly
|
||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
}
|
||||
|
||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||
|
||||
export const useEnvironment = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
throw new Error("useEnvironment must be used within an EnvironmentProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
project,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
}),
|
||||
[environment, project]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromIntegrationId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromIntegrationId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZIntegrationInput } from "@formbricks/types/integration";
|
||||
@@ -20,48 +22,79 @@ const ZCreateOrUpdateIntegrationAction = z.object({
|
||||
|
||||
export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
.schema(ZCreateOrUpdateIntegrationAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"createdUpdated",
|
||||
"integration",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
return await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
||||
});
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const result = await createOrUpdateIntegration(
|
||||
parsedInput.environmentId,
|
||||
parsedInput.integrationData
|
||||
);
|
||||
ctx.auditLoggingCtx.integrationId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteIntegrationAction = z.object({
|
||||
integrationId: ZId,
|
||||
});
|
||||
|
||||
export const deleteIntegrationAction = authenticatedActionClient
|
||||
.schema(ZDeleteIntegrationAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromIntegrationId(parsedInput.integrationId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"integration",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const organizationId = await getOrganizationIdFromIntegrationId(parsedInput.integrationId);
|
||||
|
||||
return await deleteIntegration(parsedInput.integrationId);
|
||||
});
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.integrationId = parsedInput.integrationId;
|
||||
const result = await deleteIntegration(parsedInput.integrationId);
|
||||
ctx.auditLoggingCtx.oldObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationAirtable,
|
||||
TIntegrationAirtableConfigData,
|
||||
TIntegrationAirtableCredential,
|
||||
TIntegrationAirtableTables,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown",
|
||||
() => ({
|
||||
BaseSelectDropdown: ({ control, airtableArray, fetchTable, defaultValue, setValue }) => (
|
||||
<div>
|
||||
<label htmlFor="base">Base</label>
|
||||
<select
|
||||
id="base"
|
||||
defaultValue={defaultValue}
|
||||
onChange={(e) => {
|
||||
control._mockOnChange({ target: { name: "base", value: e.target.value } });
|
||||
setValue("table", ""); // Reset table when base changes
|
||||
fetchTable(e.target.value);
|
||||
}}>
|
||||
<option value="">Select Base</option>
|
||||
{airtableArray.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
|
||||
fetchTables: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (value, _locale) => value?.default || value || "",
|
||||
}));
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey, _locale) => survey,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
|
||||
AdditionalIntegrationSettings: ({
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
setIncludeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
}) => (
|
||||
<div data-testid="additional-settings">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-variables"
|
||||
checked={includeVariables}
|
||||
onChange={(e) => setIncludeVariables(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-hidden"
|
||||
checked={includeHiddenFields}
|
||||
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-metadata"
|
||||
checked={includeMetadata}
|
||||
onChange={(e) => setIncludeMetadata(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-createdat"
|
||||
checked={includeCreatedAt}
|
||||
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
|
||||
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children }) => <div data-testid="alert">{children}</div>,
|
||||
AlertTitle: ({ children }) => <div data-testid="alert-title">{children}</div>,
|
||||
AlertDescription: ({ children }) => <div data-testid="alert-description">{children}</div>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: (props) => <img alt="test" {...props} />,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ refresh: vi.fn() })),
|
||||
}));
|
||||
|
||||
// Mock the Select component used for Table and Survey selections
|
||||
vi.mock("@/modules/ui/components/select", () => ({
|
||||
Select: ({ children }) => (
|
||||
// Render children, assuming Controller passes props to the Trigger/Value
|
||||
// The actual select logic will be handled by the mocked Controller/field
|
||||
// We need to simulate the structure expected by the Controller render prop
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectTrigger: ({ children, ...props }) => <div {...props}>{children}</div>, // Mock Trigger
|
||||
SelectValue: ({ placeholder }) => <span>{placeholder || "Select..."}</span>, // Mock Value display
|
||||
SelectContent: ({ children }) => <div>{children}</div>, // Mock Content wrapper
|
||||
SelectItem: ({ children, value, ...props }) => (
|
||||
// Mock Item - crucial for userEvent.selectOptions if we were using a real select
|
||||
// For Controller, the value change is handled by field.onChange directly
|
||||
<div data-value={value} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock react-hook-form Controller to render a simple select
|
||||
vi.mock("react-hook-form", async () => {
|
||||
const actual = await vi.importActual("react-hook-form");
|
||||
let fields = {};
|
||||
const mockReset = vi.fn((values) => {
|
||||
fields = values || {}; // Reset fields, optionally with new values
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useForm: vi.fn((options) => {
|
||||
fields = options?.defaultValues || {};
|
||||
const mockControlOnChange = (event) => {
|
||||
if (event && event.target) {
|
||||
fields[event.target.name] = event.target.value;
|
||||
}
|
||||
};
|
||||
return {
|
||||
handleSubmit: (fn) => (e) => {
|
||||
e?.preventDefault();
|
||||
fn(fields);
|
||||
},
|
||||
control: {
|
||||
_mockOnChange: mockControlOnChange,
|
||||
// Add other necessary control properties if needed
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
getFieldState: vi.fn(() => ({ invalid: false, isDirty: false, isTouched: false, error: null })),
|
||||
_names: { mount: new Set(), unMount: new Set(), array: new Set(), watch: new Set() },
|
||||
_options: {},
|
||||
_proxyFormState: {
|
||||
isDirty: false,
|
||||
isValidating: false,
|
||||
dirtyFields: {},
|
||||
touchedFields: {},
|
||||
errors: {},
|
||||
},
|
||||
_formState: { isDirty: false, isValidating: false, dirtyFields: {}, touchedFields: {}, errors: {} },
|
||||
_updateFormState: vi.fn(),
|
||||
_updateFieldArray: vi.fn(),
|
||||
_executeSchema: vi.fn().mockResolvedValue({ errors: {}, values: {} }),
|
||||
_getWatch: vi.fn(),
|
||||
_subjects: {
|
||||
watch: { subscribe: vi.fn() },
|
||||
array: { subscribe: vi.fn() },
|
||||
state: { subscribe: vi.fn() },
|
||||
},
|
||||
_getDirty: vi.fn(),
|
||||
_reset: vi.fn(),
|
||||
_removeUnmounted: vi.fn(),
|
||||
},
|
||||
watch: (name) => fields[name],
|
||||
setValue: (name, value) => {
|
||||
fields[name] = value;
|
||||
},
|
||||
reset: mockReset,
|
||||
formState: { errors: {}, isDirty: false, isValid: true, isSubmitting: false },
|
||||
getValues: (name) => (name ? fields[name] : fields),
|
||||
};
|
||||
}),
|
||||
Controller: ({ name, defaultValue }) => {
|
||||
// Initialize field value if not already set by reset/defaultValues
|
||||
if (fields[name] === undefined && defaultValue !== undefined) {
|
||||
fields[name] = defaultValue;
|
||||
}
|
||||
|
||||
const field = {
|
||||
onChange: (valueOrEvent) => {
|
||||
const value = valueOrEvent?.target ? valueOrEvent.target.value : valueOrEvent;
|
||||
fields[name] = value;
|
||||
// Re-render might be needed here in a real scenario, but testing library handles it
|
||||
},
|
||||
onBlur: vi.fn(),
|
||||
value: fields[name],
|
||||
name: name,
|
||||
ref: vi.fn(),
|
||||
};
|
||||
|
||||
// Find the corresponding label to associate with the select
|
||||
const labelId = name; // Assuming label 'for' matches field name
|
||||
const labelText =
|
||||
name === "table" ? "environments.integrations.airtable.table_name" : "common.select_survey";
|
||||
|
||||
// Render a simple select element instead of the complex component
|
||||
// This makes interaction straightforward with userEvent.selectOptions
|
||||
return (
|
||||
<>
|
||||
{/* The actual label is rendered outside the Controller in the component */}
|
||||
<select
|
||||
id={labelId}
|
||||
aria-label={labelText} // Use aria-label for accessibility in tests
|
||||
{...field} // Spread field props
|
||||
defaultValue={defaultValue} // Pass defaultValue
|
||||
>
|
||||
{/* Need to dynamically get options based on context, simplified here */}
|
||||
{name === "table" &&
|
||||
mockTables.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
{name === "survey" &&
|
||||
mockSurveys.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
);
|
||||
},
|
||||
reset: mockReset,
|
||||
};
|
||||
});
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const mockSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
questions: [
|
||||
{ id: "q1", headline: { default: "Question 1" } },
|
||||
{ id: "q2", headline: { default: "Question 2" } },
|
||||
],
|
||||
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
|
||||
variables: { enabled: true, fieldIds: ["var1"] },
|
||||
} as any,
|
||||
{
|
||||
id: "survey2",
|
||||
name: "Survey 2",
|
||||
questions: [{ id: "q3", headline: { default: "Question 3" } }],
|
||||
hiddenFields: { enabled: false },
|
||||
variables: { enabled: false },
|
||||
} as any,
|
||||
];
|
||||
const mockAirtableArray: TIntegrationItem[] = [
|
||||
{ id: "base1", name: "Base 1" },
|
||||
{ id: "base2", name: "Base 2" },
|
||||
];
|
||||
const mockAirtableIntegration: TIntegrationAirtable = {
|
||||
id: "integration1",
|
||||
type: "airtable",
|
||||
environmentId,
|
||||
config: {
|
||||
key: { access_token: "abc" } as TIntegrationAirtableCredential,
|
||||
email: "test@test.com",
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
const mockTables: TIntegrationAirtableTables["tables"] = [
|
||||
{ id: "table1", name: "Table 1" },
|
||||
{ id: "table2", name: "Table 2" },
|
||||
];
|
||||
const mockSetOpenWithStates = vi.fn();
|
||||
const mockRouterRefresh = vi.fn();
|
||||
|
||||
describe("AddIntegrationModal", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useRouter).mockReturnValue({ refresh: mockRouterRefresh } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders in add mode correctly", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.link_airtable_table")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Base")).toBeInTheDocument();
|
||||
// Use getByLabelText for the mocked selects
|
||||
expect(screen.getByLabelText("environments.integrations.airtable.table_name")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("common.select_survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.save")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
||||
expect(screen.queryByText("common.delete")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'No Base Found' error when airtableArray is empty", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={[]}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("alert-title")).toHaveTextContent(
|
||||
"environments.integrations.airtable.no_bases_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("shows 'No Surveys Found' warning when surveys array is empty", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={[]}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.integrations.create_survey_warning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("fetches and displays tables when a base is selected", async () => {
|
||||
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const baseSelect = screen.getByLabelText("Base");
|
||||
await userEvent.selectOptions(baseSelect, "base1");
|
||||
|
||||
expect(fetchTables).toHaveBeenCalledWith(environmentId, "base1");
|
||||
await waitFor(() => {
|
||||
// Use getByLabelText (mocked select)
|
||||
const tableSelect = screen.getByLabelText("environments.integrations.airtable.table_name");
|
||||
expect(tableSelect).toBeEnabled();
|
||||
// Check options within the mocked select
|
||||
expect(tableSelect.querySelector("option[value='table1']")).toBeInTheDocument();
|
||||
expect(tableSelect.querySelector("option[value='table2']")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles deletion in edit mode", async () => {
|
||||
const initialData: TIntegrationAirtableConfigData = {
|
||||
baseId: "base1",
|
||||
tableId: "table1",
|
||||
surveyId: "survey1",
|
||||
questionIds: ["q1"],
|
||||
questions: "common.selected_questions",
|
||||
tableName: "Table 1",
|
||||
surveyName: "Survey 1",
|
||||
createdAt: new Date(),
|
||||
includeVariables: false,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: false,
|
||||
includeCreatedAt: true,
|
||||
};
|
||||
const integrationWithData = {
|
||||
...mockAirtableIntegration,
|
||||
config: { ...mockAirtableIntegration.config, data: [initialData] },
|
||||
};
|
||||
const defaultData = { ...initialData, index: 0 } as any;
|
||||
|
||||
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
|
||||
vi.mocked(createOrUpdateIntegrationAction).mockResolvedValue({ ok: true, data: {} } as any);
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={integrationWithData}
|
||||
isEditMode={true}
|
||||
defaultData={defaultData}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchTables).toHaveBeenCalled()); // Wait for initial load
|
||||
|
||||
// Click delete
|
||||
await userEvent.click(screen.getByText("common.delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalledTimes(1);
|
||||
const submittedData = vi.mocked(createOrUpdateIntegrationAction).mock.calls[0][0].integrationData;
|
||||
// Expect data array to be empty after deletion
|
||||
expect(submittedData.config.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
|
||||
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
|
||||
expect(mockRouterRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles cancel button click", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("common.cancel"));
|
||||
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,16 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,11 +27,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
@@ -68,6 +76,80 @@ const NoBaseFoundError = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderQuestionSelection = ({
|
||||
t,
|
||||
selectedSurvey,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeHiddenFields,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
}: {
|
||||
t: TFnType;
|
||||
selectedSurvey: TSurvey;
|
||||
control: Control<IntegrationModalInputs>;
|
||||
includeVariables: boolean;
|
||||
setIncludeVariables: (value: boolean) => void;
|
||||
includeHiddenFields: boolean;
|
||||
includeMetadata: boolean;
|
||||
setIncludeHiddenFields: (value: boolean) => void;
|
||||
setIncludeMetadata: (value: boolean) => void;
|
||||
includeCreatedAt: boolean;
|
||||
setIncludeCreatedAt: (value: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<Controller
|
||||
key={question.id}
|
||||
control={control}
|
||||
name={"questions"}
|
||||
render={({ field }) => (
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={question.id}
|
||||
value={question.id}
|
||||
className="bg-white"
|
||||
checked={field.value?.includes(question.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([...field.value, question.id])
|
||||
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
setIncludeMetadata={setIncludeMetadata}
|
||||
includeCreatedAt={includeCreatedAt}
|
||||
setIncludeCreatedAt={setIncludeCreatedAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddIntegrationModal = ({
|
||||
open,
|
||||
setOpenWithStates,
|
||||
@@ -210,182 +292,148 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={handleClose} noPadding>
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent className="overflow-visible md:overflow-visible">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" />
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={AirtableLogo}
|
||||
alt={t("environments.integrations.airtable.airtable_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.airtable.link_airtable_table")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.airtable.sync_responses_with_airtable")}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(submitHandler)}>
|
||||
<div className="flex rounded-lg p-6">
|
||||
<div className="flex w-full flex-col gap-y-4 pt-5">
|
||||
{airtableArray.length ? (
|
||||
<BaseSelectDropdown
|
||||
control={control}
|
||||
isLoading={isLoading}
|
||||
fetchTable={fetchTable}
|
||||
airtableArray={airtableArray}
|
||||
setValue={setValue}
|
||||
defaultValue={defaultData?.base}
|
||||
/>
|
||||
) : (
|
||||
<NoBaseFoundError />
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
|
||||
<DialogBody className="overflow-visible">
|
||||
<div className="flex w-full flex-col gap-y-4">
|
||||
{airtableArray.length ? (
|
||||
<BaseSelectDropdown
|
||||
control={control}
|
||||
name="table"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
disabled={!tables.length}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
}}
|
||||
defaultValue={defaultData?.table}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
{tables.length ? (
|
||||
<SelectContent>
|
||||
{tables.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
) : null}
|
||||
</Select>
|
||||
)}
|
||||
isLoading={isLoading}
|
||||
fetchTable={fetchTable}
|
||||
airtableArray={airtableArray}
|
||||
setValue={setValue}
|
||||
defaultValue={defaultData?.base}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NoBaseFoundError />
|
||||
)}
|
||||
|
||||
{surveys.length ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="survey">{t("common.select_survey")}</Label>
|
||||
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="survey"
|
||||
name="table"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
disabled={!tables.length}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("questions", []);
|
||||
}}
|
||||
defaultValue={defaultData?.survey}>
|
||||
defaultValue={defaultData?.table}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
{tables.length ? (
|
||||
<SelectContent>
|
||||
{tables.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
) : null}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!surveys.length ? (
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.create_survey_warning")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{survey && selectedSurvey && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<Controller
|
||||
key={question.id}
|
||||
control={control}
|
||||
name={"questions"}
|
||||
render={({ field }) => (
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={question.id}
|
||||
value={question.id}
|
||||
className="bg-white"
|
||||
checked={field.value?.includes(question.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([...field.value, question.id])
|
||||
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getLocalizedValue(question.headline, "default")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{surveys.length ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="survey">{t("common.select_survey")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="survey"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("questions", []);
|
||||
}}
|
||||
defaultValue={defaultData?.survey}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
setIncludeMetadata={setIncludeMetadata}
|
||||
includeCreatedAt={includeCreatedAt}
|
||||
setIncludeCreatedAt={setIncludeCreatedAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-x-2">
|
||||
{isEditMode ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleDelete(defaultData.index);
|
||||
}}
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
variant="destructive">
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.create_survey_warning")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit">{t("common.save")}</Button>
|
||||
{survey &&
|
||||
selectedSurvey &&
|
||||
renderQuestionSelection({
|
||||
t,
|
||||
selectedSurvey,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeHiddenFields,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{isEditMode ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleDelete(defaultData.index);
|
||||
}}
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
variant="destructive">
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="submit">{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "./AirtableWrapper";
|
||||
|
||||
// Mock child components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration",
|
||||
() => ({
|
||||
ManageIntegration: ({ setIsConnected }) => (
|
||||
<div data-testid="manage-integration">
|
||||
<button onClick={() => setIsConnected(false)}>Disconnect</button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
ConnectIntegration: ({ handleAuthorization, isEnabled }) => (
|
||||
<div data-testid="connect-integration">
|
||||
<button onClick={handleAuthorization} disabled={!isEnabled}>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock library function
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
|
||||
authorize: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock image import
|
||||
vi.mock("@/images/airtableLogo.svg", () => ({
|
||||
default: "airtable-logo-path",
|
||||
}));
|
||||
|
||||
// Mock window.location.replace
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
replace: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const webAppUrl = "https://app.formbricks.com";
|
||||
const environment = { id: environmentId } as TEnvironment;
|
||||
const surveys = [];
|
||||
const airtableArray = [];
|
||||
const locale = "en-US" as const;
|
||||
|
||||
const baseProps = {
|
||||
environmentId,
|
||||
airtableArray,
|
||||
surveys,
|
||||
environment,
|
||||
webAppUrl,
|
||||
locale,
|
||||
};
|
||||
|
||||
describe("AirtableWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected (no integration)", () => {
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected (integration without key)", () => {
|
||||
const integrationWithoutKey = { config: {} } as TIntegrationAirtable;
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={integrationWithoutKey} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration disabled when isEnabled is false", () => {
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={false} airtableIntegration={undefined} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("calls authorize and redirects when Connect button is clicked", async () => {
|
||||
const mockAuthorize = vi.mocked(authorize);
|
||||
const redirectUrl = "https://airtable.com/auth";
|
||||
mockAuthorize.mockResolvedValue(redirectUrl);
|
||||
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
|
||||
|
||||
const connectButton = screen.getByRole("button", { name: "Connect" });
|
||||
await userEvent.click(connectButton);
|
||||
|
||||
expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl);
|
||||
await vi.waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith(redirectUrl);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders ManageIntegration when connected", () => {
|
||||
const connectedIntegration = {
|
||||
id: "int-1",
|
||||
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
|
||||
} as unknown as TIntegrationAirtable;
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
|
||||
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches from ManageIntegration to ConnectIntegration when disconnected", async () => {
|
||||
const connectedIntegration = {
|
||||
id: "int-1",
|
||||
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
|
||||
} as unknown as TIntegrationAirtable;
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
|
||||
|
||||
// Initially, ManageIntegration is shown
|
||||
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
|
||||
|
||||
// Simulate disconnection via ManageIntegration's button
|
||||
const disconnectButton = screen.getByRole("button", { name: "Disconnect" });
|
||||
await userEvent.click(disconnectButton);
|
||||
|
||||
// Now, ConnectIntegration should be shown
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { IntegrationModalInputs } from "./AddIntegrationModal";
|
||||
import { BaseSelectDropdown } from "./BaseSelectDropdown";
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor: string }) => (
|
||||
<label htmlFor={htmlFor}>{children}</label>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/select", () => ({
|
||||
Select: ({ children, onValueChange, disabled, defaultValue }) => (
|
||||
<select
|
||||
data-testid="base-select"
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
defaultValue={defaultValue}>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectTrigger: ({ children }) => <div>{children}</div>,
|
||||
SelectValue: () => <span>SelectValueMock</span>,
|
||||
SelectContent: ({ children }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }) => <option value={value}>{children}</option>,
|
||||
}));
|
||||
|
||||
// Mock react-hook-form's Controller specifically
|
||||
vi.mock("react-hook-form", async () => {
|
||||
const actual = await vi.importActual("react-hook-form");
|
||||
// Keep the actual useForm
|
||||
const originalUseForm = actual.useForm;
|
||||
|
||||
// Mock Controller
|
||||
const MockController = ({ name, _, render, defaultValue }) => {
|
||||
// Minimal mock: call render with a basic field object
|
||||
const field = {
|
||||
onChange: vi.fn(), // Simple spy for field.onChange
|
||||
onBlur: vi.fn(),
|
||||
value: defaultValue, // Use defaultValue passed to Controller
|
||||
name: name,
|
||||
ref: vi.fn(),
|
||||
};
|
||||
// The component passes the render prop result to the actual Select component
|
||||
return render({ field });
|
||||
};
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useForm: originalUseForm, // Use the actual useForm
|
||||
Controller: MockController, // Use the mocked Controller
|
||||
};
|
||||
});
|
||||
|
||||
const mockAirtableArray: TIntegrationItem[] = [
|
||||
{ id: "base1", name: "Base One" },
|
||||
{ id: "base2", name: "Base Two" },
|
||||
];
|
||||
|
||||
const mockFetchTable = vi.fn();
|
||||
|
||||
// Use a wrapper component that utilizes the actual useForm
|
||||
const renderComponent = (
|
||||
isLoading = false,
|
||||
defaultValue: string | undefined = undefined,
|
||||
airtableArray = mockAirtableArray
|
||||
) => {
|
||||
const Component = () => {
|
||||
// Now uses the actual useForm because Controller is mocked separately
|
||||
const { control, setValue } = useForm<IntegrationModalInputs>({
|
||||
defaultValues: { base: defaultValue },
|
||||
});
|
||||
return (
|
||||
<BaseSelectDropdown
|
||||
control={control}
|
||||
isLoading={isLoading}
|
||||
fetchTable={mockFetchTable} // The spy
|
||||
airtableArray={airtableArray}
|
||||
setValue={setValue} // Actual RHF setValue
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return render(<Component />);
|
||||
};
|
||||
|
||||
describe("BaseSelectDropdown", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders the label and select trigger", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_base")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("base-select")).toBeInTheDocument();
|
||||
expect(screen.getByText("SelectValueMock")).toBeInTheDocument(); // From mocked SelectValue
|
||||
});
|
||||
|
||||
test("renders options from airtableArray", () => {
|
||||
renderComponent();
|
||||
const select = screen.getByTestId("base-select");
|
||||
expect(select.querySelectorAll("option")).toHaveLength(mockAirtableArray.length);
|
||||
expect(screen.getByText("Base One")).toBeInTheDocument();
|
||||
expect(screen.getByText("Base Two")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("disables the select when isLoading is true", () => {
|
||||
renderComponent(true);
|
||||
expect(screen.getByTestId("base-select")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("enables the select when isLoading is false", () => {
|
||||
renderComponent(false);
|
||||
expect(screen.getByTestId("base-select")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("renders correctly with empty airtableArray", () => {
|
||||
renderComponent(false, undefined, []);
|
||||
const select = screen.getByTestId("base-select");
|
||||
expect(select.querySelectorAll("option")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
|
||||
import { authorize, fetchTables } from "./airtable";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const baseId = "test-base-id";
|
||||
const apiHost = "http://localhost:3000";
|
||||
|
||||
describe("Airtable Library", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("fetchTables", () => {
|
||||
test("should fetch tables successfully", async () => {
|
||||
const mockTables: TIntegrationAirtableTables = {
|
||||
tables: [
|
||||
{ id: "tbl1", name: "Table 1" },
|
||||
{ id: "tbl2", name: "Table 2" },
|
||||
],
|
||||
};
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({ data: mockTables }),
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
const tables = await fetchTables(environmentId, baseId);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
cache: "no-store",
|
||||
});
|
||||
expect(tables).toEqual(mockTables);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authorize", () => {
|
||||
test("should return authUrl successfully", async () => {
|
||||
const mockAuthUrl = "https://airtable.com/oauth2/v1/authorize?...";
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({ data: { authUrl: mockAuthUrl } }),
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
const authUrl = await authorize(environmentId, apiHost);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
});
|
||||
expect(authUrl).toBe(mockAuthUrl);
|
||||
});
|
||||
|
||||
test("should throw error and log when fetch fails", async () => {
|
||||
const errorText = "Failed to fetch";
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
text: async () => errorText,
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable, TIntegrationAirtableCredential } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import Page from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper", () => ({
|
||||
AirtableWrapper: vi.fn(() => <div>AirtableWrapper Mock</div>),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys");
|
||||
vi.mock("@/lib/airtable/service");
|
||||
|
||||
let mockAirtableClientId: string | undefined = "test-client-id";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get AIRTABLE_CLIENT_ID() {
|
||||
return mockAirtableClientId;
|
||||
},
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
IS_PRODUCTION: true,
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-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",
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integration/service");
|
||||
vi.mock("@/lib/utils/locale");
|
||||
vi.mock("@/modules/environments/lib/utils");
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: vi.fn(() => <div>GoBackButton Mock</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/navigation");
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockEnvironment = {
|
||||
id: mockEnvironmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
const mockSurveys: TSurvey[] = [{ id: "survey1", name: "Survey 1" } as TSurvey];
|
||||
const mockAirtableIntegration: TIntegrationAirtable = {
|
||||
type: "airtable",
|
||||
config: {
|
||||
key: { access_token: "test-token" } as unknown as TIntegrationAirtableCredential,
|
||||
data: [],
|
||||
email: "test@example.com",
|
||||
},
|
||||
environmentId: mockEnvironmentId,
|
||||
id: "int_airtable_123",
|
||||
};
|
||||
const mockAirtableTables: TIntegrationItem[] = [{ id: "table1", name: "Table 1" } as TIntegrationItem];
|
||||
const mockLocale = "en-US";
|
||||
|
||||
const props = {
|
||||
params: {
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
};
|
||||
|
||||
describe("Airtable Integration Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getIntegrations).mockResolvedValue([mockAirtableIntegration]);
|
||||
vi.mocked(getAirtableTables).mockResolvedValue(mockAirtableTables);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects if user is readOnly", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: true,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
await render(await Page(props));
|
||||
expect(redirect).toHaveBeenCalledWith("./");
|
||||
});
|
||||
|
||||
test("renders correctly when integration is configured", async () => {
|
||||
await render(await Page(props));
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("GoBackButton Mock")).toBeInTheDocument();
|
||||
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getSurveys)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getIntegrations)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getAirtableTables)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(findMatchingLocale)).toHaveBeenCalled();
|
||||
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
expect(AirtableWrapper).toHaveBeenCalledWith(
|
||||
{
|
||||
isEnabled: true,
|
||||
airtableIntegration: mockAirtableIntegration,
|
||||
airtableArray: mockAirtableTables,
|
||||
environmentId: mockEnvironmentId,
|
||||
surveys: mockSurveys,
|
||||
environment: mockEnvironment,
|
||||
webAppUrl: WEBAPP_URL,
|
||||
locale: mockLocale,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("renders correctly when integration exists but is not configured (no key)", async () => {
|
||||
const integrationWithoutKey = {
|
||||
...mockAirtableIntegration,
|
||||
config: { ...mockAirtableIntegration.config, key: undefined },
|
||||
} as unknown as TIntegrationAirtable;
|
||||
vi.mocked(getIntegrations).mockResolvedValue([integrationWithoutKey]);
|
||||
|
||||
await render(await Page(props));
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(getAirtableTables)).not.toHaveBeenCalled(); // Should not fetch tables if no key
|
||||
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
// Update assertion to match the actual call
|
||||
expect(AirtableWrapper).toHaveBeenCalledWith(
|
||||
{
|
||||
isEnabled: true, // isEnabled is true because AIRTABLE_CLIENT_ID is set in beforeEach
|
||||
airtableIntegration: integrationWithoutKey,
|
||||
airtableArray: [], // Should be empty as getAirtableTables is not called
|
||||
environmentId: mockEnvironmentId,
|
||||
surveys: mockSurveys,
|
||||
environment: mockEnvironment,
|
||||
webAppUrl: WEBAPP_URL,
|
||||
locale: mockLocale,
|
||||
},
|
||||
undefined // Change second argument to undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("renders correctly when integration is disabled (no client ID)", async () => {
|
||||
mockAirtableClientId = undefined; // Simulate disabled integration
|
||||
|
||||
await render(await Page(props));
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
|
||||
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
expect(AirtableWrapper).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isEnabled: false, // Should be false
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock actions and utilities
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions", () => ({
|
||||
getSpreadsheetNameByIdAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util", () => ({
|
||||
constructGoogleSheetsUrl: (id: string) => `https://docs.google.com/spreadsheets/d/${id}`,
|
||||
extractSpreadsheetIdFromUrl: (url: string) => url.split("/")[5],
|
||||
isValidGoogleSheetsUrl: (url: string) => url.startsWith("https://docs.google.com/spreadsheets/d/"),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
|
||||
}));
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey: any) => survey,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
|
||||
AdditionalIntegrationSettings: ({
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
setIncludeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
}: any) => (
|
||||
<div>
|
||||
<span>Additional Settings</span>
|
||||
<input
|
||||
data-testid="include-variables"
|
||||
type="checkbox"
|
||||
checked={includeVariables}
|
||||
onChange={(e) => setIncludeVariables(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-hidden-fields"
|
||||
type="checkbox"
|
||||
checked={includeHiddenFields}
|
||||
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-metadata"
|
||||
type="checkbox"
|
||||
checked={includeMetadata}
|
||||
onChange={(e) => setIncludeMetadata(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-created-at"
|
||||
type="checkbox"
|
||||
checked={includeCreatedAt}
|
||||
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
DropdownSelector: ({ label, items, selectedItem, setSelectedItem }: any) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select
|
||||
data-testid="survey-dropdown"
|
||||
value={selectedItem?.id || ""}
|
||||
onChange={(e) => {
|
||||
const selected = items.find((item: any) => item.id === e.target.value);
|
||||
setSelectedItem(selected);
|
||||
}}>
|
||||
<option value="">Select a survey</option>
|
||||
{items.map((item: any) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
|
||||
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
||||
}));
|
||||
vi.mock("react-hook-form", () => ({
|
||||
useForm: () => ({
|
||||
handleSubmit: (callback: any) => (event: any) => {
|
||||
event.preventDefault();
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@tolgee/react", async () => {
|
||||
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const useTranslate = () => ({
|
||||
t: (key: string, _?: any) => {
|
||||
// NOSONAR
|
||||
// Simple mock translation function
|
||||
if (key === "common.all_questions") return "All questions";
|
||||
if (key === "common.selected_questions") return "Selected questions";
|
||||
if (key === "environments.integrations.google_sheets.link_google_sheet") return "Link Google Sheet";
|
||||
if (key === "common.update") return "Update";
|
||||
if (key === "common.delete") return "Delete";
|
||||
if (key === "common.cancel") return "Cancel";
|
||||
if (key === "environments.integrations.google_sheets.spreadsheet_url") return "Spreadsheet URL";
|
||||
if (key === "common.select_survey") return "Select survey";
|
||||
if (key === "common.questions") return "Questions";
|
||||
if (key === "environments.integrations.google_sheets.enter_a_valid_spreadsheet_url_error")
|
||||
return "Please enter a valid Google Sheet URL.";
|
||||
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
|
||||
if (key === "environments.integrations.select_at_least_one_question_error")
|
||||
return "Please select at least one question.";
|
||||
if (key === "environments.integrations.integration_updated_successfully")
|
||||
return "Integration updated successfully.";
|
||||
if (key === "environments.integrations.integration_added_successfully")
|
||||
return "Integration added successfully.";
|
||||
if (key === "environments.integrations.integration_removed_successfully")
|
||||
return "Integration removed successfully.";
|
||||
if (key === "environments.integrations.google_sheets.google_sheet_logo") return "Google Sheet logo";
|
||||
if (key === "environments.integrations.google_sheets.google_sheets_integration_description")
|
||||
return "Sync responses with Google Sheets.";
|
||||
if (key === "environments.integrations.create_survey_warning")
|
||||
return "You need to create a survey first.";
|
||||
return key; // Return key if no translation is found
|
||||
},
|
||||
});
|
||||
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
|
||||
});
|
||||
|
||||
// Mock dependencies
|
||||
const createOrUpdateIntegrationAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
|
||||
.createOrUpdateIntegrationAction
|
||||
);
|
||||
const getSpreadsheetNameByIdAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"))
|
||||
.getSpreadsheetNameByIdAction
|
||||
);
|
||||
const toast = vi.mocked((await import("react-hot-toast")).default);
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const surveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 1",
|
||||
type: "app",
|
||||
environmentId: environmentId,
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1?" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 2?" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Choice 1" } },
|
||||
{ id: "c2", label: { default: "Choice 2" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
variables: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
id: "survey2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 2",
|
||||
type: "link",
|
||||
environmentId: environmentId,
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate this?" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
variables: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const mockGoogleSheetIntegration = {
|
||||
id: "integration1",
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
key: {
|
||||
access_token: "mock_access_token",
|
||||
expiry_date: Date.now() + 3600000,
|
||||
refresh_token: "mock_refresh_token",
|
||||
scope: "mock_scope",
|
||||
token_type: "Bearer",
|
||||
},
|
||||
email: "test@example.com",
|
||||
data: [], // Initially empty, will be populated in beforeEach
|
||||
},
|
||||
} as unknown as TIntegrationGoogleSheets;
|
||||
|
||||
const mockSelectedIntegration: TIntegrationGoogleSheetsConfigData & { index: number } = {
|
||||
spreadsheetId: "existing-sheet-id",
|
||||
spreadsheetName: "Existing Sheet",
|
||||
surveyId: surveys[0].id,
|
||||
surveyName: surveys[0].name,
|
||||
questionIds: [surveys[0].questions[0].id],
|
||||
questions: "Selected questions",
|
||||
createdAt: new Date(),
|
||||
includeVariables: true,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: false,
|
||||
index: 0,
|
||||
};
|
||||
|
||||
describe("AddIntegrationModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset integration data before each test if needed
|
||||
mockGoogleSheetIntegration.config.data = [
|
||||
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
|
||||
];
|
||||
});
|
||||
|
||||
test("renders correctly when open (create mode)", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
).toBeInTheDocument();
|
||||
// Use getByTestId for the dropdown
|
||||
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Link Google Sheet" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Questions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly when open (update mode)", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
).toHaveValue("https://docs.google.com/spreadsheets/d/existing-sheet-id");
|
||||
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Questions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("include-variables")).toBeChecked();
|
||||
expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked();
|
||||
expect(screen.getByTestId("include-metadata")).toBeChecked();
|
||||
expect(screen.getByTestId("include-created-at")).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("selects survey and shows questions", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[1].id);
|
||||
|
||||
expect(screen.getByText("Questions")).toBeInTheDocument();
|
||||
surveys[1].questions.forEach((q) => {
|
||||
expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument();
|
||||
// Initially all questions should be checked when a survey is selected in create mode
|
||||
expect(screen.getByLabelText(q.headline.default)).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles question selection", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default);
|
||||
expect(firstQuestionCheckbox).toBeChecked(); // Initially checked
|
||||
|
||||
await userEvent.click(firstQuestionCheckbox);
|
||||
expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click
|
||||
|
||||
await userEvent.click(firstQuestionCheckbox);
|
||||
expect(firstQuestionCheckbox).toBeChecked(); // Checked again
|
||||
});
|
||||
|
||||
test("creates integration successfully", async () => {
|
||||
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Test Sheet Name" });
|
||||
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any }); // Mock successful action
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={{
|
||||
...mockGoogleSheetIntegration,
|
||||
config: { ...mockGoogleSheetIntegration.config, data: [] },
|
||||
}} // Start with empty data
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/new-sheet-id");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
// Wait for questions to appear and potentially uncheck one
|
||||
const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default);
|
||||
await userEvent.click(firstQuestionCheckbox); // Uncheck first question
|
||||
|
||||
// Check additional settings
|
||||
await userEvent.click(screen.getByTestId("include-variables"));
|
||||
await userEvent.click(screen.getByTestId("include-metadata"));
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSpreadsheetNameByIdAction).toHaveBeenCalledWith({
|
||||
googleSheetIntegration: expect.any(Object),
|
||||
environmentId,
|
||||
spreadsheetId: "new-sheet-id",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
integrationData: expect.objectContaining({
|
||||
type: "googleSheets",
|
||||
config: expect.objectContaining({
|
||||
key: mockGoogleSheetIntegration.config.key,
|
||||
email: mockGoogleSheetIntegration.config.email,
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
spreadsheetId: "new-sheet-id",
|
||||
spreadsheetName: "Test Sheet Name",
|
||||
surveyId: surveys[0].id,
|
||||
surveyName: surveys[0].name,
|
||||
questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question
|
||||
questions: "Selected questions",
|
||||
includeVariables: true,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: true, // Default
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration added successfully.");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("deletes integration successfully", async () => {
|
||||
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any });
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration} // Contains initial data at index 0
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByText("Delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
integrationData: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
data: [], // Data array should be empty after deletion
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("shows validation error for invalid URL", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "invalid-url");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please enter a valid Google Sheet URL.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows validation error if no survey selected", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
|
||||
// No survey selected
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows validation error if no questions selected", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
// Uncheck all questions
|
||||
for (const question of surveys[0].questions) {
|
||||
const checkbox = await screen.findByLabelText(question.headline.default);
|
||||
await userEvent.click(checkbox);
|
||||
}
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select at least one question.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast if createOrUpdateIntegrationAction fails", async () => {
|
||||
const errorMessage = "Failed to update integration";
|
||||
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Some Sheet Name" });
|
||||
createOrUpdateIntegrationAction.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/another-id");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSpreadsheetNameByIdAction).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls setOpen(false) and resets form on cancel", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
|
||||
// Simulate some interaction
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/temp-id");
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
// Re-render with open=true to check if state was reset (URL should be empty)
|
||||
cleanup();
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
// Use getByPlaceholderText for the input check after re-render
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,18 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -202,31 +210,28 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image
|
||||
className="w-12"
|
||||
src={GoogleSheetLogo}
|
||||
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={GoogleSheetLogo}
|
||||
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(linkSheet)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
|
||||
<DialogBody>
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -255,7 +260,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isLinkingSheet}>
|
||||
{selectedIntegration
|
||||
? t("common.update")
|
||||
: t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isLinkingSheet}>
|
||||
{selectedIntegration
|
||||
? t("common.update")
|
||||
: t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsCredential,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock child components and functions
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration",
|
||||
() => ({
|
||||
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal }) => (
|
||||
<div data-testid="manage-integration">
|
||||
<button onClick={() => setOpenAddIntegrationModal(true)}>Open Modal</button>
|
||||
</div>
|
||||
)),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
ConnectIntegration: vi.fn(({ handleAuthorization }) => (
|
||||
<div data-testid="connect-integration">
|
||||
<button onClick={handleAuthorization}>Connect</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal",
|
||||
() => ({
|
||||
AddIntegrationModal: vi.fn(({ open }) =>
|
||||
open ? <div data-testid="add-integration-modal">Modal</div> : null
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google", () => ({
|
||||
authorize: vi.fn(() => Promise.resolve("http://google.com/auth")),
|
||||
}));
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
appSetupCompleted: false,
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockSurveys: TSurvey[] = [];
|
||||
const mockWebAppUrl = "http://localhost:3000";
|
||||
const mockLocale = "en-US";
|
||||
|
||||
const mockGoogleSheetIntegration = {
|
||||
id: "test-integration-id",
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
key: { access_token: "test-token" } as unknown as TIntegrationGoogleSheetsCredential,
|
||||
data: [],
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as unknown as TIntegrationGoogleSheets;
|
||||
|
||||
describe("GoogleSheetWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected", () => {
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
// No googleSheetIntegration provided initially
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when integration exists but has no key", () => {
|
||||
const integrationWithoutKey = {
|
||||
...mockGoogleSheetIntegration,
|
||||
config: { data: [], email: "test" },
|
||||
} as unknown as TIntegrationGoogleSheets;
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
googleSheetIntegration={integrationWithoutKey}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls authorize when connect button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Mock window.location.replace
|
||||
const originalLocation = window.location;
|
||||
// @ts-expect-error
|
||||
delete window.location;
|
||||
window.location = { ...originalLocation, replace: vi.fn() } as any;
|
||||
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectButton = screen.getByRole("button", { name: "Connect" });
|
||||
await user.click(connectButton);
|
||||
|
||||
expect(vi.mocked(authorize)).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
|
||||
// Need to wait for the promise returned by authorize to resolve
|
||||
await vi.waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith("http://google.com/auth");
|
||||
});
|
||||
|
||||
// Restore window.location
|
||||
window.location = originalLocation as any;
|
||||
});
|
||||
|
||||
test("renders ManageIntegration and AddIntegrationModal when connected", () => {
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
|
||||
// Modal is rendered but initially hidden
|
||||
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens AddIntegrationModal when triggered from ManageIntegration", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
|
||||
const openModalButton = screen.getByRole("button", { name: "Open Modal" }); // Button inside mocked ManageIntegration
|
||||
await user.click(openModalButton);
|
||||
expect(screen.getByTestId("add-integration-modal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user