mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7971b9b312 | ||
|
|
1143f58ba5 | ||
|
|
47fe3c73dd | ||
|
|
727e586b16 | ||
|
|
4a9b4d52ca | ||
|
|
cbb0166419 | ||
|
|
4b0c518683 | ||
|
|
5f05f8d36b | ||
|
|
f7558a7497 | ||
|
|
009beba866 | ||
|
|
c3ec5ddc3a | ||
|
|
9573ae19e6 | ||
|
|
7b3f841c5e | ||
|
|
8f7d225d6a | ||
|
|
094b6dedba | ||
|
|
36f0be07c4 | ||
|
|
e079055a43 | ||
|
|
9ae9a3a9fc | ||
|
|
b4606c0113 | ||
|
|
6be654ab60 | ||
|
|
95c2e24416 | ||
|
|
5b86dd3a8f | ||
|
|
0da083a214 | ||
|
|
379a86cf46 | ||
|
|
bed78716f0 | ||
|
|
6167c3d9e6 | ||
|
|
1db1271e7f | ||
|
|
9ec1964106 | ||
|
|
d5a70796dd | ||
|
|
246351b3e6 | ||
|
|
22ea7302bb | ||
|
|
8d47ab9709 | ||
|
|
8f6d27c1ef | ||
|
|
a37815b831 | ||
|
|
2b526a87ca | ||
|
|
047750967c | ||
|
|
a54356c3b0 | ||
|
|
38ea5ed6ae | ||
|
|
6e19de32f7 | ||
|
|
957a4432f4 | ||
|
|
22a5d4bb7d | ||
|
|
226dff0344 | ||
|
|
d474a94a21 | ||
|
|
c1a4cc308b | ||
|
|
210da98b69 | ||
|
|
2fc183d384 | ||
|
|
78fb111610 | ||
|
|
11c0cb4b61 | ||
|
|
95831f7c7f | ||
|
|
a31e7bfaa5 | ||
|
|
6e35fc1769 | ||
|
|
48cded1646 | ||
|
|
db752cee15 | ||
|
|
b33aae0a73 | ||
|
|
72126ad736 | ||
|
|
4a2eeac90b | ||
|
|
46be3e7d70 | ||
|
|
6d140532a7 | ||
|
|
8c4a7f1518 | ||
|
|
63fe32a786 | ||
|
|
84c465f974 | ||
|
|
6a33498737 | ||
|
|
5130c747d4 | ||
|
|
f5583d2652 | ||
|
|
e0d75914a4 | ||
|
|
f02ca1cfe1 | ||
|
|
4ade83f189 | ||
|
|
f1fc9fea2c | ||
|
|
25266e4566 | ||
|
|
b960cfd2a1 | ||
|
|
9e1d1c1dc2 | ||
|
|
8c63a9f7af | ||
|
|
fff0a7f052 | ||
|
|
0ecc8aabff | ||
|
|
01cc0ab64d | ||
|
|
1d125bdac2 | ||
|
|
ca67c4d5a8 | ||
|
|
d167d591ce | ||
|
|
acc3b0179a | ||
|
|
3434b5cf08 | ||
|
|
a618f2df95 | ||
|
|
5b334f6623 | ||
|
|
fa2b63d6a1 | ||
|
|
9f0fe69b6b | ||
|
|
98cb2de02b | ||
|
|
f00d0b7e20 | ||
|
|
65abd4ee07 | ||
|
|
939f135bf4 | ||
|
|
729a16854a | ||
|
|
a2d3e37d69 | ||
|
|
adf12f551d | ||
|
|
3f2bddc358 | ||
|
|
ae6d1ac133 | ||
|
|
7c4569cd50 | ||
|
|
7354122447 | ||
|
|
d54dca2b27 | ||
|
|
acd5cff534 | ||
|
|
834929e766 | ||
|
|
09f40ad816 | ||
|
|
689b6491b3 | ||
|
|
b70b2eef95 | ||
|
|
392a95834b | ||
|
|
66d9cc8eac | ||
|
|
befdc078f1 | ||
|
|
13b983b3b2 | ||
|
|
1e285ebe4e | ||
|
|
a7c4971952 | ||
|
|
c8689d91d5 | ||
|
|
73a2ff7421 | ||
|
|
0c28e89b41 | ||
|
|
a736436e29 | ||
|
|
7dbb0300d3 |
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
- [packages/cache/src/service.ts](mdc:packages/cache/src/service.ts) - Redis cache service
|
|
||||||
- [packages/cache/src/client.ts](mdc:packages/cache/src/client.ts) - Cache client initialization and singleton management
|
|
||||||
- [apps/web/lib/cache/index.ts](mdc:apps/web/lib/cache/index.ts) - Cache service proxy for web app
|
|
||||||
- [packages/cache/src/index.ts](mdc:packages/cache/src/index.ts) - Cache package exports 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 the cache package:
|
|
||||||
|
|
||||||
```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 `cache.withCache()` for Simple Database Queries
|
|
||||||
```typescript
|
|
||||||
// ✅ Simple caching with automatic fallback (TTL in milliseconds)
|
|
||||||
export const getActionClasses = (environmentId: string) => {
|
|
||||||
return cache.withCache(() => fetchActionClassesFromDB(environmentId),
|
|
||||||
createCacheKey.environment.actionClasses(environmentId),
|
|
||||||
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; // 1 minute (seconds for client)
|
|
||||||
|
|
||||||
// Server Redis cache - shorter TTL ensures fresh data for clients
|
|
||||||
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
|
|
||||||
|
|
||||||
// HTTP cache headers (seconds)
|
|
||||||
const BROWSER_TTL = 60; // 1 minute (max-age)
|
|
||||||
const CDN_TTL = 60; // 1 minute (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)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
---
|
|
||||||
description: >
|
|
||||||
globs: schema.prisma
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# 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.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
|
||||||
|
|
||||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
title: "FEATURE NAME"
|
|
||||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
|
||||||
icon: "link"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
|
||||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt - e.g. if docs describe consecutive steps, always use Mintlify Step component.
|
|
||||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case.
|
|
||||||
- The page should never start with H1 headline, because it's already part of the template.
|
|
||||||
- Tonality: Keep it concise and to the point. Avoid Jargon where possible.
|
|
||||||
- If a feature is part of the Enterprise Edition, use this note:
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license)
|
|
||||||
</Note>
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
├── 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
|
|
||||||
|
|
||||||
## 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
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
---
|
|
||||||
description: Security best practices and guidelines for writing GitHub Actions and workflows
|
|
||||||
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
|
|
||||||
---
|
|
||||||
|
|
||||||
# GitHub Actions Security Best Practices
|
|
||||||
|
|
||||||
## Required Security Measures
|
|
||||||
|
|
||||||
### 1. Set Minimum GITHUB_TOKEN Permissions
|
|
||||||
|
|
||||||
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
# Only add additional permissions if absolutely necessary:
|
|
||||||
# pull-requests: write # for commenting on PRs
|
|
||||||
# issues: write # for creating/updating issues
|
|
||||||
# checks: write # for publishing check results
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add Harden-Runner as First Step
|
|
||||||
|
|
||||||
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit # or 'block' for stricter security
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Pin Actions to Full Commit SHA
|
|
||||||
|
|
||||||
**Always** pin third-party actions to their full commit SHA, not tags:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - uses mutable tag
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# ✅ GOOD - pinned to immutable commit SHA
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Secure Variable Handling
|
|
||||||
|
|
||||||
Prevent command injection by properly quoting variables:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - potential command injection
|
|
||||||
run: echo "Processing ${{ inputs.user_input }}"
|
|
||||||
|
|
||||||
# ✅ GOOD - properly quoted
|
|
||||||
env:
|
|
||||||
USER_INPUT: ${{ inputs.user_input }}
|
|
||||||
run: echo "Processing ${USER_INPUT}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
|
|
||||||
|
|
||||||
### 5. Environment Variables for Secrets
|
|
||||||
|
|
||||||
Store sensitive data in environment variables, not inline:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD
|
|
||||||
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
|
|
||||||
|
|
||||||
# ✅ GOOD
|
|
||||||
env:
|
|
||||||
API_TOKEN: ${{ secrets.TOKEN }}
|
|
||||||
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Structure Best Practices
|
|
||||||
|
|
||||||
### Required Workflow Elements
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: "Descriptive Workflow Name"
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Define specific triggers
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
# Always set explicit permissions
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
job-name:
|
|
||||||
name: "Descriptive Job Name"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30 # tune per job; standardize repo-wide
|
|
||||||
|
|
||||||
# Set job-level permissions if different from workflow level
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# Always start with Harden-Runner on ubuntu-latest
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@v2
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
# Pin all actions to commit SHA
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Input Validation for Actions
|
|
||||||
|
|
||||||
For composite actions, always validate inputs:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
inputs:
|
|
||||||
user_input:
|
|
||||||
description: "User provided input"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: "composite"
|
|
||||||
steps:
|
|
||||||
- name: Validate input
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Harden shell and validate input format/content before use
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
USER_INPUT="${{ inputs.user_input }}"
|
|
||||||
|
|
||||||
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
||||||
echo "❌ Invalid input format"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Security in Actions
|
|
||||||
|
|
||||||
### Pin Docker Images to Digests
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - mutable tag
|
|
||||||
container: node:18
|
|
||||||
|
|
||||||
# ✅ GOOD - pinned to digest
|
|
||||||
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Secure File Operations
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Process files securely
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
FILE_PATH: ${{ inputs.file_path }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail # Fail on errors, undefined vars, pipe failures
|
|
||||||
|
|
||||||
# Use absolute paths and validate
|
|
||||||
SAFE_PATH=$(realpath "${FILE_PATH}")
|
|
||||||
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
|
|
||||||
echo "❌ Path outside workspace"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Artifact Handling
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Upload artifacts securely
|
|
||||||
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
|
|
||||||
with:
|
|
||||||
name: build-artifacts
|
|
||||||
path: |
|
|
||||||
dist/
|
|
||||||
!dist/**/*.log # Exclude sensitive files
|
|
||||||
retention-days: 30
|
|
||||||
```
|
|
||||||
|
|
||||||
### GHCR authentication for pulls/scans
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Minimal permissions required for GHCR pulls/scans
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Checklist
|
|
||||||
|
|
||||||
- [ ] Minimum GITHUB_TOKEN permissions set
|
|
||||||
- [ ] Harden-Runner added to all ubuntu-latest jobs
|
|
||||||
- [ ] All third-party actions pinned to commit SHA
|
|
||||||
- [ ] Input validation implemented for custom actions
|
|
||||||
- [ ] Variables properly quoted in shell scripts
|
|
||||||
- [ ] Secrets stored in environment variables
|
|
||||||
- [ ] Docker images pinned to digests (if used)
|
|
||||||
- [ ] Error handling with `set -euo pipefail`
|
|
||||||
- [ ] File paths validated and sanitized
|
|
||||||
- [ ] No sensitive data in logs or outputs
|
|
||||||
- [ ] GHCR login performed before pulls/scans (packages: read)
|
|
||||||
- [ ] Job timeouts configured (`timeout-minutes`)
|
|
||||||
|
|
||||||
## Recommended Additional Workflows
|
|
||||||
|
|
||||||
Consider adding these security-focused workflows to your repository:
|
|
||||||
|
|
||||||
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
|
|
||||||
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
|
|
||||||
3. **Dependabot Configuration** - Automated dependency updates
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
|
|
||||||
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
|
|
||||||
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
---
|
|
||||||
title: i18n Management with Lingo.dev
|
|
||||||
description: Guidelines for managing internationalization (i18n) with Lingo.dev, including translation workflow, key validation, and best practices
|
|
||||||
---
|
|
||||||
|
|
||||||
# i18n Management with Lingo.dev
|
|
||||||
|
|
||||||
This rule defines the workflow and best practices for managing internationalization (i18n) in the Formbricks project using Lingo.dev.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Formbricks uses [Lingo.dev](https://lingo.dev) for managing translations across multiple languages. The translation workflow includes:
|
|
||||||
|
|
||||||
1. **Translation Keys**: Defined in code using the `t()` function from `react-i18next`
|
|
||||||
2. **Translation Files**: JSON files stored in `apps/web/locales/` for each supported language
|
|
||||||
3. **Validation**: Automated scanning to detect missing and unused translation keys
|
|
||||||
4. **CI/CD**: Pre-commit hooks and GitHub Actions to enforce translation quality
|
|
||||||
|
|
||||||
## Translation Workflow
|
|
||||||
|
|
||||||
### 1. Using Translations in Code
|
|
||||||
|
|
||||||
When adding translatable text in the web app, use the `t()` function or `<Trans>` component:
|
|
||||||
|
|
||||||
**Using the `t()` function:**
|
|
||||||
```tsx
|
|
||||||
import { useTranslate } from "@/lib/i18n/translate";
|
|
||||||
|
|
||||||
const MyComponent = () => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>{t("common.welcome")}</h1>
|
|
||||||
<p>{t("pages.dashboard.description")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Using the `<Trans>` component (for text with HTML elements):**
|
|
||||||
```tsx
|
|
||||||
import { Trans } from "react-i18next";
|
|
||||||
|
|
||||||
const MyComponent = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
<Trans
|
|
||||||
i18nKey="auth.terms_agreement"
|
|
||||||
components={{
|
|
||||||
link: <a href="/terms" />,
|
|
||||||
b: <b />
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Naming Conventions:**
|
|
||||||
- Use dot notation for nested keys: `section.subsection.key`
|
|
||||||
- Use descriptive names: `auth.login.success_message` not `auth.msg1`
|
|
||||||
- Group related keys together: `auth.*`, `errors.*`, `common.*`
|
|
||||||
- Use lowercase with underscores: `user_profile_settings` not `UserProfileSettings`
|
|
||||||
|
|
||||||
### 2. Translation File Structure
|
|
||||||
|
|
||||||
Translation files are located in `apps/web/locales/` and use the following naming convention:
|
|
||||||
- `en-US.json` (English - United States, default)
|
|
||||||
- `de-DE.json` (German)
|
|
||||||
- `fr-FR.json` (French)
|
|
||||||
- `pt-BR.json` (Portuguese - Brazil)
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
**File Structure:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"common": {
|
|
||||||
"welcome": "Welcome",
|
|
||||||
"save": "Save",
|
|
||||||
"cancel": "Cancel"
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"login": {
|
|
||||||
"title": "Login",
|
|
||||||
"email_placeholder": "Enter your email",
|
|
||||||
"password_placeholder": "Enter your password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Adding New Translation Keys
|
|
||||||
|
|
||||||
When adding new translation keys:
|
|
||||||
|
|
||||||
1. **Add the key in your code** using `t("your.new.key")`
|
|
||||||
2. **Add translation for that key in en-US.json file**
|
|
||||||
3. **Run the translation workflow:**
|
|
||||||
```bash
|
|
||||||
pnpm i18n
|
|
||||||
```
|
|
||||||
This will:
|
|
||||||
- Generate translations for all languages using Lingo.dev
|
|
||||||
- Validate that all keys are present and used
|
|
||||||
|
|
||||||
4. **Review and commit** the generated translation files
|
|
||||||
|
|
||||||
### 4. Available Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate translations using Lingo.dev
|
|
||||||
pnpm generate-translations
|
|
||||||
|
|
||||||
# Scan and validate translation keys
|
|
||||||
pnpm scan-translations
|
|
||||||
|
|
||||||
# Full workflow: generate + validate
|
|
||||||
pnpm i18n
|
|
||||||
|
|
||||||
# Validate only (without generation)
|
|
||||||
pnpm i18n:validate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Translation Key Validation
|
|
||||||
|
|
||||||
### Automated Validation
|
|
||||||
|
|
||||||
The project includes automated validation that runs:
|
|
||||||
- **Pre-commit hook**: Validates translations before allowing commits (when `LINGODOTDEV_API_KEY` is set)
|
|
||||||
- **GitHub Actions**: Validates translations on every PR and push to main
|
|
||||||
|
|
||||||
### Validation Rules
|
|
||||||
|
|
||||||
The validation script (`scan-translations.ts`) checks for:
|
|
||||||
|
|
||||||
1. **Missing Keys**: Translation keys used in code but not present in translation files
|
|
||||||
2. **Unused Keys**: Translation keys present in translation files but not used in code
|
|
||||||
3. **Incomplete Translations**: Keys that exist in the default language (`en-US`) but are missing in target languages
|
|
||||||
|
|
||||||
**What gets scanned:**
|
|
||||||
- All `.ts` and `.tsx` files in `apps/web/`
|
|
||||||
- Both `t()` function calls and `<Trans i18nKey="">` components
|
|
||||||
- All locale files (`de-DE.json`, `fr-FR.json`, `ja-JP.json`, etc.)
|
|
||||||
|
|
||||||
**What gets excluded:**
|
|
||||||
- Test files (`*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`)
|
|
||||||
- Build directories (`node_modules`, `dist`, `build`, `.next`, `coverage`)
|
|
||||||
- Locale files themselves (from code scanning)
|
|
||||||
|
|
||||||
**Note:** Test files are excluded because they often use mock or example translation keys for testing purposes that don't need to exist in production translation files.
|
|
||||||
|
|
||||||
### Fixing Validation Errors
|
|
||||||
|
|
||||||
#### Missing Keys
|
|
||||||
|
|
||||||
If you encounter missing key errors:
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ MISSING KEYS (2):
|
|
||||||
|
|
||||||
These keys are used in code but not found in translation files:
|
|
||||||
|
|
||||||
• auth.signup.email_required
|
|
||||||
• settings.profile.update_success
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
1. Ensure that translations for those keys are present in en-US.json .
|
|
||||||
2. Run `pnpm generate-translations` to have Lingo.dev generate the missing translations
|
|
||||||
3. OR manually add the keys to `apps/web/locales/en-US.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"signup": {
|
|
||||||
"email_required": "Email is required"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"profile": {
|
|
||||||
"update_success": "Profile updated successfully"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. Run `pnpm scan-translations` to verify
|
|
||||||
4. Commit the changes
|
|
||||||
|
|
||||||
#### Unused Keys
|
|
||||||
|
|
||||||
If you encounter unused key errors:
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠️ UNUSED KEYS (1):
|
|
||||||
|
|
||||||
These keys exist in translation files but are not used in code:
|
|
||||||
|
|
||||||
• old.deprecated.key
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
1. If the key is truly unused, remove it from all translation files
|
|
||||||
2. If the key should be used, add it to your code using `t("old.deprecated.key")`
|
|
||||||
3. Run `pnpm scan-translations` to verify
|
|
||||||
4. Commit the changes
|
|
||||||
|
|
||||||
#### Incomplete Translations
|
|
||||||
|
|
||||||
If you encounter incomplete translation errors:
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠️ INCOMPLETE TRANSLATIONS:
|
|
||||||
|
|
||||||
Some keys from en-US are missing in target languages:
|
|
||||||
|
|
||||||
📝 de-DE (5 missing keys):
|
|
||||||
• auth.new_feature.title
|
|
||||||
• auth.new_feature.description
|
|
||||||
• settings.advanced.option
|
|
||||||
... and 2 more
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
1. **Recommended:** Run `pnpm generate-translations` to have Lingo.dev automatically translate the missing keys
|
|
||||||
2. **Manual:** Add the missing keys to the target language files:
|
|
||||||
```bash
|
|
||||||
# Copy the structure from en-US.json and translate the values
|
|
||||||
# For example, in de-DE.json:
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"new_feature": {
|
|
||||||
"title": "Neues Feature",
|
|
||||||
"description": "Beschreibung des neuen Features"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. Run `pnpm scan-translations` to verify all translations are complete
|
|
||||||
4. Commit the changes
|
|
||||||
|
|
||||||
## Pre-commit Hook Behavior
|
|
||||||
|
|
||||||
The pre-commit hook will:
|
|
||||||
|
|
||||||
1. Run `lint-staged` for code formatting
|
|
||||||
2. If `LINGODOTDEV_API_KEY` is set:
|
|
||||||
- Generate translations using Lingo.dev
|
|
||||||
- Validate translation keys
|
|
||||||
- Auto-add updated locale files to the commit
|
|
||||||
- **Block the commit** if validation fails
|
|
||||||
3. If `LINGODOTDEV_API_KEY` is not set:
|
|
||||||
- Skip translation validation (for community contributors)
|
|
||||||
- Show a warning message
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
### LINGODOTDEV_API_KEY
|
|
||||||
|
|
||||||
This is the API key for Lingo.dev integration.
|
|
||||||
|
|
||||||
**For Core Team:**
|
|
||||||
- Add to your local `.env` file
|
|
||||||
- Required for running translation generation
|
|
||||||
|
|
||||||
**For Community Contributors:**
|
|
||||||
- Not required for local development
|
|
||||||
- Translation validation will be skipped
|
|
||||||
- The CI will still validate translations
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### 1. Keep Keys Organized
|
|
||||||
|
|
||||||
Group related keys together:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"login": { ... },
|
|
||||||
"signup": { ... },
|
|
||||||
"forgot_password": { ... }
|
|
||||||
},
|
|
||||||
"dashboard": {
|
|
||||||
"header": { ... },
|
|
||||||
"sidebar": { ... }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Avoid Hardcoded Strings
|
|
||||||
|
|
||||||
**❌ Bad:**
|
|
||||||
```tsx
|
|
||||||
<button>Click here</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
**✅ Good:**
|
|
||||||
```tsx
|
|
||||||
<button>{t("common.click_here")}</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Use Interpolation for Dynamic Content
|
|
||||||
|
|
||||||
**❌ Bad:**
|
|
||||||
```tsx
|
|
||||||
{t("welcome")} {userName}!
|
|
||||||
```
|
|
||||||
|
|
||||||
**✅ Good:**
|
|
||||||
```tsx
|
|
||||||
{t("auth.welcome_message", { userName })}
|
|
||||||
```
|
|
||||||
|
|
||||||
With translation:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"welcome_message": "Welcome, {userName}!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Avoid Dynamic Key Construction
|
|
||||||
|
|
||||||
**❌ Bad:**
|
|
||||||
```tsx
|
|
||||||
const key = `errors.${errorCode}`;
|
|
||||||
t(key);
|
|
||||||
```
|
|
||||||
|
|
||||||
**✅ Good:**
|
|
||||||
```tsx
|
|
||||||
switch (errorCode) {
|
|
||||||
case "401":
|
|
||||||
return t("errors.unauthorized");
|
|
||||||
case "404":
|
|
||||||
return t("errors.not_found");
|
|
||||||
default:
|
|
||||||
return t("errors.unknown");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Test Translation Keys
|
|
||||||
|
|
||||||
When adding new features:
|
|
||||||
1. Add translation keys
|
|
||||||
2. Test in multiple languages using the language switcher
|
|
||||||
3. Ensure text doesn't overflow in longer translations (German, French)
|
|
||||||
4. Run `pnpm scan-translations` before committing
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: Pre-commit hook fails with validation errors
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# Run the full i18n workflow
|
|
||||||
pnpm i18n
|
|
||||||
|
|
||||||
# Fix any missing or unused keys
|
|
||||||
# Then commit again
|
|
||||||
git add .
|
|
||||||
git commit -m "your message"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Translation validation passes locally but fails in CI
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Ensure all translation files are committed
|
|
||||||
- Check that `scan-translations.ts` hasn't been modified
|
|
||||||
- Verify that locale files are properly formatted JSON
|
|
||||||
|
|
||||||
### Issue: Cannot commit because of missing translations
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# If you have LINGODOTDEV_API_KEY:
|
|
||||||
pnpm generate-translations
|
|
||||||
|
|
||||||
# If you don't have the API key (community contributor):
|
|
||||||
# Manually add the missing keys to en-US.json
|
|
||||||
# Then run validation:
|
|
||||||
pnpm scan-translations
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Getting "unused keys" for keys that are used
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- The script scans `.ts` and `.tsx` files only
|
|
||||||
- If keys are used in other file types, they may be flagged
|
|
||||||
- Verify the key is actually used with `grep -r "your.key" apps/web/`
|
|
||||||
- If it's a false positive, consider updating the scanning patterns in `scan-translations.ts`
|
|
||||||
|
|
||||||
## AI Assistant Guidelines
|
|
||||||
|
|
||||||
When assisting with i18n-related tasks, always:
|
|
||||||
|
|
||||||
1. **Use the `t()` function** for all user-facing text
|
|
||||||
2. **Follow key naming conventions** (lowercase, dots for nesting)
|
|
||||||
3. **Run validation** after making changes: `pnpm scan-translations`
|
|
||||||
4. **Fix missing keys** by adding them to `en-US.json`
|
|
||||||
5. **Remove unused keys** from all translation files
|
|
||||||
6. **Test the pre-commit hook** if making changes to translation workflow
|
|
||||||
7. **Update this rule file** if translation workflow changes
|
|
||||||
|
|
||||||
### Fixing Missing Translation Keys
|
|
||||||
|
|
||||||
When the AI encounters missing translation key errors:
|
|
||||||
|
|
||||||
1. Identify the missing keys from the error output
|
|
||||||
2. Determine the appropriate section and naming for each key
|
|
||||||
3. Add the keys to `apps/web/locales/en-US.json` with meaningful English text
|
|
||||||
4. Ensure proper JSON structure and nesting
|
|
||||||
5. Run `pnpm scan-translations` to verify
|
|
||||||
6. Inform the user that other language files will be updated via Lingo.dev
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```typescript
|
|
||||||
// Error: Missing key "settings.api.rate_limit_exceeded"
|
|
||||||
|
|
||||||
// Add to en-US.json:
|
|
||||||
{
|
|
||||||
"settings": {
|
|
||||||
"api": {
|
|
||||||
"rate_limit_exceeded": "API rate limit exceeded. Please try again later."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Removing Unused Translation Keys
|
|
||||||
|
|
||||||
When the AI encounters unused translation key errors:
|
|
||||||
|
|
||||||
1. Verify the keys are truly unused by searching the codebase
|
|
||||||
2. Remove the keys from `apps/web/locales/en-US.json`
|
|
||||||
3. Note that removal from other language files can be handled via Lingo.dev
|
|
||||||
4. Run `pnpm scan-translations` to verify
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
This project previously used Tolgee for translations. As of this migration:
|
|
||||||
|
|
||||||
- **Old scripts**: `tolgee-pull` is deprecated (kept for reference)
|
|
||||||
- **New scripts**: Use `pnpm i18n` or `pnpm generate-translations`
|
|
||||||
- **Old workflows**: `tolgee.yml` and `tolgee-missing-key-check.yml` removed
|
|
||||||
- **New workflow**: `translation-check.yml` handles all validation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** October 14, 2025
|
|
||||||
**Related Files:**
|
|
||||||
- `scan-translations.ts` - Translation validation script
|
|
||||||
- `.husky/pre-commit` - Pre-commit hook with i18n validation
|
|
||||||
- `.github/workflows/translation-check.yml` - CI workflow for translation validation
|
|
||||||
- `apps/web/locales/*.json` - Translation files
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
---
|
|
||||||
description: Apply these quality standards before finalizing code changes to ensure DRY principles, React best practices, TypeScript conventions, and maintainable code.
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Review & Refine
|
|
||||||
|
|
||||||
Before finalizing any code changes, review your implementation against these quality standards:
|
|
||||||
|
|
||||||
## Core Principles
|
|
||||||
|
|
||||||
### DRY (Don't Repeat Yourself)
|
|
||||||
|
|
||||||
- Extract duplicated logic into reusable functions or hooks
|
|
||||||
- If the same code appears in multiple places, consolidate it
|
|
||||||
- Create helper functions at appropriate scope (component-level, module-level, or utility files)
|
|
||||||
- Avoid copy-pasting code blocks
|
|
||||||
|
|
||||||
### Code Reduction
|
|
||||||
|
|
||||||
- Remove unnecessary code, comments, and abstractions
|
|
||||||
- Prefer built-in solutions over custom implementations
|
|
||||||
- Consolidate similar logic
|
|
||||||
- Remove dead code and unused imports
|
|
||||||
- Question if every line of code is truly needed
|
|
||||||
|
|
||||||
## React Best Practices
|
|
||||||
|
|
||||||
### Component Design
|
|
||||||
|
|
||||||
- Keep components focused on a single responsibility
|
|
||||||
- Extract complex logic into custom hooks
|
|
||||||
- Prefer composition over prop drilling
|
|
||||||
- Use children props and render props when appropriate
|
|
||||||
- Keep component files under 300 lines when possible
|
|
||||||
|
|
||||||
### Hooks Usage
|
|
||||||
|
|
||||||
- Follow Rules of Hooks (only call at top level, only in React functions)
|
|
||||||
- Extract complex `useEffect` logic into custom hooks
|
|
||||||
- Use `useMemo` and `useCallback` only when you have a measured performance issue
|
|
||||||
- Declare dependencies arrays correctly - don't ignore exhaustive-deps warnings
|
|
||||||
- Keep `useEffect` focused on a single concern
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
|
|
||||||
- Colocate state as close as possible to where it's used
|
|
||||||
- Lift state only when necessary
|
|
||||||
- Use `useReducer` for complex state logic with multiple sub-values
|
|
||||||
- Avoid derived state - compute values during render instead
|
|
||||||
- Don't store values in state that can be computed from props
|
|
||||||
|
|
||||||
### Event Handlers
|
|
||||||
|
|
||||||
- Name event handlers with `handle` prefix (e.g., `handleClick`, `handleSubmit`)
|
|
||||||
- Extract complex event handler logic into separate functions
|
|
||||||
- Avoid inline arrow functions in JSX when they contain complex logic
|
|
||||||
|
|
||||||
## TypeScript Best Practices
|
|
||||||
|
|
||||||
### Type Safety
|
|
||||||
|
|
||||||
- Prefer type inference over explicit types when possible
|
|
||||||
- Use `const` assertions for literal types
|
|
||||||
- Avoid `any` - use `unknown` if type is truly unknown
|
|
||||||
- Use discriminated unions for complex conditional logic
|
|
||||||
- Leverage type guards and narrowing
|
|
||||||
|
|
||||||
### Interface & Type Usage
|
|
||||||
|
|
||||||
- Use existing types from `@formbricks/types` - don't recreate them
|
|
||||||
- Prefer `interface` for object shapes that might be extended
|
|
||||||
- Prefer `type` for unions, intersections, and mapped types
|
|
||||||
- Define types close to where they're used unless they're shared
|
|
||||||
- Export types from index files for shared types
|
|
||||||
|
|
||||||
### Type Assertions
|
|
||||||
|
|
||||||
- Avoid type assertions (`as`) when possible
|
|
||||||
- Use type guards instead of assertions
|
|
||||||
- Only assert when you have more information than TypeScript
|
|
||||||
|
|
||||||
## Code Organization
|
|
||||||
|
|
||||||
### Separation of Concerns
|
|
||||||
|
|
||||||
- Separate business logic from UI rendering
|
|
||||||
- Extract API calls into separate functions or modules
|
|
||||||
- Keep data transformation separate from component logic
|
|
||||||
- Use custom hooks for stateful logic that doesn't render UI
|
|
||||||
|
|
||||||
### Function Clarity
|
|
||||||
|
|
||||||
- Functions should do one thing well
|
|
||||||
- Name functions clearly and descriptively
|
|
||||||
- Keep functions small (aim for under 20 lines)
|
|
||||||
- Extract complex conditionals into named boolean variables or functions
|
|
||||||
- Avoid deep nesting (max 3 levels)
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
|
|
||||||
- Group related functions together
|
|
||||||
- Order declarations logically (types → hooks → helpers → component)
|
|
||||||
- Keep imports organized (external → internal → relative)
|
|
||||||
- Consider splitting large files by concern
|
|
||||||
|
|
||||||
## Additional Quality Checks
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- Don't optimize prematurely - measure first
|
|
||||||
- Avoid creating new objects/arrays/functions in render unnecessarily
|
|
||||||
- Use keys properly in lists (stable, unique identifiers)
|
|
||||||
- Lazy load heavy components when appropriate
|
|
||||||
|
|
||||||
### Accessibility
|
|
||||||
|
|
||||||
- Use semantic HTML elements
|
|
||||||
- Include ARIA labels where needed
|
|
||||||
- Ensure keyboard navigation works
|
|
||||||
- Check color contrast and focus states
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- Handle error states in components
|
|
||||||
- Provide user feedback for failed operations
|
|
||||||
- Use error boundaries for component errors
|
|
||||||
- Log errors appropriately (avoid swallowing errors silently)
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
- Use descriptive names (avoid abbreviations unless very common)
|
|
||||||
- Boolean variables/props should sound like yes/no questions (`isLoading`, `hasError`, `canEdit`)
|
|
||||||
- Arrays should be plural (`users`, `choices`, `items`)
|
|
||||||
- Event handlers: `handleX` in components, `onX` for props
|
|
||||||
- Constants in UPPER_SNAKE_CASE only for true constants
|
|
||||||
|
|
||||||
### Code Readability
|
|
||||||
|
|
||||||
- Prefer early returns to reduce nesting
|
|
||||||
- Use destructuring to make code clearer
|
|
||||||
- Break complex expressions into named variables
|
|
||||||
- Add comments only when code can't be made self-explanatory
|
|
||||||
- Use whitespace to group related code
|
|
||||||
|
|
||||||
### Testing Considerations
|
|
||||||
|
|
||||||
- Write code that's easy to test (pure functions, clear inputs/outputs)
|
|
||||||
- Avoid hard-to-mock dependencies when possible
|
|
||||||
- Keep side effects at the edges of your code
|
|
||||||
|
|
||||||
## Review Checklist
|
|
||||||
|
|
||||||
Before submitting your changes, ask yourself:
|
|
||||||
|
|
||||||
1. **DRY**: Is there any duplicated logic I can extract?
|
|
||||||
2. **Clarity**: Would another developer understand this code easily?
|
|
||||||
3. **Simplicity**: Is this the simplest solution that works?
|
|
||||||
4. **Types**: Am I using TypeScript effectively?
|
|
||||||
5. **React**: Am I following React idioms and best practices?
|
|
||||||
6. **Performance**: Are there obvious performance issues?
|
|
||||||
7. **Separation**: Are concerns properly separated?
|
|
||||||
8. **Testing**: Is this code testable?
|
|
||||||
9. **Maintenance**: Will this be easy to change in 6 months?
|
|
||||||
10. **Deletion**: Can I remove any code and still accomplish the goal?
|
|
||||||
|
|
||||||
## When to Apply This Rule
|
|
||||||
|
|
||||||
Apply this rule:
|
|
||||||
|
|
||||||
- After implementing a feature but before marking it complete
|
|
||||||
- When you notice your code feels "messy" or complex
|
|
||||||
- Before requesting code review
|
|
||||||
- When you see yourself copy-pasting code
|
|
||||||
- After receiving feedback about code quality
|
|
||||||
|
|
||||||
Don't let perfect be the enemy of good, but always strive for:
|
|
||||||
**Simple, readable, maintainable code that does one thing well.**
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
---
|
|
||||||
description: Migrate deprecated UI components to a unified component
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Component Migration Automation Rule
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases.
|
|
||||||
|
|
||||||
## Trigger
|
|
||||||
When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration").
|
|
||||||
|
|
||||||
## Process
|
|
||||||
|
|
||||||
### Step 1: Discovery and Planning
|
|
||||||
1. **Identify migration parameters:**
|
|
||||||
- Ask user for deprecated component name (e.g., "Modal")
|
|
||||||
- Ask user for new component name(s) (e.g., "Dialog")
|
|
||||||
- Ask for any components to exclude (e.g., "ModalWithTabs")
|
|
||||||
- Ask for specific import paths if needed
|
|
||||||
|
|
||||||
2. **Scan codebase** for deprecated components:
|
|
||||||
- Search for `import.*[DeprecatedComponent]` patterns
|
|
||||||
- Exclude specified components that should not be migrated
|
|
||||||
- List all found components with file paths
|
|
||||||
- Present numbered list to user for confirmation
|
|
||||||
|
|
||||||
### Step 2: Component-by-Component Migration
|
|
||||||
For each component, follow this exact sequence:
|
|
||||||
|
|
||||||
#### 2.1 Component Migration
|
|
||||||
- **Import changes:**
|
|
||||||
- Ask user to provide the new import structure
|
|
||||||
- Example transformation pattern:
|
|
||||||
```typescript
|
|
||||||
// FROM:
|
|
||||||
import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]"
|
|
||||||
|
|
||||||
// TO:
|
|
||||||
import {
|
|
||||||
[NewComponent],
|
|
||||||
[NewComponentPart1],
|
|
||||||
[NewComponentPart2],
|
|
||||||
// ... other parts
|
|
||||||
} from "@/components/ui/[NewComponent]"
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Props transformation:**
|
|
||||||
- Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`)
|
|
||||||
- Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`)
|
|
||||||
- Apply transformations based on user specifications
|
|
||||||
|
|
||||||
- **Structure transformation:**
|
|
||||||
- Ask user for the new component structure pattern
|
|
||||||
- Apply the transformation maintaining all functionality
|
|
||||||
- Preserve all existing logic, state management, and event handlers
|
|
||||||
|
|
||||||
#### 2.2 Wait for User Approval
|
|
||||||
- Present the migration changes
|
|
||||||
- Wait for explicit user approval before proceeding
|
|
||||||
- If rejected, ask for specific feedback and iterate
|
|
||||||
#### 2.3 Re-read and Apply Additional Changes
|
|
||||||
- Re-read the component file to capture any user modifications
|
|
||||||
- Apply any additional improvements the user made
|
|
||||||
- Ensure all changes are incorporated
|
|
||||||
|
|
||||||
#### 2.4 Test File Updates
|
|
||||||
- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`)
|
|
||||||
- **Update test mocks:**
|
|
||||||
- Ask user for new component mock structure
|
|
||||||
- Replace old component mocks with new ones
|
|
||||||
- Example pattern:
|
|
||||||
```typescript
|
|
||||||
// Add to test setup:
|
|
||||||
jest.mock("@/components/ui/[NewComponent]", () => ({
|
|
||||||
[NewComponent]: ({ children, [props] }: any) => ([mock implementation]),
|
|
||||||
[NewComponentPart1]: ({ children }: any) => <div data-testid="[new-component-part1]">{children}</div>,
|
|
||||||
[NewComponentPart2]: ({ children }: any) => <div data-testid="[new-component-part2]">{children}</div>,
|
|
||||||
// ... other parts
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
- **Update test expectations:**
|
|
||||||
- Change test IDs from old component to new component
|
|
||||||
- Update any component-specific assertions
|
|
||||||
- Ensure all new component parts used in the component are mocked
|
|
||||||
|
|
||||||
#### 2.5 Run Tests and Optimize
|
|
||||||
- Execute `Node package manager test -- ComponentName.test.tsx`
|
|
||||||
- Fix any failing tests
|
|
||||||
- Optimize code quality (imports, formatting, etc.)
|
|
||||||
- Re-run tests until all pass
|
|
||||||
- **Maximum 3 iterations** - if still failing, ask user for guidance
|
|
||||||
|
|
||||||
#### 2.6 Wait for Final Approval
|
|
||||||
- Present test results and any optimizations made
|
|
||||||
- Wait for user approval of the complete migration
|
|
||||||
- If rejected, iterate based on feedback
|
|
||||||
|
|
||||||
#### 2.7 Git Commit
|
|
||||||
- Run: `git add .`
|
|
||||||
- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"`
|
|
||||||
- Confirm commit was successful
|
|
||||||
|
|
||||||
### Step 3: Final Report Generation
|
|
||||||
After all components are migrated, generate a comprehensive GitHub PR report:
|
|
||||||
|
|
||||||
#### PR Title
|
|
||||||
```
|
|
||||||
feat: migrate [DeprecatedComponent] components to [NewComponent] system
|
|
||||||
```
|
|
||||||
|
|
||||||
#### PR Description Template
|
|
||||||
```markdown
|
|
||||||
## 🔄 [DeprecatedComponent] to [NewComponent] Migration
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency.
|
|
||||||
|
|
||||||
### Components Migrated
|
|
||||||
[List each component with file path]
|
|
||||||
|
|
||||||
### Technical Changes
|
|
||||||
- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]`
|
|
||||||
- **Props:** [List prop transformations]
|
|
||||||
- **Structure:** Implemented proper [NewComponent] component hierarchy
|
|
||||||
- **Styling:** [Describe styling changes]
|
|
||||||
- **Tests:** Updated all test mocks and expectations
|
|
||||||
|
|
||||||
### Migration Pattern
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
<[DeprecatedComponent] [oldProps]>
|
|
||||||
[oldStructure]
|
|
||||||
</[DeprecatedComponent]>
|
|
||||||
|
|
||||||
// After
|
|
||||||
<[NewComponent] [newProps]>
|
|
||||||
[newStructure]
|
|
||||||
</[NewComponent]>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- ✅ All existing tests updated and passing
|
|
||||||
- ✅ Component functionality preserved
|
|
||||||
- ✅ UI/UX behavior maintained
|
|
||||||
|
|
||||||
### How to Test This PR
|
|
||||||
1. **Functional Testing:**
|
|
||||||
- Navigate to each migrated component's usage
|
|
||||||
- Verify [component] opens and closes correctly
|
|
||||||
- Test all interactive elements within [components]
|
|
||||||
- Confirm styling and layout are preserved
|
|
||||||
|
|
||||||
2. **Automated Testing:**
|
|
||||||
```bash
|
|
||||||
Node package manager test
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Visual Testing:**
|
|
||||||
- Check that all [components] maintain proper styling
|
|
||||||
- Verify responsive behavior
|
|
||||||
- Test keyboard navigation and accessibility
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."]
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- [Any excluded components] were preserved as they already use [NewComponent] internally
|
|
||||||
- All form validation and complex state management preserved
|
|
||||||
- Enhanced code quality with better imports and formatting
|
|
||||||
```
|
|
||||||
|
|
||||||
## Special Considerations
|
|
||||||
|
|
||||||
### Excluded Components
|
|
||||||
- **DO NOT MIGRATE** components specified by user as exclusions
|
|
||||||
- They may already use the new component internally or have other reasons
|
|
||||||
- Inform user these are skipped and why
|
|
||||||
|
|
||||||
### Complex Components
|
|
||||||
- Preserve all existing functionality (forms, validation, state management)
|
|
||||||
- Maintain prop interfaces
|
|
||||||
- Keep all event handlers and callbacks
|
|
||||||
- Preserve accessibility features
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
- Ensure all new component parts are mocked when used
|
|
||||||
- Mock all new component parts that appear in the component
|
|
||||||
- Update test IDs from old component to new component
|
|
||||||
- Maintain all existing test scenarios
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- If tests fail after 3 iterations, stop and ask user for guidance
|
|
||||||
- If component is too complex, ask user for specific guidance
|
|
||||||
- If unsure about functionality preservation, ask for clarification
|
|
||||||
|
|
||||||
### Migration Patterns
|
|
||||||
- Always ask user for specific migration patterns before starting
|
|
||||||
- Confirm import structures, prop mappings, and component hierarchies
|
|
||||||
- Adapt to different component architectures (simple replacements, complex restructuring, etc.)
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
- All deprecated components successfully migrated to new components
|
|
||||||
- All tests passing
|
|
||||||
- No functionality lost
|
|
||||||
- Code quality maintained or improved
|
|
||||||
- User approval on each component
|
|
||||||
- Successful git commits for each migration
|
|
||||||
- Comprehensive PR report generated
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
- "migrate Modal to Dialog"
|
|
||||||
- "migrate Button to NewButton"
|
|
||||||
- "migrate Card to ModernCard"
|
|
||||||
- "component migration" (will prompt for details)
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
---
|
|
||||||
description: Create a story in Storybook for a given component
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Formbricks Storybook Stories
|
|
||||||
|
|
||||||
## When generating Storybook stories for Formbricks components:
|
|
||||||
|
|
||||||
### 1. **File Structure**
|
|
||||||
- Create `stories.tsx` (not `.stories.tsx`) in component directory
|
|
||||||
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
|
|
||||||
- Import component from `"./index"`
|
|
||||||
|
|
||||||
### 2. **Story Structure Template**
|
|
||||||
```tsx
|
|
||||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
|
||||||
import { ComponentName } from "./index";
|
|
||||||
|
|
||||||
// For complex components with configurable options
|
|
||||||
// consider this as an example the options need to reflect the props types
|
|
||||||
interface StoryOptions {
|
|
||||||
showIcon: boolean;
|
|
||||||
numberOfElements: number;
|
|
||||||
customLabels: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
|
|
||||||
|
|
||||||
const meta: Meta<StoryProps> = {
|
|
||||||
title: "UI/ComponentName",
|
|
||||||
component: ComponentName,
|
|
||||||
tags: ["autodocs"],
|
|
||||||
parameters: {
|
|
||||||
layout: "centered",
|
|
||||||
controls: { sort: "alpha", exclude: [] },
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component: "The **ComponentName** component provides [description].",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
// Organize in exactly these categories: Behavior, Appearance, Content
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **ArgTypes Organization**
|
|
||||||
Organize ALL argTypes into exactly three categories:
|
|
||||||
- **Behavior**: disabled, variant, onChange, etc.
|
|
||||||
- **Appearance**: size, color, layout, styling, etc.
|
|
||||||
- **Content**: text, icons, numberOfElements, etc.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
```tsx
|
|
||||||
argTypes: {
|
|
||||||
propName: {
|
|
||||||
control: "select" | "boolean" | "text" | "number",
|
|
||||||
options: ["option1", "option2"], // for select
|
|
||||||
description: "Clear description",
|
|
||||||
table: {
|
|
||||||
category: "Behavior" | "Appearance" | "Content",
|
|
||||||
type: { summary: "string" },
|
|
||||||
defaultValue: { summary: "default" },
|
|
||||||
},
|
|
||||||
order: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Required Stories**
|
|
||||||
Every component must include:
|
|
||||||
- `Default`: Most common use case
|
|
||||||
- `Disabled`: If component supports disabled state
|
|
||||||
- `WithIcon`: If component supports icons
|
|
||||||
- Variant stories for each variant (Primary, Secondary, Error, etc.)
|
|
||||||
- Edge case stories (ManyElements, LongText, CustomStyling)
|
|
||||||
|
|
||||||
### 5. **Story Format**
|
|
||||||
```tsx
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
// Props with realistic values
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EdgeCase: Story = {
|
|
||||||
args: { /* ... */ },
|
|
||||||
parameters: {
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
story: "Use this when [specific scenario].",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. **Dynamic Content Pattern**
|
|
||||||
For components with dynamic content, create render function:
|
|
||||||
```tsx
|
|
||||||
const renderComponent = (args: StoryProps) => {
|
|
||||||
const { numberOfElements, showIcon, customLabels } = args;
|
|
||||||
|
|
||||||
// Generate dynamic content
|
|
||||||
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
|
|
||||||
id: `element-${i}`,
|
|
||||||
label: customLabels[i] || `Element ${i + 1}`,
|
|
||||||
icon: showIcon ? <IconComponent /> : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return <ComponentName {...args} elements={elements} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Dynamic: Story = {
|
|
||||||
render: renderComponent,
|
|
||||||
args: {
|
|
||||||
numberOfElements: 3,
|
|
||||||
showIcon: true,
|
|
||||||
customLabels: ["First", "Second", "Third"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. **State Management**
|
|
||||||
For interactive components:
|
|
||||||
```tsx
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const ComponentWithState = (args: any) => {
|
|
||||||
const [value, setValue] = useState(args.defaultValue);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComponentName
|
|
||||||
{...args}
|
|
||||||
value={value}
|
|
||||||
onChange={(newValue) => {
|
|
||||||
setValue(newValue);
|
|
||||||
args.onChange?.(newValue);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Interactive: Story = {
|
|
||||||
render: ComponentWithState,
|
|
||||||
args: { defaultValue: "initial" },
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. **Quality Requirements**
|
|
||||||
- Include component description in parameters.docs
|
|
||||||
- Add story documentation for non-obvious use cases
|
|
||||||
- Test edge cases (overflow, empty states, many elements)
|
|
||||||
- Ensure no TypeScript errors
|
|
||||||
- Use realistic prop values
|
|
||||||
- Include at least 3-5 story variants
|
|
||||||
- Example values need to be in the context of survey application
|
|
||||||
|
|
||||||
### 9. **Naming Conventions**
|
|
||||||
- **Story titles**: "UI/ComponentName"
|
|
||||||
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
|
|
||||||
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
|
|
||||||
- **Props**: camelCase matching component props
|
|
||||||
|
|
||||||
### 10. **Special Cases**
|
|
||||||
- **Generic components**: Remove `component` from meta if type conflicts
|
|
||||||
- **Form components**: Include Invalid, WithValue stories
|
|
||||||
- **Navigation**: Include ManyItems stories
|
|
||||||
- **Modals, Dropdowns and Popups **: Include trigger and content structure
|
|
||||||
|
|
||||||
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.
|
|
||||||
@@ -168,6 +168,9 @@ SLACK_CLIENT_SECRET=
|
|||||||
# Enterprise License Key
|
# Enterprise License Key
|
||||||
ENTERPRISE_LICENSE_KEY=
|
ENTERPRISE_LICENSE_KEY=
|
||||||
|
|
||||||
|
# Internal Environment (production, staging) - used for internal staging environment
|
||||||
|
# ENVIRONMENT=production
|
||||||
|
|
||||||
# Automatically assign new users to a specific organization and role within that organization
|
# 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)
|
# 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)
|
# (Role Management is an Enterprise feature)
|
||||||
|
|||||||
31
.github/workflows/chromatic.yml
vendored
31
.github/workflows/chromatic.yml
vendored
@@ -13,13 +13,12 @@ jobs:
|
|||||||
chromatic:
|
chromatic:
|
||||||
name: Run Chromatic
|
name: Run Chromatic
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
contents: read
|
||||||
id-token: write
|
|
||||||
actions: read
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
@@ -27,16 +26,34 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: ${{ env.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: Run Chromatic
|
- name: Run Chromatic
|
||||||
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
|
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||||
with:
|
with:
|
||||||
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
|
|
||||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||||
workingDir: apps/storybook
|
workingDir: apps/storybook
|
||||||
|
zip: true
|
||||||
|
|||||||
36
.github/workflows/pr-size-check.yml
vendored
36
.github/workflows/pr-size-check.yml
vendored
@@ -111,27 +111,21 @@ jobs:
|
|||||||
const additions = ${{ steps.check-size.outputs.total_additions }};
|
const additions = ${{ steps.check-size.outputs.total_additions }};
|
||||||
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
||||||
|
|
||||||
const body = `## 🚨 PR Size Warning
|
const body = '## 🚨 PR Size Warning\n\n' +
|
||||||
|
'This PR has approximately **' + totalChanges + ' lines** of changes (' + additions + ' additions, ' + deletions + ' deletions across ' + countedFiles + ' files).\n\n' +
|
||||||
This PR has approximately **${totalChanges} lines** of changes (${additions} additions, ${deletions} deletions across ${countedFiles} files).
|
'Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.\n\n' +
|
||||||
|
'### 💡 Suggestions:\n' +
|
||||||
Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.
|
'- **Split by feature or module** - Break down into logical, independent pieces\n' +
|
||||||
|
'- **Create a sequence of PRs** - Each building on the previous one\n' +
|
||||||
### 💡 Suggestions:
|
'- **Branch off PR branches** - Don\'t wait for reviews to continue dependent work\n\n' +
|
||||||
- **Split by feature or module** - Break down into logical, independent pieces
|
'### 📊 What was counted:\n' +
|
||||||
- **Create a sequence of PRs** - Each building on the previous one
|
'- ✅ Source files, stylesheets, configuration files\n' +
|
||||||
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
|
'- ❌ Excluded ' + excludedFiles + ' files (tests, locales, locks, generated files)\n\n' +
|
||||||
|
'### 📚 Guidelines:\n' +
|
||||||
### 📊 What was counted:
|
'- **Ideal:** 300-500 lines per PR\n' +
|
||||||
- ✅ Source files, stylesheets, configuration files
|
'- **Warning:** 500-800 lines\n' +
|
||||||
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
|
'- **Critical:** 800+ lines ⚠️\n\n' +
|
||||||
|
'If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn\'t be split.';
|
||||||
### 📚 Guidelines:
|
|
||||||
- **Ideal:** 300-500 lines per PR
|
|
||||||
- **Warning:** 500-800 lines
|
|
||||||
- **Critical:** 800+ lines ⚠️
|
|
||||||
|
|
||||||
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.`;
|
|
||||||
|
|
||||||
// Check if we already commented
|
// Check if we already commented
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,3 +62,4 @@ branch.json
|
|||||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||||
.cursorrules
|
.cursorrules
|
||||||
i18n.cache
|
i18n.cache
|
||||||
|
stats.html
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
# Load environment variables from .env files
|
# Load environment variables from .env files
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
set -a
|
set -a
|
||||||
|
|||||||
54
AGENTS.md
54
AGENTS.md
@@ -18,11 +18,65 @@ Formbricks runs as a pnpm/turbo monorepo. `apps/web` is the Next.js product surf
|
|||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||||
|
We are using SonarQube to identify code smells and security hotspots.
|
||||||
|
|
||||||
|
## Architecture & Patterns
|
||||||
|
|
||||||
|
- Next.js app router lives in `apps/web/app` with route groups like `(app)` and `(auth)`. Services live in `apps/web/lib`, feature modules in `apps/web/modules`.
|
||||||
|
- Server actions wrap service calls and return `{ data }` or `{ error }` consistently.
|
||||||
|
- Context providers should guard against missing provider usage and use cleanup patterns that snapshot refs inside `useEffect` to avoid React hooks warnings
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
- Use React `cache()` for request-level dedupe and `cache.withCache()` or explicit Redis for expensive data.
|
||||||
|
- Do not use Next.js `unstable_cache()`.
|
||||||
|
- Always use `createCacheKey.*` utilities for cache keys.
|
||||||
|
|
||||||
|
## i18n (Internationalization)
|
||||||
|
|
||||||
|
- All user-facing text must use the `t()` function from `react-i18next`.
|
||||||
|
- Key naming: use lowercase with dots for nesting (e.g., `common.welcome`).
|
||||||
|
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
||||||
|
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||||
|
|
||||||
|
## Database & Prisma Performance
|
||||||
|
|
||||||
|
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||||
|
- Soft Deletion: Check for `isActive` or `deletedAt` fields; use proper filtering.
|
||||||
|
- Never use `skip`/`offset` with `prisma.response.count()`; only use `where`.
|
||||||
|
- Separate count and data queries and run in parallel (`Promise.all`).
|
||||||
|
- Prefer cursor pagination for large datasets.
|
||||||
|
- When filtering by `createdAt`, include indexed fields (e.g., `surveyId` + `createdAt`).
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|
||||||
Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs colocated with the code they exercise (`utility.test.ts`). Do not write tests for `.tsx` files—React components are covered by Playwright E2E tests instead. Mock network and storage boundaries through helpers from `@formbricks/*`. Run `pnpm test` before opening a PR and `pnpm test:coverage` when touching critical flows; keep coverage from regressing. End-to-end scenarios belong in `apps/web/playwright`, using descriptive filenames (`billing.spec.ts`) and tagging slow suites with `@slow` when necessary.
|
Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs colocated with the code they exercise (`utility.test.ts`). Do not write tests for `.tsx` files—React components are covered by Playwright E2E tests instead. Mock network and storage boundaries through helpers from `@formbricks/*`. Run `pnpm test` before opening a PR and `pnpm test:coverage` when touching critical flows; keep coverage from regressing. End-to-end scenarios belong in `apps/web/playwright`, using descriptive filenames (`billing.spec.ts`) and tagging slow suites with `@slow` when necessary.
|
||||||
|
|
||||||
|
## Documentation (apps/docs)
|
||||||
|
|
||||||
|
- Add frontmatter with `title`, `description`, and `icon` at the top of the MDX file.
|
||||||
|
- Do not start with an H1; use Camel Case headings (only capitalize the feature name).
|
||||||
|
- Use Mintlify components for steps and callouts.
|
||||||
|
- If Enterprise-only, add the Enterprise note block described in docs.
|
||||||
|
|
||||||
|
## Storybook
|
||||||
|
|
||||||
|
- Stories live in `stories.tsx` in the component folder and import from `"./index"`.
|
||||||
|
- Use `@storybook/react-vite` and organize argTypes into `Behavior`, `Appearance`, `Content`.
|
||||||
|
- Include Default, Disabled (if supported), WithIcon (if supported), all variants, and edge cases.
|
||||||
|
|
||||||
|
## GitHub Actions
|
||||||
|
|
||||||
|
- Always set minimal `permissions` for `GITHUB_TOKEN`.
|
||||||
|
- On `ubuntu-latest`, add `step-security/harden-runner` as the first step.
|
||||||
|
|
||||||
|
## Quality Checklist
|
||||||
|
|
||||||
|
- Keep code DRY and small; remove dead code and unused imports.
|
||||||
|
- Follow React hooks rules, keep effects focused, and avoid unnecessary `useMemo`/`useCallback`.
|
||||||
|
- Prefer type inference, avoid `any`, and use shared types from `@formbricks/types`.
|
||||||
|
- Keep components focused, avoid deep nesting, and ensure basic accessibility.
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
|
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
|
||||||
|
|||||||
@@ -203,6 +203,14 @@ Here are a few options:
|
|||||||
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
|
||||||
|
Formbricks is supported by the following companies who provide us with their tools for free as part of their open-source support:
|
||||||
|
|
||||||
|
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
|
||||||
|
|
||||||
|
<a href="https://sentry.io/"><img src="https://github.com/user-attachments/assets/d743ffd4-b575-4802-a29a-10136be9227e" width="150" height="30" alt="Sentry" /></a>
|
||||||
|
|
||||||
<a id="contact-us"></a>
|
<a id="contact-us"></a>
|
||||||
|
|
||||||
## 📆 Contact us
|
## 📆 Contact us
|
||||||
|
|||||||
@@ -11,24 +11,24 @@
|
|||||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formbricks/survey-ui": "workspace:*",
|
"@formbricks/survey-ui": "workspace:*"
|
||||||
"eslint-plugin-react-refresh": "0.4.24"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^4.1.3",
|
"@chromatic-com/storybook": "^5.0.0",
|
||||||
"@storybook/addon-a11y": "10.0.8",
|
"@storybook/addon-a11y": "10.1.11",
|
||||||
"@storybook/addon-links": "10.0.8",
|
"@storybook/addon-links": "10.1.11",
|
||||||
"@storybook/addon-onboarding": "10.0.8",
|
"@storybook/addon-onboarding": "10.1.11",
|
||||||
"@storybook/react-vite": "10.0.8",
|
"@storybook/react-vite": "10.1.11",
|
||||||
"@typescript-eslint/eslint-plugin": "8.48.0",
|
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||||
"@tailwindcss/vite": "4.1.17",
|
"@tailwindcss/vite": "4.1.18",
|
||||||
"@typescript-eslint/parser": "8.48.0",
|
"@typescript-eslint/parser": "8.53.0",
|
||||||
"@vitejs/plugin-react": "5.1.1",
|
"@vitejs/plugin-react": "5.1.2",
|
||||||
"esbuild": "0.27.0",
|
"esbuild": "0.25.12",
|
||||||
"eslint-plugin-storybook": "10.0.8",
|
"eslint-plugin-react-refresh": "0.4.26",
|
||||||
|
"eslint-plugin-storybook": "10.1.11",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"storybook": "10.0.8",
|
"storybook": "10.1.11",
|
||||||
"vite": "7.2.4",
|
"vite": "7.3.1",
|
||||||
"@storybook/addon-docs": "10.0.8"
|
"@storybook/addon-docs": "10.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
apps/web/.eslintignore
Normal file
7
apps/web/.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
public/
|
||||||
|
playwright/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
vendor/
|
||||||
@@ -1,20 +1,4 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ["@formbricks/eslint-config/legacy-next.js"],
|
extends: ["@formbricks/eslint-config/legacy-next.js"],
|
||||||
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
|
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
files: ["locales/*.json"],
|
|
||||||
plugins: ["i18n-json"],
|
|
||||||
rules: {
|
|
||||||
"i18n-json/identical-keys": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
filePath: require("path").join(__dirname, "locales", "en-US.json"),
|
|
||||||
checkExtraKeys: false,
|
|
||||||
checkMissingKeys: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-alpine3.22 AS base
|
FROM node:24-alpine3.23 AS base
|
||||||
|
|
||||||
#
|
#
|
||||||
## step 1: Prune monorepo
|
## step 1: Prune monorepo
|
||||||
@@ -20,7 +20,7 @@ FROM base AS installer
|
|||||||
# Enable corepack and prepare pnpm
|
# Enable corepack and prepare pnpm
|
||||||
RUN npm install --ignore-scripts -g corepack@latest
|
RUN npm install --ignore-scripts -g corepack@latest
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@9.15.9 --activate
|
RUN corepack prepare pnpm@10.28.2 --activate
|
||||||
|
|
||||||
# Install necessary build tools and compilers
|
# Install necessary build tools and compilers
|
||||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||||
@@ -69,20 +69,14 @@ RUN --mount=type=secret,id=database_url \
|
|||||||
--mount=type=secret,id=sentry_auth_token \
|
--mount=type=secret,id=sentry_auth_token \
|
||||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||||
|
|
||||||
# Extract Prisma version
|
|
||||||
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
|
||||||
|
|
||||||
#
|
#
|
||||||
## step 3: setup production runner
|
## step 3: setup production runner
|
||||||
#
|
#
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
RUN npm install --ignore-scripts -g corepack@latest && \
|
# Update npm to latest, then create user
|
||||||
corepack enable
|
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
|
||||||
|
RUN npm install --ignore-scripts -g npm@latest \
|
||||||
RUN apk add --no-cache curl \
|
|
||||||
&& apk add --no-cache supercronic \
|
|
||||||
# && addgroup --system --gid 1001 nodejs \
|
|
||||||
&& addgroup -S nextjs \
|
&& addgroup -S nextjs \
|
||||||
&& adduser -S -u 1001 -G nextjs nextjs
|
&& adduser -S -u 1001 -G nextjs nextjs
|
||||||
|
|
||||||
@@ -104,31 +98,37 @@ RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.n
|
|||||||
COPY --from=installer /app/apps/web/public ./apps/web/public
|
COPY --from=installer /app/apps/web/public ./apps/web/public
|
||||||
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||||
|
|
||||||
|
# Create packages/database directory structure with proper ownership for runtime migrations
|
||||||
|
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
||||||
|
|
||||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||||
|
|
||||||
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
||||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
||||||
|
|
||||||
|
# Copy prisma client packages
|
||||||
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||||
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
|
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
|
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
|
||||||
|
|
||||||
COPY --from=installer /prisma_version.txt .
|
|
||||||
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||||
|
|
||||||
|
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
|
||||||
|
RUN chmod -R 755 ./node_modules/uuid
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
||||||
RUN chmod -R 755 ./node_modules/@noble/hashes
|
RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||||
RUN chmod -R 755 ./node_modules/zod
|
RUN chmod -R 755 ./node_modules/zod
|
||||||
|
|
||||||
RUN npm install -g prisma@6
|
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
|
||||||
|
RUN npm install --ignore-scripts -g prisma@6 \
|
||||||
|
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
|
||||||
|
|
||||||
# Create a startup script to handle the conditional logic
|
# Create a startup script to handle the conditional logic
|
||||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||||
@@ -138,10 +138,8 @@ EXPOSE 3000
|
|||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Prepare pnpm as the nextjs user to ensure it's available at runtime
|
|
||||||
# Prepare volumes for uploads and SAML connections
|
# Prepare volumes for uploads and SAML connections
|
||||||
RUN corepack prepare pnpm@9.15.9 --activate && \
|
RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||||
mkdir -p /home/nextjs/apps/web/uploads/ && \
|
|
||||||
mkdir -p /home/nextjs/apps/web/saml-connection
|
mkdir -p /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
VOLUME /home/nextjs/apps/web/uploads/
|
VOLUME /home/nextjs/apps/web/uploads/
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = project.config.channel || null;
|
const channel = project.config.channel || null;
|
||||||
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}`}>
|
<Link href={`/environments/${environment.id}`}>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, organizationId);
|
const projects = await getUserProjects(session.user.id, organizationId);
|
||||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||||
{projects.length >= 2 && (
|
{projects.length >= 2 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}/surveys`}>
|
<Link href={`/environments/${environment.id}/surveys`}>
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ const Page = async (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.landing.no_projects_warning_title")}
|
title={t("organizations.landing.no_workspaces_warning_title")}
|
||||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
subtitle={t("organizations.landing.no_workspaces_warning_subtitle")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,16 +26,16 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const channelOptions = [
|
const channelOptions = [
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.channel.link_and_email_surveys"),
|
title: t("organizations.workspaces.new.channel.link_and_email_surveys"),
|
||||||
description: t("organizations.projects.new.channel.link_and_email_surveys_description"),
|
description: t("organizations.workspaces.new.channel.link_and_email_surveys_description"),
|
||||||
icon: SendIcon,
|
icon: SendIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?channel=link`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=link`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.channel.in_product_surveys"),
|
title: t("organizations.workspaces.new.channel.in_product_surveys"),
|
||||||
description: t("organizations.projects.new.channel.in_product_surveys_description"),
|
description: t("organizations.workspaces.new.channel.in_product_surveys_description"),
|
||||||
icon: PictureInPicture2Icon,
|
icon: PictureInPicture2Icon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?channel=app`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=app`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -44,13 +44,13 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.projects.new.channel.channel_select_title")}
|
title={t("organizations.workspaces.new.channel.channel_select_title")}
|
||||||
subtitle={t("organizations.projects.new.channel.channel_select_subtitle")}
|
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
||||||
/>
|
/>
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
@@ -15,7 +15,7 @@ const OnboardingLayout = async (props) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || !session.user) {
|
if (!session?.user) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,16 +26,16 @@ const Page = async (props: ModePageProps) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const channelOptions = [
|
const channelOptions = [
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.mode.formbricks_surveys"),
|
title: t("organizations.workspaces.new.mode.formbricks_surveys"),
|
||||||
description: t("organizations.projects.new.mode.formbricks_surveys_description"),
|
description: t("organizations.workspaces.new.mode.formbricks_surveys_description"),
|
||||||
icon: ListTodoIcon,
|
icon: ListTodoIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/channel`,
|
href: `/organizations/${params.organizationId}/workspaces/new/channel`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.mode.formbricks_cx"),
|
title: t("organizations.workspaces.new.mode.formbricks_cx"),
|
||||||
description: t("organizations.projects.new.mode.formbricks_cx_description"),
|
description: t("organizations.workspaces.new.mode.formbricks_cx_description"),
|
||||||
icon: HeartIcon,
|
icon: HeartIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?mode=cx`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?mode=cx`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -43,11 +43,11 @@ const Page = async (props: ModePageProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header title={t("organizations.projects.new.mode.what_are_you_here_for")} />
|
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
@@ -79,7 +79,7 @@ export const ProjectSettings = ({
|
|||||||
(environment) => environment.type === "production"
|
(environment) => environment.type === "production"
|
||||||
);
|
);
|
||||||
if (productionEnvironment) {
|
if (productionEnvironment) {
|
||||||
if (typeof window !== "undefined") {
|
if (globalThis.window !== undefined) {
|
||||||
// Rmove filters when creating a new project
|
// Rmove filters when creating a new project
|
||||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ export const ProjectSettings = ({
|
|||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("organizations.projects.new.settings.project_creation_failed"));
|
toast.error(t("organizations.workspaces.new.settings.workspace_creation_failed"));
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -107,7 +107,6 @@ export const ProjectSettings = ({
|
|||||||
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
||||||
teamIds: [],
|
teamIds: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
resolver: zodResolver(ZProjectUpdateInput),
|
resolver: zodResolver(ZProjectUpdateInput),
|
||||||
});
|
});
|
||||||
const projectName = form.watch("name");
|
const projectName = form.watch("name");
|
||||||
@@ -131,9 +130,9 @@ export const ProjectSettings = ({
|
|||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormItem className="w-full space-y-4">
|
<FormItem className="w-full space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("organizations.projects.new.settings.brand_color")}</FormLabel>
|
<FormLabel>{t("organizations.workspaces.new.settings.brand_color")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.brand_color_description")}
|
{t("organizations.workspaces.new.settings.brand_color_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -155,9 +154,9 @@ export const ProjectSettings = ({
|
|||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormItem className="w-full space-y-4">
|
<FormItem className="w-full space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("organizations.projects.new.settings.project_name")}</FormLabel>
|
<FormLabel>{t("organizations.workspaces.new.settings.workspace_name")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.project_name_description")}
|
{t("organizations.workspaces.new.settings.workspace_name_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -186,7 +185,7 @@ export const ProjectSettings = ({
|
|||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("common.teams")}</FormLabel>
|
<FormLabel>{t("common.teams")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.team_description")}
|
{t("organizations.workspaces.new.settings.team_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -194,7 +193,7 @@ export const ProjectSettings = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCreateTeamModalOpen(true)}>
|
onClick={() => setCreateTeamModalOpen(true)}>
|
||||||
{t("organizations.projects.new.settings.create_new_team")}
|
{t("organizations.workspaces.new.settings.create_new_team")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -227,7 +226,7 @@ export const ProjectSettings = ({
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={256}
|
width={256}
|
||||||
height={56}
|
height={56}
|
||||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
className="absolute top-2 left-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>
|
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
@@ -53,8 +53,8 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.projects.new.settings.project_settings_title")}
|
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
||||||
subtitle={t("organizations.projects.new.settings.project_settings_subtitle")}
|
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
||||||
/>
|
/>
|
||||||
<ProjectSettings
|
<ProjectSettings
|
||||||
organizationId={params.organizationId}
|
organizationId={params.organizationId}
|
||||||
@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AttributesPage as default } from "@/modules/ee/contacts/attributes/page";
|
||||||
@@ -57,7 +57,7 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||||
|
|
||||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||||
throw new OperationNotAllowedError("Organization project limit reached");
|
throw new OperationNotAllowedError("Organization workspace limit reached");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
// Calculate derived values (no queries)
|
// Calculate derived values (no queries)
|
||||||
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
||||||
|
|
||||||
const { features, lastChecked, isPendingDowngrade, active } = license;
|
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
||||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||||
const isOwnerOrManager = isOwner || isManager;
|
const isOwnerOrManager = isOwner || isManager;
|
||||||
|
|
||||||
// Validate that project permission exists for members
|
// Validate that project permission exists for members
|
||||||
if (isMember && !projectPermission) {
|
if (isMember && !projectPermission) {
|
||||||
throw new Error(t("common.project_permission_not_found"));
|
throw new Error(t("common.workspace_permission_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +63,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
active={active}
|
active={active}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
locale={user.locale}
|
locale={user.locale}
|
||||||
|
status={status}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export const MainNavigation = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("common.configuration"),
|
name: t("common.configuration"),
|
||||||
href: `/environments/${environment.id}/project/general`,
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
isActive: pathname?.includes("/project"),
|
isActive: pathname?.includes("/project"),
|
||||||
},
|
},
|
||||||
@@ -164,7 +164,7 @@ export const MainNavigation = ({
|
|||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
|
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
|
||||||
)}>
|
)}>
|
||||||
<div>
|
<div>
|
||||||
{/* Logo and Toggle */}
|
{/* Logo and Toggle */}
|
||||||
@@ -185,7 +185,7 @@ export const MainNavigation = ({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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"
|
"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"
|
||||||
)}>
|
)}>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
|||||||
const stati = {
|
const stati = {
|
||||||
notImplemented: {
|
notImplemented: {
|
||||||
icon: AlertTriangleIcon,
|
icon: AlertTriangleIcon,
|
||||||
title: t("environments.project.app-connection.formbricks_sdk_not_connected"),
|
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
|
||||||
subtitle: t("environments.project.app-connection.formbricks_sdk_not_connected_description"),
|
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
|
||||||
},
|
},
|
||||||
running: {
|
running: {
|
||||||
icon: CheckIcon,
|
icon: CheckIcon,
|
||||||
title: t("environments.project.app-connection.receiving_data"),
|
title: t("environments.workspace.app-connection.receiving_data"),
|
||||||
subtitle: t("environments.project.app-connection.formbricks_sdk_connected"),
|
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,11 +53,11 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
|||||||
<currentStatus.icon />
|
<currentStatus.icon />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||||
{status === "notImplemented" && (
|
{status === "notImplemented" && (
|
||||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||||
<RotateCcwIcon />
|
<RotateCcwIcon />
|
||||||
{t("environments.project.app-connection.recheck")}
|
{t("environments.workspace.app-connection.recheck")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import {
|
import {
|
||||||
BuildingIcon,
|
Building2Icon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -144,6 +144,12 @@ export const OrganizationBreadcrumb = ({
|
|||||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||||
hidden: !isOwnerOrManager,
|
hidden: !isOwnerOrManager,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "domain",
|
||||||
|
label: t("common.domain"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/settings/domain`,
|
||||||
|
hidden: isFormbricksCloud,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
@@ -166,7 +172,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
id="organizationDropdownTrigger"
|
id="organizationDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
<Building2Icon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{organizationName}</span>
|
<span>{organizationName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isOrganizationDropdownOpen ? (
|
{isOrganizationDropdownOpen ? (
|
||||||
@@ -180,7 +186,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
{showOrganizationDropdown && (
|
{showOrganizationDropdown && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
<Building2Icon className="mr-2 inline h-4 w-4" />
|
||||||
{t("common.choose_organization")}
|
{t("common.choose_organization")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingOrganizations && (
|
{isLoadingOrganizations && (
|
||||||
@@ -203,7 +209,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
)}
|
)}
|
||||||
{!isLoadingOrganizations && !loadError && (
|
{!isLoadingOrganizations && !loadError && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||||
{organizations.map((org) => (
|
{organizations.map((org) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={org.id}
|
key={org.id}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -36,12 +36,12 @@ interface ProjectBreadcrumbProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||||
// Match /project/{settingId} or /project/{settingId}/... but exclude settings paths
|
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
|
||||||
if (pathname.includes("/settings/")) {
|
if (pathname.includes("/settings/")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check if path matches /project/{settingId} (with optional trailing path)
|
// Check if path matches /workspace/{settingId} (with optional trailing path)
|
||||||
const pattern = new RegExp(`/project/${settingId}(?:/|$)`);
|
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
||||||
return pattern.test(pathname);
|
return pattern.test(pathname);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
const error = new Error(errorMessage);
|
const error = new Error(errorMessage);
|
||||||
logger.error(error, "Failed to load projects");
|
logger.error(error, "Failed to load projects");
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
|
||||||
}
|
}
|
||||||
setIsLoadingProjects(false);
|
setIsLoadingProjects(false);
|
||||||
});
|
});
|
||||||
@@ -101,42 +101,42 @@ export const ProjectBreadcrumb = ({
|
|||||||
{
|
{
|
||||||
id: "general",
|
id: "general",
|
||||||
label: t("common.general"),
|
label: t("common.general"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/general`,
|
href: `/environments/${currentEnvironmentId}/workspace/general`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "look",
|
id: "look",
|
||||||
label: t("common.look_and_feel"),
|
label: t("common.look_and_feel"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/look`,
|
href: `/environments/${currentEnvironmentId}/workspace/look`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "app-connection",
|
id: "app-connection",
|
||||||
label: t("common.website_and_app_connection"),
|
label: t("common.website_and_app_connection"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/app-connection`,
|
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "integrations",
|
id: "integrations",
|
||||||
label: t("common.integrations"),
|
label: t("common.integrations"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/integrations`,
|
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.team_access"),
|
label: t("common.team_access"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/teams`,
|
href: `/environments/${currentEnvironmentId}/workspace/teams`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "languages",
|
id: "languages",
|
||||||
label: t("common.survey_languages"),
|
label: t("common.survey_languages"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/languages`,
|
href: `/environments/${currentEnvironmentId}/workspace/languages`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tags",
|
id: "tags",
|
||||||
label: t("common.tags"),
|
label: t("common.tags"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/tags`,
|
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||||
logger.error(errorMessage);
|
logger.error(errorMessage);
|
||||||
Sentry.captureException(new Error(errorMessage));
|
Sentry.captureException(new Error(errorMessage));
|
||||||
return;
|
return;
|
||||||
@@ -145,7 +145,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
const handleProjectChange = (projectId: string) => {
|
const handleProjectChange = (projectId: string) => {
|
||||||
if (projectId === currentProjectId) return;
|
if (projectId === currentProjectId) return;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/projects/${projectId}/`);
|
router.push(`/workspaces/${projectId}/`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
|
|
||||||
const handleProjectSettingsNavigation = (settingId: string) => {
|
const handleProjectSettingsNavigation = (settingId: string) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/environments/${currentEnvironmentId}/project/${settingId}`);
|
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,21 +198,21 @@ export const ProjectBreadcrumb = ({
|
|||||||
id="projectDropdownTrigger"
|
id="projectDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{projectName}</span>
|
<span>{projectName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isProjectDropdownOpen ? (
|
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
) : (
|
) : (
|
||||||
isEnvironmentBreadcrumbVisible && <ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="start" className="mt-2">
|
<DropdownMenuContent align="start" className="mt-2">
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.choose_project")}
|
{t("common.choose_workspace")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingProjects && (
|
{isLoadingProjects && (
|
||||||
<div className="flex items-center justify-center py-2">
|
<div className="flex items-center justify-center py-2">
|
||||||
@@ -234,7 +234,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
)}
|
)}
|
||||||
{!isLoadingProjects && !loadError && (
|
{!isLoadingProjects && !loadError && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||||
{projects.map((proj) => (
|
{projects.map((proj) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={proj.id}
|
key={proj.id}
|
||||||
@@ -251,7 +251,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
onClick={handleAddProject}
|
onClick={handleAddProject}
|
||||||
className="w-full cursor-pointer justify-between">
|
className="w-full cursor-pointer justify-between">
|
||||||
<span>{t("common.add_new_project")}</span>
|
<span>{t("common.add_new_workspace")}</span>
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
)}
|
)}
|
||||||
@@ -261,7 +261,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.project_configuration")}
|
{t("common.workspace_configuration")}
|
||||||
</div>
|
</div>
|
||||||
{projectSettings.map((setting) => (
|
{projectSettings.map((setting) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
|
||||||
|
|
||||||
export const TYPE_MAPPING = {
|
|
||||||
[TSurveyQuestionTypeEnum.CTA]: ["checkbox"],
|
|
||||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ["multi_select"],
|
|
||||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: ["select", "status"],
|
|
||||||
[TSurveyQuestionTypeEnum.OpenText]: [
|
|
||||||
"created_by",
|
|
||||||
"created_time",
|
|
||||||
"email",
|
|
||||||
"last_edited_by",
|
|
||||||
"last_edited_time",
|
|
||||||
"number",
|
|
||||||
"phone_number",
|
|
||||||
"rich_text",
|
|
||||||
"title",
|
|
||||||
"url",
|
|
||||||
],
|
|
||||||
[TSurveyQuestionTypeEnum.NPS]: ["number"],
|
|
||||||
[TSurveyQuestionTypeEnum.Consent]: ["checkbox"],
|
|
||||||
[TSurveyQuestionTypeEnum.Rating]: ["number"],
|
|
||||||
[TSurveyQuestionTypeEnum.PictureSelection]: ["url"],
|
|
||||||
[TSurveyQuestionTypeEnum.FileUpload]: ["url"],
|
|
||||||
[TSurveyQuestionTypeEnum.Date]: ["date"],
|
|
||||||
[TSurveyQuestionTypeEnum.Address]: ["rich_text"],
|
|
||||||
[TSurveyQuestionTypeEnum.Matrix]: ["rich_text"],
|
|
||||||
[TSurveyQuestionTypeEnum.Cal]: ["checkbox"],
|
|
||||||
[TSurveyQuestionTypeEnum.ContactInfo]: ["rich_text"],
|
|
||||||
[TSurveyQuestionTypeEnum.Ranking]: ["rich_text"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UNSUPPORTED_TYPES_BY_NOTION = [
|
|
||||||
"rollup",
|
|
||||||
"created_by",
|
|
||||||
"created_time",
|
|
||||||
"last_edited_by",
|
|
||||||
"last_edited_time",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ERRORS = {
|
|
||||||
MAPPING: "Mapping Error",
|
|
||||||
UNSUPPORTED_TYPE: "Unsupported type by Notion",
|
|
||||||
};
|
|
||||||
@@ -21,7 +21,7 @@ const AccountSettingsLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
|
|||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
||||||
<a
|
<a
|
||||||
href={`/environments/${environmentId}/project/integrations`}
|
href={`/environments/${environmentId}/workspace/integrations`}
|
||||||
className="ml-1 cursor-pointer text-sm underline">
|
className="ml-1 cursor-pointer text-sm underline">
|
||||||
{t("environments.settings.notifications.use_the_integration")}
|
{t("environments.settings.notifications.use_the_integration")}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ async function handleEmailUpdate({
|
|||||||
payload.email = inputEmail;
|
payload.email = inputEmail;
|
||||||
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
|
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
|
||||||
} else {
|
} else {
|
||||||
await sendVerificationNewEmail(ctx.user.id, inputEmail);
|
await sendVerificationNewEmail(ctx.user.id, inputEmail, ctx.user.locale);
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
current: pathname?.includes("/api-keys"),
|
current: pathname?.includes("/api-keys"),
|
||||||
hidden: !isOwner,
|
hidden: !isOwner,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "domain",
|
||||||
|
label: t("common.domain"),
|
||||||
|
href: `/environments/${environmentId}/settings/domain`,
|
||||||
|
current: pathname?.includes("/domain"),
|
||||||
|
hidden: isFormbricksCloud,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurveyStatus } from "@formbricks/types/surveys/types";
|
||||||
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||||
|
|
||||||
|
interface SurveyWithSlug {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string | null;
|
||||||
|
status: TSurveyStatus;
|
||||||
|
environment: {
|
||||||
|
id: string;
|
||||||
|
type: "production" | "development";
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrettyUrlsTableProps {
|
||||||
|
surveys: SurveyWithSlug[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const getEnvironmentBadgeColor = (type: string) => {
|
||||||
|
return type === "production" ? "bg-green-100 text-green-800" : "bg-blue-100 text-blue-800";
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableHeaders = [
|
||||||
|
{
|
||||||
|
label: t("environments.settings.domain.survey_name"),
|
||||||
|
key: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("environments.settings.domain.workspace"),
|
||||||
|
key: "project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("environments.settings.domain.pretty_url"),
|
||||||
|
key: "slug",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("common.environment"),
|
||||||
|
key: "environment",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-slate-100">
|
||||||
|
{tableHeaders.map((header) => (
|
||||||
|
<TableHead key={header.key} className="font-medium text-slate-500">
|
||||||
|
{header.label}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="[&_tr:last-child]:border-b">
|
||||||
|
{surveys.length === 0 && (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell colSpan={4} className="text-center text-slate-500">
|
||||||
|
{t("environments.settings.domain.no_pretty_urls")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{surveys.map((survey) => (
|
||||||
|
<TableRow key={survey.id} className="border-slate-200 hover:bg-transparent">
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Link
|
||||||
|
href={`/environments/${survey.environment.id}/surveys/${survey.id}/summary`}
|
||||||
|
className="text-slate-900 hover:text-slate-700 hover:underline">
|
||||||
|
{survey.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{survey.environment.project.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IdBadge id={survey.slug ?? ""} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium ${getEnvironmentBadgeColor(survey.environment.type)}`}>
|
||||||
|
{survey.environment.type === "production"
|
||||||
|
? t("common.production")
|
||||||
|
: t("common.development")}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { FaviconCustomizationSettings } from "@/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { getSurveysWithSlugsByOrganizationId } from "@/modules/survey/lib/slug";
|
||||||
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
|
import { OrganizationSettingsNavbar } from "../components/OrganizationSettingsNavbar";
|
||||||
|
import { PrettyUrlsTable } from "./components/pretty-urls-table";
|
||||||
|
|
||||||
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const t = await getTranslate();
|
||||||
|
|
||||||
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
|
||||||
|
params.environmentId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(t("common.session_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
|
||||||
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
|
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||||
|
<OrganizationSettingsNavbar
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
|
membershipRole={currentUserMembership?.role}
|
||||||
|
activeId="domain"
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{!IS_STORAGE_CONFIGURED && (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>{t("common.storage_not_configured")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FaviconCustomizationSettings
|
||||||
|
organization={organization}
|
||||||
|
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
isReadOnly={!isOwnerOrManager}
|
||||||
|
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.domain.title")}
|
||||||
|
description={t("environments.settings.domain.description")}>
|
||||||
|
<PrettyUrlsTable surveys={surveys} />
|
||||||
|
</SettingsCard>
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -39,7 +39,7 @@ const Page = async (props) => {
|
|||||||
onRequest: false,
|
onRequest: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("environments.project.languages.multi_language_surveys"),
|
title: t("environments.workspace.languages.multi_language_surveys"),
|
||||||
comingSoon: false,
|
comingSoon: false,
|
||||||
onRequest: false,
|
onRequest: false,
|
||||||
},
|
},
|
||||||
@@ -118,7 +118,7 @@ const Page = async (props) => {
|
|||||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 1024 1024"
|
viewBox="0 0 1024 1024"
|
||||||
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
<circle
|
<circle
|
||||||
cx={512}
|
cx={512}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ShieldCheckIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const SecurityListTip = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="flex items-center space-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
|
||||||
|
<ShieldCheckIcon className="h-5 w-5 flex-shrink-0 text-blue-400" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{t("environments.settings.general.security_list_tip")}{" "}
|
||||||
|
<Link
|
||||||
|
href="https://formbricks.com/security#stay-informed-with-formbricks-security-updates"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-blue-700">
|
||||||
|
{t("environments.settings.general.security_list_tip_link")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
|||||||
import { SettingsCard } from "../../components/SettingsCard";
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||||
|
import { SecurityListTip } from "./components/SecurityListTip";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -48,6 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!IS_FORMBRICKS_CLOUD && <SecurityListTip />}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("environments.settings.general.organization_name")}
|
title={t("environments.settings.general.organization_name")}
|
||||||
description={t("environments.settings.general.organization_name_description")}>
|
description={t("environments.settings.general.organization_name_description")}>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const Layout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const EmptyAppSurveys = ({ environment }: TEmptyAppSurveysProps) => {
|
|||||||
{t("environments.surveys.summary.connect_your_website_or_app_with_formbricks_to_get_started")}
|
{t("environments.surveys.summary.connect_your_website_or_app_with_formbricks_to_get_started")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link className="mt-2" href={`/environments/${environment.id}/project/app-connection`}>
|
<Link className="mt-2" href={`/environments/${environment.id}/workspace/app-connection`}>
|
||||||
<Button size="sm" className="flex w-[120px] justify-center">
|
<Button size="sm" className="flex w-[120px] justify-center">
|
||||||
{t("common.connect")}
|
{t("common.connect")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -316,6 +316,14 @@ export const generateResponseTableColumns = (
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const responseIdColumn: ColumnDef<TResponseTableData> = {
|
||||||
|
accessorKey: "responseId",
|
||||||
|
header: () => <div className="gap-x-1.5">{t("common.response_id")}</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return <IdBadge id={row.original.responseId} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const quotasColumn: ColumnDef<TResponseTableData> = {
|
const quotasColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "quota",
|
accessorKey: "quota",
|
||||||
header: t("common.quota"),
|
header: t("common.quota"),
|
||||||
@@ -376,24 +384,24 @@ export const generateResponseTableColumns = (
|
|||||||
|
|
||||||
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
|
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
|
||||||
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
|
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
|
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
|
||||||
header: () => (
|
header: () => (
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">
|
<span className="h-4 w-4">
|
||||||
<EyeOffIcon className="h-4 w-4" />
|
<EyeOffIcon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{hiddenFieldId}</span>
|
<span className="truncate">{hiddenFieldId}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
|
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
|
||||||
if (typeof hiddenFieldResponse === "string") {
|
if (typeof hiddenFieldResponse === "string") {
|
||||||
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
|
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const metadataColumns = getMetadataColumnsData(t);
|
const metadataColumns = getMetadataColumnsData(t);
|
||||||
@@ -414,6 +422,7 @@ export const generateResponseTableColumns = (
|
|||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
personColumn,
|
personColumn,
|
||||||
singleUseIdColumn,
|
singleUseIdColumn,
|
||||||
|
responseIdColumn,
|
||||||
dateColumn,
|
dateColumn,
|
||||||
...(showQuotasColumn ? [quotasColumn] : []),
|
...(showQuotasColumn ? [quotasColumn] : []),
|
||||||
statusColumn,
|
statusColumn,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
|||||||
ctx.user.email,
|
ctx.user.email,
|
||||||
emailHtml,
|
emailHtml,
|
||||||
survey.environmentId,
|
survey.environmentId,
|
||||||
|
ctx.user.locale,
|
||||||
organizationLogoUrl || ""
|
organizationLogoUrl || ""
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
|
projectCustomScripts={project.customHeadScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SuccessMessage environment={environment} survey={survey} />
|
<SuccessMessage environment={environment} survey={survey} />
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import {
|
import {
|
||||||
Code2Icon,
|
Code2Icon,
|
||||||
LinkIcon,
|
CodeIcon,
|
||||||
|
Link2Icon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
QrCodeIcon,
|
QrCodeIcon,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -18,10 +19,12 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
|||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
|
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
|
||||||
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
|
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
|
||||||
|
import { CustomHtmlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/custom-html-tab";
|
||||||
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
||||||
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
||||||
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
|
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
|
||||||
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
|
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
|
||||||
|
import { PrettyUrlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/pretty-url-tab";
|
||||||
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
|
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
|
||||||
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
|
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
|
||||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||||
@@ -50,6 +53,7 @@ interface ShareSurveyModalProps {
|
|||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
isStorageConfigured: boolean;
|
isStorageConfigured: boolean;
|
||||||
|
projectCustomScripts?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShareSurveyModal = ({
|
export const ShareSurveyModal = ({
|
||||||
@@ -64,6 +68,7 @@ export const ShareSurveyModal = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
isStorageConfigured,
|
isStorageConfigured,
|
||||||
|
projectCustomScripts,
|
||||||
}: ShareSurveyModalProps) => {
|
}: ShareSurveyModalProps) => {
|
||||||
const environmentId = survey.environmentId;
|
const environmentId = survey.environmentId;
|
||||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||||
@@ -80,13 +85,13 @@ export const ShareSurveyModal = ({
|
|||||||
componentType: React.ComponentType<unknown>;
|
componentType: React.ComponentType<unknown>;
|
||||||
componentProps: unknown;
|
componentProps: unknown;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}[] = useMemo(
|
}[] = useMemo(() => {
|
||||||
() => [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: ShareViaType.ANON_LINKS,
|
id: ShareViaType.ANON_LINKS,
|
||||||
type: LinkTabsType.SHARE_VIA,
|
type: LinkTabsType.SHARE_VIA,
|
||||||
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||||
icon: LinkIcon,
|
icon: Link2Icon,
|
||||||
title: t("environments.surveys.share.anonymous_links.nav_title"),
|
title: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||||
description: t("environments.surveys.share.anonymous_links.description"),
|
description: t("environments.surveys.share.anonymous_links.description"),
|
||||||
componentType: AnonymousLinksTab,
|
componentType: AnonymousLinksTab,
|
||||||
@@ -180,22 +185,49 @@ export const ShareSurveyModal = ({
|
|||||||
componentType: LinkSettingsTab,
|
componentType: LinkSettingsTab,
|
||||||
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
|
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
[
|
id: ShareSettingsType.PRETTY_URL,
|
||||||
t,
|
type: LinkTabsType.SHARE_SETTING,
|
||||||
survey,
|
label: t("environments.surveys.share.pretty_url.title"),
|
||||||
publicDomain,
|
icon: Link2Icon,
|
||||||
user.locale,
|
title: t("environments.surveys.share.pretty_url.title"),
|
||||||
surveyUrl,
|
description: t("environments.surveys.share.pretty_url.description"),
|
||||||
isReadOnly,
|
componentType: PrettyUrlTab,
|
||||||
environmentId,
|
componentProps: { publicDomain, isReadOnly },
|
||||||
segments,
|
},
|
||||||
isContactsEnabled,
|
{
|
||||||
isFormbricksCloud,
|
id: ShareSettingsType.CUSTOM_HTML,
|
||||||
email,
|
type: LinkTabsType.SHARE_SETTING,
|
||||||
isStorageConfigured,
|
label: t("environments.surveys.share.custom_html.nav_title"),
|
||||||
]
|
icon: CodeIcon,
|
||||||
);
|
title: t("environments.surveys.share.custom_html.nav_title"),
|
||||||
|
description: t("environments.surveys.share.custom_html.description"),
|
||||||
|
componentType: CustomHtmlTab,
|
||||||
|
componentProps: { projectCustomScripts, isReadOnly },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter out tabs that should not be shown on Formbricks Cloud
|
||||||
|
return isFormbricksCloud
|
||||||
|
? tabs.filter(
|
||||||
|
(tab) => tab.id !== ShareSettingsType.PRETTY_URL && tab.id !== ShareSettingsType.CUSTOM_HTML
|
||||||
|
)
|
||||||
|
: tabs;
|
||||||
|
}, [
|
||||||
|
t,
|
||||||
|
survey,
|
||||||
|
publicDomain,
|
||||||
|
user.locale,
|
||||||
|
surveyUrl,
|
||||||
|
isReadOnly,
|
||||||
|
environmentId,
|
||||||
|
segments,
|
||||||
|
isContactsEnabled,
|
||||||
|
isFormbricksCloud,
|
||||||
|
email,
|
||||||
|
isStorageConfigured,
|
||||||
|
projectCustomScripts,
|
||||||
|
]);
|
||||||
|
|
||||||
const getDefaultActiveId = useCallback(() => {
|
const getDefaultActiveId = useCallback(() => {
|
||||||
if (survey.type !== "link") {
|
if (survey.type !== "link") {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
@@ -34,7 +34,6 @@ export const AnonymousLinksTab = ({
|
|||||||
locale,
|
locale,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
}: AnonymousLinksTabProps) => {
|
}: AnonymousLinksTabProps) => {
|
||||||
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
|
|||||||
pendingAction: () => Promise<void> | void;
|
pendingAction: () => Promise<void> | void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
const surveyUrlWithCustomSuid = useMemo(() => {
|
||||||
|
const url = new URL(surveyUrl);
|
||||||
|
url.searchParams.set("suId", "CUSTOM-ID");
|
||||||
|
return url.toString();
|
||||||
|
}, [surveyUrl]);
|
||||||
|
|
||||||
const resetState = () => {
|
const resetState = () => {
|
||||||
const { singleUse } = survey;
|
const { singleUse } = survey;
|
||||||
const { enabled, isEncrypted } = singleUse ?? {};
|
const { enabled, isEncrypted } = singleUse ?? {};
|
||||||
@@ -177,7 +182,11 @@ export const AnonymousLinksTab = ({
|
|||||||
|
|
||||||
if (!!response?.data?.length) {
|
if (!!response?.data?.length) {
|
||||||
const singleUseIds = response.data;
|
const singleUseIds = response.data;
|
||||||
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
|
const surveyLinks = singleUseIds.map((singleUseId) => {
|
||||||
|
const url = new URL(surveyUrl);
|
||||||
|
url.searchParams.set("suId", singleUseId);
|
||||||
|
return url.toString();
|
||||||
|
});
|
||||||
|
|
||||||
// Create content with just the links
|
// Create content with just the links
|
||||||
const csvContent = surveyLinks.join("\n");
|
const csvContent = surveyLinks.join("\n");
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export const AppTab = () => {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
{!environment.appSetupCompleted && (
|
{!environment.appSetupCompleted && (
|
||||||
<AlertButton asChild>
|
<AlertButton asChild>
|
||||||
<Link href={`/environments/${environment.id}/project/app-connection`}>
|
<Link href={`/environments/${environment.id}/workspace/app-connection`}>
|
||||||
{t("common.connect_formbricks")}
|
{t("common.connect_formbricks")}
|
||||||
</Link>
|
</Link>
|
||||||
</AlertButton>
|
</AlertButton>
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangleIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||||
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormProvider,
|
||||||
|
} from "@/modules/ui/components/form";
|
||||||
|
import { TabToggle } from "@/modules/ui/components/tab-toggle";
|
||||||
|
|
||||||
|
interface CustomHtmlTabProps {
|
||||||
|
projectCustomScripts: string | null | undefined;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomHtmlFormData {
|
||||||
|
customHeadScripts: string;
|
||||||
|
customHeadScriptsMode: TSurvey["customHeadScriptsMode"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { survey } = useSurvey();
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<CustomHtmlFormData>({
|
||||||
|
defaultValues: {
|
||||||
|
customHeadScripts: survey.customHeadScripts ?? "",
|
||||||
|
customHeadScriptsMode: survey.customHeadScriptsMode ?? "add",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { isDirty },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const scriptsMode = watch("customHeadScriptsMode");
|
||||||
|
|
||||||
|
const onSubmit = async (data: CustomHtmlFormData) => {
|
||||||
|
if (isSaving || isReadOnly) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
const updatedSurvey: TSurvey = {
|
||||||
|
...survey,
|
||||||
|
customHeadScripts: data.customHeadScripts || null,
|
||||||
|
customHeadScriptsMode: data.customHeadScriptsMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateSurveyAction(updatedSurvey);
|
||||||
|
|
||||||
|
if (result?.data) {
|
||||||
|
toast.success(t("environments.surveys.share.custom_html.saved_successfully"));
|
||||||
|
reset(data);
|
||||||
|
} else {
|
||||||
|
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-1">
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>{t("environments.surveys.share.custom_html.script_mode")}</FormLabel>
|
||||||
|
<TabToggle
|
||||||
|
id="custom-scripts-mode"
|
||||||
|
options={[
|
||||||
|
{ value: "add", label: t("environments.surveys.share.custom_html.add_to_workspace") },
|
||||||
|
{ value: "replace", label: t("environments.surveys.share.custom_html.replace_workspace") },
|
||||||
|
]}
|
||||||
|
defaultSelected={scriptsMode ?? "add"}
|
||||||
|
onChange={(value) => setValue("customHeadScriptsMode", value, { shouldDirty: true })}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{scriptsMode === "add"
|
||||||
|
? t("environments.surveys.share.custom_html.add_mode_description")
|
||||||
|
: t("environments.surveys.share.custom_html.replace_mode_description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workspace Scripts Preview */}
|
||||||
|
{projectCustomScripts && (
|
||||||
|
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
|
||||||
|
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
|
||||||
|
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
|
||||||
|
{projectCustomScripts}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!projectCustomScripts && (
|
||||||
|
<div className="rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{t("environments.surveys.share.custom_html.no_workspace_scripts")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Survey Scripts */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customHeadScripts"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("environments.surveys.share.custom_html.survey_scripts_label")}</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{t("environments.surveys.share.custom_html.survey_scripts_description")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
placeholder={t("environments.surveys.share.custom_html.placeholder")}
|
||||||
|
className={cn(
|
||||||
|
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<Button type="submit" disabled={isSaving || isReadOnly || !isDirty}>
|
||||||
|
{isSaving ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
{/* Security Warning */}
|
||||||
|
<Alert variant="warning" className="flex items-start gap-2">
|
||||||
|
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t("environments.surveys.share.custom_html.security_warning")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
|
||||||
|
interface PrettyUrlInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
publicDomain: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrettyUrlInput = ({ value, onChange, publicDomain, disabled = false }: PrettyUrlInputProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center overflow-hidden rounded-md border border-slate-300 bg-white">
|
||||||
|
<span className="flex-shrink-0 border-r border-slate-300 bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||||
|
{publicDomain}/p/
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value.toLowerCase().replaceAll(/[^a-z0-9-]/g, ""))}
|
||||||
|
placeholder={t("environments.surveys.share.pretty_url.slug_placeholder")}
|
||||||
|
disabled={disabled}
|
||||||
|
className="border-0 bg-white focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Copy, Trash2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
|
import { removeSurveySlugAction, updateSurveySlugAction } from "@/modules/survey/slug/actions";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormProvider,
|
||||||
|
} from "@/modules/ui/components/form";
|
||||||
|
import { PrettyUrlInput } from "./pretty-url-input";
|
||||||
|
|
||||||
|
interface PrettyUrlTabProps {
|
||||||
|
publicDomain: string;
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrettyUrlFormData {
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { survey } = useSurvey();
|
||||||
|
const [isEditing, setIsEditing] = useState(!survey.slug);
|
||||||
|
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Initialize form with current values - memoize to prevent re-initialization
|
||||||
|
const initialFormData = useMemo(() => {
|
||||||
|
return {
|
||||||
|
slug: survey.slug || "",
|
||||||
|
};
|
||||||
|
}, [survey.slug]);
|
||||||
|
|
||||||
|
const form = useForm<PrettyUrlFormData>({
|
||||||
|
defaultValues: initialFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleSubmit, reset } = form;
|
||||||
|
|
||||||
|
// Sync isEditing state and form with survey.slug changes
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEditing(!survey.slug);
|
||||||
|
reset({ slug: survey.slug || "" });
|
||||||
|
}, [survey.slug, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: PrettyUrlFormData) => {
|
||||||
|
if (!data.slug.trim()) {
|
||||||
|
toast.error(t("environments.surveys.share.pretty_url.slug_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await updateSurveySlugAction({
|
||||||
|
surveyId: survey.id,
|
||||||
|
slug: data.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
|
if (errorMessage) {
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} else {
|
||||||
|
toast.success(t("environments.surveys.share.pretty_url.save_success"));
|
||||||
|
router.refresh();
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : t("common.something_went_wrong_please_try_again");
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
reset({ slug: survey.slug || "" });
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await removeSurveySlugAction({ surveyId: survey.id });
|
||||||
|
|
||||||
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
|
if (errorMessage) {
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} else {
|
||||||
|
setShowRemoveDialog(false);
|
||||||
|
reset({ slug: "" });
|
||||||
|
router.refresh();
|
||||||
|
setIsEditing(true);
|
||||||
|
toast.success(t("environments.surveys.share.pretty_url.remove_success"));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : t("common.something_went_wrong_please_try_again");
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = () => {
|
||||||
|
if (!survey.slug) return;
|
||||||
|
const prettyUrl = `${publicDomain}/p/${survey.slug}`;
|
||||||
|
navigator.clipboard.writeText(prettyUrl);
|
||||||
|
toast.success(t("common.copied_to_clipboard"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-1">
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("environments.surveys.share.pretty_url.slug_label")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<PrettyUrlInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
publicDomain={publicDomain}
|
||||||
|
disabled={isReadOnly || !isEditing}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>{t("environments.surveys.share.pretty_url.slug_help")}</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button type="submit" disabled={isReadOnly || isSubmitting}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
{survey.slug && (
|
||||||
|
<Button type="button" variant="secondary" onClick={handleCancel} disabled={isSubmitting}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="secondary" onClick={handleEdit} disabled={isReadOnly}>
|
||||||
|
{t("common.edit")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{survey.slug && !isEditing && (
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="default" onClick={handleCopyUrl} disabled={isReadOnly}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.copy")} URL
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowRemoveDialog(true)}
|
||||||
|
disabled={isReadOnly}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.remove")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
|
||||||
|
<DeleteDialog
|
||||||
|
open={showRemoveDialog}
|
||||||
|
setOpen={setShowRemoveDialog}
|
||||||
|
deleteWhat={t("environments.surveys.share.pretty_url.title")}
|
||||||
|
onDelete={handleRemove}
|
||||||
|
text={t("environments.surveys.share.pretty_url.remove_description")}></DeleteDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -66,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
|||||||
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
||||||
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||||
{t("environments.surveys.summary.use_personal_links")}
|
{t("environments.surveys.summary.use_personal_links")}
|
||||||
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
|
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={`/environments/${environmentId}/settings/notifications`}
|
href={`/environments/${environmentId}/settings/notifications`}
|
||||||
@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
|||||||
{t("environments.surveys.summary.configure_alerts")}
|
{t("environments.surveys.summary.configure_alerts")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/environments/${environmentId}/project/integrations`}
|
href={`/environments/${environmentId}/workspace/integrations`}
|
||||||
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
||||||
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||||
{t("environments.surveys.summary.setup_integrations")}
|
{t("environments.surveys.summary.setup_integrations")}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
|||||||
}
|
}
|
||||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error("Project not found");
|
throw new Error("Workspace not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const styling = getStyling(project, survey);
|
const styling = getStyling(project, survey);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export enum ShareViaType {
|
|||||||
|
|
||||||
export enum ShareSettingsType {
|
export enum ShareSettingsType {
|
||||||
LINK_SETTINGS = "link-settings",
|
LINK_SETTINGS = "link-settings",
|
||||||
|
PRETTY_URL = "pretty-url",
|
||||||
|
CUSTOM_HTML = "custom-html",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LinkTabsType {
|
export enum LinkTabsType {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ListOrderedIcon,
|
ListOrderedIcon,
|
||||||
MessageSquareTextIcon,
|
MessageSquareTextIcon,
|
||||||
MousePointerClickIcon,
|
MousePointerClickIcon,
|
||||||
|
NetworkIcon,
|
||||||
PieChartIcon,
|
PieChartIcon,
|
||||||
Rows3Icon,
|
Rows3Icon,
|
||||||
SmartphoneIcon,
|
SmartphoneIcon,
|
||||||
@@ -99,6 +100,7 @@ const elementIcons = {
|
|||||||
action: MousePointerClickIcon,
|
action: MousePointerClickIcon,
|
||||||
country: FlagIcon,
|
country: FlagIcon,
|
||||||
url: LinkIcon,
|
url: LinkIcon,
|
||||||
|
ipAddress: NetworkIcon,
|
||||||
|
|
||||||
// others
|
// others
|
||||||
Language: LanguagesIcon,
|
Language: LanguagesIcon,
|
||||||
@@ -190,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onValueChange={setInputValue}
|
onValueChange={setInputValue}
|
||||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import {
|
|||||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
|
||||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
@@ -58,12 +58,12 @@ const ElementCheckbox = ({
|
|||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
const handleCheckedChange = (checked: boolean) => {
|
const addElement = () => {
|
||||||
if (checked) {
|
field.onChange([...(field.value || []), element.id]);
|
||||||
field.onChange([...(field.value || []), element.id]);
|
};
|
||||||
} else {
|
|
||||||
field.onChange(field.value?.filter((value) => value !== element.id) || []);
|
const removeElement = () => {
|
||||||
}
|
field.onChange(field.value?.filter((value) => value !== element.id) || []);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,7 +75,7 @@ const ElementCheckbox = ({
|
|||||||
value={element.id}
|
value={element.id}
|
||||||
className="bg-white"
|
className="bg-white"
|
||||||
checked={field.value?.includes(element.id)}
|
checked={field.value?.includes(element.id)}
|
||||||
onCheckedChange={handleCheckedChange}
|
onCheckedChange={(checked) => (checked ? addElement() : removeElement())}
|
||||||
/>
|
/>
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
{getTextContent(recallToHeadline(element.headline, selectedSurvey, false, "default")["default"])}
|
{getTextContent(recallToHeadline(element.headline, selectedSurvey, false, "default")["default"])}
|
||||||
@@ -5,8 +5,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
|||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/ManageIntegration";
|
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/ManageIntegration";
|
||||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||||
import airtableLogo from "@/images/airtableLogo.svg";
|
import airtableLogo from "@/images/airtableLogo.svg";
|
||||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
|||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AddIntegrationModal";
|
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper";
|
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { getAirtableTables } from "@/lib/airtable/service";
|
import { getAirtableTables } from "@/lib/airtable/service";
|
||||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
@@ -42,7 +42,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||||
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
|
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
|
||||||
<div className="h-[75vh] w-full">
|
<div className="h-[75vh] w-full">
|
||||||
<AirtableWrapper
|
<AirtableWrapper
|
||||||
@@ -12,13 +12,13 @@ import {
|
|||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
|
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
|
||||||
import {
|
import {
|
||||||
constructGoogleSheetsUrl,
|
constructGoogleSheetsUrl,
|
||||||
extractSpreadsheetIdFromUrl,
|
extractSpreadsheetIdFromUrl,
|
||||||
isValidGoogleSheetsUrl,
|
isValidGoogleSheetsUrl,
|
||||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
|
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
|
||||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
@@ -266,7 +266,7 @@ export const AddIntegrationModal = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-x-hidden 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">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{surveyElements.map((question) => (
|
{surveyElements.map((question) => (
|
||||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/ManageIntegration";
|
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
|
||||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google";
|
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
|
||||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
TIntegrationGoogleSheetsConfigData,
|
TIntegrationGoogleSheetsConfigData,
|
||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
|||||||
<div className="mt-6 p-6">
|
<div className="mt-6 p-6">
|
||||||
<GoBackButton />
|
<GoBackButton />
|
||||||
<div className="mb-6 text-right">
|
<div className="mb-6 text-right">
|
||||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||||
{t("environments.integrations.google_sheets.link_new_sheet")}
|
{t("environments.integrations.google_sheets.link_new_sheet")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +51,7 @@ const Loading = () => {
|
|||||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center"></div>
|
<div className="text-center"></div>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper";
|
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import {
|
import {
|
||||||
GOOGLE_SHEETS_CLIENT_ID,
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||||
@@ -40,7 +40,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||||
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
|
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
|
||||||
<div className="h-[75vh] w-full">
|
<div className="h-[75vh] w-full">
|
||||||
<GoogleSheetWrapper
|
<GoogleSheetWrapper
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -15,17 +15,15 @@ import {
|
|||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import {
|
import {
|
||||||
ERRORS,
|
MappingRow,
|
||||||
TYPE_MAPPING,
|
TMapping,
|
||||||
UNSUPPORTED_TYPES_BY_NOTION,
|
createEmptyMapping,
|
||||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
|
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
|
||||||
import NotionLogo from "@/images/notion.png";
|
import NotionLogo from "@/images/notion.png";
|
||||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
import { getElementTypes } from "@/modules/survey/lib/elements";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -39,59 +37,6 @@ import {
|
|||||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
|
||||||
const MappingErrorMessage = ({
|
|
||||||
error,
|
|
||||||
col,
|
|
||||||
elem,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
error: { type: string; msg?: React.ReactNode | string } | null | undefined;
|
|
||||||
col: { id: string; name: string; type: string };
|
|
||||||
elem: { id: string; name: string; type: string };
|
|
||||||
t: ReturnType<typeof useTranslation>["t"];
|
|
||||||
}) => {
|
|
||||||
const showErrorMsg = useMemo(() => {
|
|
||||||
switch (error?.type) {
|
|
||||||
case ERRORS.UNSUPPORTED_TYPE:
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
-{" "}
|
|
||||||
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
|
||||||
col_name: col.name,
|
|
||||||
type: col.type,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case ERRORS.MAPPING:
|
|
||||||
const element = getElementTypes(t).find((et) => et.id === elem.type);
|
|
||||||
if (!element) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
|
||||||
que_name: elem.name,
|
|
||||||
question_label: element.label,
|
|
||||||
col_name: col.name,
|
|
||||||
col_type: col.type,
|
|
||||||
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [error, col, elem, t]);
|
|
||||||
|
|
||||||
if (!error) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
|
||||||
<span className="mb-2 block">{error.type}</span>
|
|
||||||
{showErrorMsg}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AddIntegrationModalProps {
|
interface AddIntegrationModalProps {
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
surveys: TSurvey[];
|
surveys: TSurvey[];
|
||||||
@@ -115,21 +60,7 @@ export const AddIntegrationModal = ({
|
|||||||
const { handleSubmit } = useForm();
|
const { handleSubmit } = useForm();
|
||||||
const [selectedDatabase, setSelectedDatabase] = useState<TIntegrationNotionDatabase | null>();
|
const [selectedDatabase, setSelectedDatabase] = useState<TIntegrationNotionDatabase | null>();
|
||||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||||
const [mapping, setMapping] = useState<
|
const [mapping, setMapping] = useState<TMapping[]>([createEmptyMapping()]);
|
||||||
{
|
|
||||||
column: { id: string; name: string; type: string };
|
|
||||||
element: { id: string; name: string; type: string };
|
|
||||||
error?: {
|
|
||||||
type: string;
|
|
||||||
msg: React.ReactNode | string;
|
|
||||||
} | null;
|
|
||||||
}[]
|
|
||||||
>([
|
|
||||||
{
|
|
||||||
column: { id: "", name: "", type: "" },
|
|
||||||
element: { id: "", name: "", type: "" },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
const [isLinkingDatabase, setIsLinkingDatabase] = useState(false);
|
const [isLinkingDatabase, setIsLinkingDatabase] = useState(false);
|
||||||
const integrationData = {
|
const integrationData = {
|
||||||
@@ -234,7 +165,7 @@ export const AddIntegrationModal = ({
|
|||||||
return survey.id === selectedIntegration.surveyId;
|
return survey.id === selectedIntegration.surveyId;
|
||||||
})!
|
})!
|
||||||
);
|
);
|
||||||
setMapping(selectedIntegration.mapping);
|
setMapping(selectedIntegration.mapping.map((m) => ({ ...m, id: createId() })));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -320,154 +251,11 @@ export const AddIntegrationModal = ({
|
|||||||
setSelectedDatabase(null);
|
setSelectedDatabase(null);
|
||||||
setSelectedSurvey(null);
|
setSelectedSurvey(null);
|
||||||
};
|
};
|
||||||
const getFilteredElementItems = (selectedIdx) => {
|
const getFilteredElementItems = (selectedIdx: number) => {
|
||||||
const selectedElementIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id);
|
const selectedElementIds = new Set(
|
||||||
|
mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id)
|
||||||
return elementItems.filter((el) => !selectedElementIds.includes(el.id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const createCopy = (item) => structuredClone(item);
|
|
||||||
|
|
||||||
const MappingRow = ({ idx }: { idx: number }) => {
|
|
||||||
const filteredElementItems = getFilteredElementItems(idx);
|
|
||||||
|
|
||||||
const addRow = () => {
|
|
||||||
setMapping((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
column: { id: "", name: "", type: "" },
|
|
||||||
element: { id: "", name: "", type: "" },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteRow = () => {
|
|
||||||
setMapping((prev) => {
|
|
||||||
return prev.filter((_, i) => i !== idx);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFilteredDbItems = () => {
|
|
||||||
const colMapping = mapping.map((m) => m.column.id);
|
|
||||||
return dbItems.filter((item) => !colMapping.includes(item.id));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<MappingErrorMessage
|
|
||||||
key={idx}
|
|
||||||
error={mapping[idx]?.error}
|
|
||||||
col={mapping[idx].column}
|
|
||||||
elem={mapping[idx].element}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
<div className="flex w-full items-center space-x-2">
|
|
||||||
<div className="flex w-full items-center">
|
|
||||||
<div className="max-w-full flex-1">
|
|
||||||
<DropdownSelector
|
|
||||||
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
|
||||||
items={filteredElementItems}
|
|
||||||
selectedItem={mapping?.[idx]?.element}
|
|
||||||
setSelectedItem={(item) => {
|
|
||||||
setMapping((prev) => {
|
|
||||||
const copy = createCopy(prev);
|
|
||||||
const col = copy[idx].column;
|
|
||||||
if (col.id) {
|
|
||||||
if (UNSUPPORTED_TYPES_BY_NOTION.includes(col.type)) {
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
error: {
|
|
||||||
type: ERRORS.UNSUPPORTED_TYPE,
|
|
||||||
},
|
|
||||||
element: item,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidColType = TYPE_MAPPING[item.type].includes(col.type);
|
|
||||||
if (!isValidColType) {
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
error: {
|
|
||||||
type: ERRORS.MAPPING,
|
|
||||||
},
|
|
||||||
element: item,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
element: item,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={elementItems.length === 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="h-px w-4 border-t border-t-slate-300" />
|
|
||||||
<div className="max-w-full flex-1">
|
|
||||||
<DropdownSelector
|
|
||||||
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
|
|
||||||
items={getFilteredDbItems()}
|
|
||||||
selectedItem={mapping?.[idx]?.column}
|
|
||||||
setSelectedItem={(item) => {
|
|
||||||
setMapping((prev) => {
|
|
||||||
const copy = createCopy(prev);
|
|
||||||
const elem = copy[idx].element;
|
|
||||||
if (elem.id) {
|
|
||||||
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
|
|
||||||
|
|
||||||
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
error: {
|
|
||||||
type: ERRORS.UNSUPPORTED_TYPE,
|
|
||||||
},
|
|
||||||
column: item,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidElemType) {
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
error: {
|
|
||||||
type: ERRORS.MAPPING,
|
|
||||||
},
|
|
||||||
column: item,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
copy[idx] = {
|
|
||||||
...copy[idx],
|
|
||||||
column: item,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
return copy;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={dbItems.length === 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{mapping.length > 1 && (
|
|
||||||
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
|
|
||||||
<TrashIcon />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
|
|
||||||
<PlusIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
return elementItems.filter((el) => !selectedElementIds.has(el.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -539,8 +327,17 @@ export const AddIntegrationModal = ({
|
|||||||
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
|
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="mt-1 space-y-2 overflow-y-auto">
|
<div className="mt-1 space-y-2 overflow-y-auto">
|
||||||
{mapping.map((_, idx) => (
|
{mapping.map((m, idx) => (
|
||||||
<MappingRow idx={idx} key={idx} />
|
<MappingRow
|
||||||
|
key={m.id}
|
||||||
|
idx={idx}
|
||||||
|
mapping={mapping}
|
||||||
|
setMapping={setMapping}
|
||||||
|
filteredElementItems={getFilteredElementItems(idx)}
|
||||||
|
dbItems={dbItems}
|
||||||
|
elementItems={elementItems}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ERRORS,
|
||||||
|
TYPE_MAPPING,
|
||||||
|
UNSUPPORTED_TYPES_BY_NOTION,
|
||||||
|
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/constants";
|
||||||
|
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||||
|
import { getElementTypes } from "@/modules/survey/lib/elements";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||||
|
|
||||||
|
const filterByIdx = (targetIdx: number) => (_: unknown, i: number) => i !== targetIdx;
|
||||||
|
|
||||||
|
export type TColumnOrElement = { id: string; name: string; type: string };
|
||||||
|
export type TMappingError = { type: string; msg?: React.ReactNode | string } | null;
|
||||||
|
export type TMapping = {
|
||||||
|
id: string;
|
||||||
|
column: TColumnOrElement;
|
||||||
|
element: TColumnOrElement;
|
||||||
|
error?: TMappingError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEmptyMapping = (): TMapping => ({
|
||||||
|
id: createId(),
|
||||||
|
column: { id: "", name: "", type: "" },
|
||||||
|
element: { id: "", name: "", type: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const MappingErrorMessage = ({
|
||||||
|
error,
|
||||||
|
col,
|
||||||
|
elem,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
error: TMappingError | undefined;
|
||||||
|
col: TColumnOrElement;
|
||||||
|
elem: TColumnOrElement;
|
||||||
|
t: ReturnType<typeof useTranslation>["t"];
|
||||||
|
}) => {
|
||||||
|
const showErrorMsg = useMemo(() => {
|
||||||
|
switch (error?.type) {
|
||||||
|
case ERRORS.UNSUPPORTED_TYPE: {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
-{" "}
|
||||||
|
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
||||||
|
col_name: col.name,
|
||||||
|
type: col.type,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case ERRORS.MAPPING: {
|
||||||
|
const element = getElementTypes(t).find((et) => et.id === elem.type);
|
||||||
|
if (!element) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
||||||
|
que_name: elem.name,
|
||||||
|
question_label: element.label,
|
||||||
|
col_name: col.name,
|
||||||
|
col_type: col.type,
|
||||||
|
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [error, col, elem, t]);
|
||||||
|
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
||||||
|
<span className="mb-2 block">{error.type}</span>
|
||||||
|
{showErrorMsg}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MappingRowProps {
|
||||||
|
idx: number;
|
||||||
|
mapping: TMapping[];
|
||||||
|
setMapping: React.Dispatch<React.SetStateAction<TMapping[]>>;
|
||||||
|
filteredElementItems: TColumnOrElement[];
|
||||||
|
dbItems: TColumnOrElement[];
|
||||||
|
elementItems: TColumnOrElement[];
|
||||||
|
t: ReturnType<typeof useTranslation>["t"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MappingRow = ({
|
||||||
|
idx,
|
||||||
|
mapping,
|
||||||
|
setMapping,
|
||||||
|
filteredElementItems,
|
||||||
|
dbItems,
|
||||||
|
elementItems,
|
||||||
|
t,
|
||||||
|
}: MappingRowProps) => {
|
||||||
|
const createCopy = (items: TMapping[]) => structuredClone(items);
|
||||||
|
|
||||||
|
const addRow = () => {
|
||||||
|
setMapping((prev) => [...prev, createEmptyMapping()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRow = () => {
|
||||||
|
setMapping((prev) => prev.filter(filterByIdx(idx)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilteredDbItems = () => {
|
||||||
|
const colMapping = new Set(mapping.map((m) => m.column.id));
|
||||||
|
return dbItems.filter((item) => !colMapping.has(item.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleElementSelect = (item: TColumnOrElement) => {
|
||||||
|
setMapping((prev) => {
|
||||||
|
const copy = createCopy(prev);
|
||||||
|
const col = copy[idx].column;
|
||||||
|
|
||||||
|
if (col.id) {
|
||||||
|
if (UNSUPPORTED_TYPES_BY_NOTION.includes(col.type)) {
|
||||||
|
copy[idx] = {
|
||||||
|
...copy[idx],
|
||||||
|
error: { type: ERRORS.UNSUPPORTED_TYPE },
|
||||||
|
element: item,
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidColType = TYPE_MAPPING[item.type].includes(col.type);
|
||||||
|
if (!isValidColType) {
|
||||||
|
copy[idx] = {
|
||||||
|
...copy[idx],
|
||||||
|
error: { type: ERRORS.MAPPING },
|
||||||
|
element: item,
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copy[idx] = { ...copy[idx], element: item, error: null };
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColumnSelect = (item: TColumnOrElement) => {
|
||||||
|
setMapping((prev) => {
|
||||||
|
const copy = createCopy(prev);
|
||||||
|
const elem = copy[idx].element;
|
||||||
|
|
||||||
|
if (elem.id) {
|
||||||
|
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
|
||||||
|
copy[idx] = {
|
||||||
|
...copy[idx],
|
||||||
|
error: { type: ERRORS.UNSUPPORTED_TYPE },
|
||||||
|
column: item,
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
|
||||||
|
if (!isValidElemType) {
|
||||||
|
copy[idx] = {
|
||||||
|
...copy[idx],
|
||||||
|
error: { type: ERRORS.MAPPING },
|
||||||
|
column: item,
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copy[idx] = { ...copy[idx], column: item, error: null };
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<MappingErrorMessage
|
||||||
|
error={mapping[idx]?.error}
|
||||||
|
col={mapping[idx].column}
|
||||||
|
elem={mapping[idx].element}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<div className="flex w-full items-center space-x-2">
|
||||||
|
<div className="flex w-full items-center">
|
||||||
|
<div className="max-w-full flex-1">
|
||||||
|
<DropdownSelector
|
||||||
|
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
||||||
|
items={filteredElementItems}
|
||||||
|
selectedItem={mapping?.[idx]?.element}
|
||||||
|
setSelectedItem={handleElementSelect}
|
||||||
|
disabled={elementItems.length === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-px w-4 border-t border-t-slate-300" />
|
||||||
|
<div className="max-w-full flex-1">
|
||||||
|
<DropdownSelector
|
||||||
|
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
|
||||||
|
items={getFilteredDbItems()}
|
||||||
|
selectedItem={mapping?.[idx]?.column}
|
||||||
|
setSelectedItem={handleColumnSelect}
|
||||||
|
disabled={dbItems.length === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{mapping.length > 1 && (
|
||||||
|
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow} type="button">
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" size="icon" className="size-10" onClick={addRow} type="button">
|
||||||
|
<PlusIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
} from "@formbricks/types/integration/notion";
|
} from "@formbricks/types/integration/notion";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/AddIntegrationModal";
|
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/AddIntegrationModal";
|
||||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/ManageIntegration";
|
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/ManageIntegration";
|
||||||
import notionLogo from "@/images/notion.png";
|
import notionLogo from "@/images/notion.png";
|
||||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
import { authorize } from "../lib/notion";
|
import { authorize } from "../lib/notion";
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
|
|
||||||
|
export const TYPE_MAPPING = {
|
||||||
|
[TSurveyElementTypeEnum.CTA]: ["checkbox"],
|
||||||
|
[TSurveyElementTypeEnum.MultipleChoiceMulti]: ["multi_select"],
|
||||||
|
[TSurveyElementTypeEnum.MultipleChoiceSingle]: ["select", "status"],
|
||||||
|
[TSurveyElementTypeEnum.OpenText]: [
|
||||||
|
"created_by",
|
||||||
|
"created_time",
|
||||||
|
"email",
|
||||||
|
"last_edited_by",
|
||||||
|
"last_edited_time",
|
||||||
|
"number",
|
||||||
|
"phone_number",
|
||||||
|
"rich_text",
|
||||||
|
"title",
|
||||||
|
"url",
|
||||||
|
],
|
||||||
|
[TSurveyElementTypeEnum.NPS]: ["number"],
|
||||||
|
[TSurveyElementTypeEnum.Consent]: ["checkbox"],
|
||||||
|
[TSurveyElementTypeEnum.Rating]: ["number"],
|
||||||
|
[TSurveyElementTypeEnum.PictureSelection]: ["url"],
|
||||||
|
[TSurveyElementTypeEnum.FileUpload]: ["url"],
|
||||||
|
[TSurveyElementTypeEnum.Date]: ["date"],
|
||||||
|
[TSurveyElementTypeEnum.Address]: ["rich_text"],
|
||||||
|
[TSurveyElementTypeEnum.Matrix]: ["rich_text"],
|
||||||
|
[TSurveyElementTypeEnum.Cal]: ["checkbox"],
|
||||||
|
[TSurveyElementTypeEnum.ContactInfo]: ["rich_text"],
|
||||||
|
[TSurveyElementTypeEnum.Ranking]: ["rich_text"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UNSUPPORTED_TYPES_BY_NOTION = [
|
||||||
|
"rollup",
|
||||||
|
"created_by",
|
||||||
|
"created_time",
|
||||||
|
"last_edited_by",
|
||||||
|
"last_edited_time",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ERRORS = {
|
||||||
|
MAPPING: "Mapping Error",
|
||||||
|
UNSUPPORTED_TYPE: "Unsupported type by Notion",
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
|||||||
<div className="mt-6 p-6">
|
<div className="mt-6 p-6">
|
||||||
<GoBackButton />
|
<GoBackButton />
|
||||||
<div className="mb-6 text-right">
|
<div className="mb-6 text-right">
|
||||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||||
{t("environments.integrations.notion.link_database")}
|
{t("environments.integrations.notion.link_database")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +48,7 @@ const Loading = () => {
|
|||||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center"></div>
|
<div className="text-center"></div>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/NotionWrapper";
|
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
||||||
import {
|
import {
|
||||||
NOTION_AUTH_URL,
|
NOTION_AUTH_URL,
|
||||||
NOTION_OAUTH_CLIENT_ID,
|
NOTION_OAUTH_CLIENT_ID,
|
||||||
@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationType } from "@formbricks/types/integration";
|
import { TIntegrationType } from "@formbricks/types/integration";
|
||||||
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/webhook";
|
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/webhook";
|
||||||
import ActivePiecesLogo from "@/images/activepieces.webp";
|
import ActivePiecesLogo from "@/images/activepieces.webp";
|
||||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||||
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
|
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
|
||||||
@@ -79,7 +79,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/webhooks`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/webhooks`,
|
||||||
connectText: t("environments.integrations.manage_webhooks"),
|
connectText: t("environments.integrations.manage_webhooks"),
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
|
||||||
@@ -93,7 +93,7 @@ const Page = async (props) => {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/google-sheets`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/google-sheets`,
|
||||||
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
|
||||||
@@ -107,7 +107,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/airtable`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/airtable`,
|
||||||
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
|
||||||
@@ -121,7 +121,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/slack`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/slack`,
|
||||||
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
|
||||||
@@ -163,7 +163,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/notion`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/notion`,
|
||||||
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
|
||||||
@@ -196,7 +196,7 @@ const Page = async (props) => {
|
|||||||
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
|
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
|
||||||
docsText: t("common.docs"),
|
docsText: t("common.docs"),
|
||||||
docsNewTab: true,
|
docsNewTab: true,
|
||||||
connectHref: `/environments/${params.environmentId}/project/app-connection`,
|
connectHref: `/environments/${params.environmentId}/workspace/app-connection`,
|
||||||
connectText: t("common.connect"),
|
connectText: t("common.connect"),
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
label: "Javascript SDK",
|
label: "Javascript SDK",
|
||||||
@@ -209,7 +209,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("common.project_configuration")}>
|
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||||
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
|
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
|
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user