Compare commits

...

101 Commits
4.4.0 ... main

Author SHA1 Message Date
Matti Nannt
9573ae19e6 fix(security): upgrade next and lodash to fix vulnerabilities (#7179)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 06:51:37 +00:00
Matti Nannt
7b3f841c5e fix(security): upgrade qs to fix DoS vulnerability (#7178)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 05:53:59 +00:00
Dhruwang Jariwala
8f7d225d6a fix: jerky animation behaviour (#7158) 2026-01-23 12:26:57 +00:00
Anshuman Pandey
094b6dedba fix: fixes response card UI for cta question (#7157) 2026-01-23 10:29:01 +00:00
Anshuman Pandey
36f0be07c4 fix: handle server errors in survey publish flow (#7156) 2026-01-23 08:54:11 +00:00
Bhagya Amarasinghe
e079055a43 fix(helm): DB migration job (#7152) 2026-01-23 07:58:54 +00:00
Bhagya Amarasinghe
9ae9a3a9fc fix(helm): update ExternalSecret API version to v1 (#7153) 2026-01-23 07:03:50 +00:00
Dhruwang Jariwala
b4606c0113 fix: nps & rating rtl UI (#7154) 2026-01-23 06:46:41 +00:00
Dhruwang Jariwala
6be654ab60 fix: language variants not working for app surveys (#7151) 2026-01-23 06:46:21 +00:00
dependabot[bot]
95c2e24416 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#7149)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-01-22 12:06:12 +00:00
Theodór Tómas
5b86dd3a8f feat: question delete dialog (#7144) 2026-01-22 09:41:54 +00:00
Dhruwang Jariwala
0da083a214 fix: billing checks (#7137) 2026-01-22 09:24:13 +00:00
Dhruwang Jariwala
379a86cf46 fix: survey card animation issue (#7150) 2026-01-22 07:58:18 +00:00
Johannes
bed78716f0 fix: add validation for variable name conflicts with hidden fields (#7148) 2026-01-22 07:36:09 +00:00
Johannes
6167c3d9e6 fix: make redirect wait for successful response completion (#7146) 2026-01-22 06:55:54 +00:00
Dhruwang Jariwala
1db1271e7f feat: validation rules (#7140) 2026-01-21 15:23:09 +00:00
Matti Nannt
9ec1964106 fix(security): upgrade react-email packages to fix transitive next.js vulnerability (#7145) 2026-01-21 16:00:33 +01:00
Dhruwang Jariwala
d5a70796dd chore: tweaked validation of ending card url (#7139)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-01-21 14:41:36 +00:00
Dhruwang Jariwala
246351b3e6 fix: quotas not working for multi lang surveys (#7141) 2026-01-21 14:23:16 +00:00
Dhruwang Jariwala
22ea7302bb fix: removed validation from button labels (#7138) 2026-01-21 14:14:22 +00:00
Dhruwang Jariwala
8d47ab9709 fix: rtl tweaks (#7136) 2026-01-21 07:08:22 +00:00
Matti Nannt
8f6d27c1ef fix: upgrade next.js and preact to fix high-severity vulnerabilities (#7134) 2026-01-20 11:22:01 +00:00
Dhruwang Jariwala
a37815b831 fix: breaking email embed preview for single select question (#7133) 2026-01-20 06:42:15 +00:00
Dhruwang Jariwala
2b526a87ca fix: email locale in invite accepted email (#7124) 2026-01-19 13:32:01 +00:00
Dhruwang Jariwala
047750967c fix: console warnings in survey ui package (#7130) 2026-01-19 07:19:13 +00:00
Johannes
a54356c3b0 docs: add CSAT and update Survey Cooldown (#7128)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-19 07:06:16 +00:00
Matti Nannt
38ea5ed6ae perf: remove redundant database indexes (#7104) 2026-01-16 10:17:05 +00:00
Dhruwang Jariwala
6e19de32f7 fix: org managers not able to access api keys (#7123) 2026-01-16 09:54:54 +00:00
Johannes
957a4432f4 feat: introduce language variations (#7082)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-16 08:51:20 +00:00
Matti Nannt
22a5d4bb7d chore: consolidate agent instructions and remove Cursor rules (#7096) 2026-01-16 08:20:23 +00:00
Matti Nannt
226dff0344 fix: upgrade storybook to v10.1.11 (#7120) 2026-01-16 07:19:18 +00:00
Dhruwang Jariwala
d474a94a21 fix: multi lang button label issue (#7117) 2026-01-15 17:57:50 +00:00
dependabot[bot]
c1a4cc308b chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#7081)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-01-15 15:10:33 +01:00
Dhruwang Jariwala
210da98b69 fix: scrolling in project breadcrumb dropdown (#7118) 2026-01-15 11:59:17 +00:00
Matti Nannt
2fc183d384 chore: update pre-commit hook to address husky warning (#7106) 2026-01-15 07:42:37 +00:00
Dhruwang Jariwala
78fb111610 fix: syntax issue in pr check size github action (#7116) 2026-01-15 06:43:59 +00:00
Bhagya Amarasinghe
11c0cb4b61 fix: add required WEBAPP_URL/NEXTAUTH_URL config and improve helm chart (#7107) 2026-01-14 18:26:40 +00:00
Johannes
95831f7c7f feat: add auto-save for draft surveys and Cmd+S hotkey (#7087)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-14 17:23:34 +00:00
Anshuman Pandey
a31e7bfaa5 feat: security signup ui (#7088)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-01-14 16:45:21 +00:00
Matti Nannt
6e35fc1769 fix: update systeminformation to 5.27.14 (#7105) 2026-01-14 11:04:43 +00:00
Theodór Tómas
48cded1646 perf: decouple constants from zod and add bundle analyzer (#7101) 2026-01-14 09:50:05 +00:00
Dhruwang Jariwala
db752cee15 feat: add support for mp3 file extension and corresponding MIME type (#7103)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-13 12:19:22 +00:00
Dhruwang Jariwala
b33aae0a73 fix: missing Russian langauge in language select dropdown (#7099) 2026-01-13 10:08:50 +00:00
Matti Nannt
72126ad736 fix: required label not being translated (#7092) 2026-01-13 10:05:11 +00:00
Theodór Tómas
4a2eeac90b perf: reduce bundle size (#7094) 2026-01-12 16:57:12 +00:00
Anshuman Pandey
46be3e7d70 feat: webhook secret (#7084) 2026-01-09 12:31:29 +00:00
Dhruwang Jariwala
6d140532a7 feat: add IP address capture functionality to surveys (#7079) 2026-01-09 11:28:05 +00:00
Dhruwang Jariwala
8c4a7f1518 fix: remove subheader field from survey element presets (#7078) 2026-01-09 08:28:48 +00:00
Dhruwang Jariwala
63fe32a786 chore: parallel processing in lingo.dev (#7080) 2026-01-08 05:03:31 +00:00
Matti Nannt
84c465f974 fix: ensure deterministic instanceId via secondary sort key (#7070) 2026-01-07 14:04:56 +00:00
Johannes
6a33498737 feat: Custom HTML scripts in link surveys (#7064)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 10:06:41 +00:00
Matti Nannt
5130c747d4 chore: license server staging config (#7075)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 09:50:18 +00:00
Dhruwang Jariwala
f5583d2652 fix: add background color to button URL input in CTA element form (#7077) 2026-01-07 09:17:38 +00:00
Fahleen Arif
e0d75914a4 fix: update placeholder text for name input field in invite members form (#7054)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 08:18:36 +00:00
Dhruwang Jariwala
f02ca1cfe1 chore: remove string concatenation welcome card (#7073)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
2026-01-07 07:25:20 +00:00
Anshuman Pandey
4ade83f189 fix: contacts refresh button (#7066) 2026-01-06 12:31:20 +00:00
Jagadish Madavalkar
f1fc9fea2c fix: api-wrapper returns valid malformed response (#7053)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-06 10:24:39 +00:00
Dhruwang Jariwala
25266e4566 fix: disappearing survey preview (#7065) 2026-01-06 06:23:11 +00:00
Matti Nannt
b960cfd2a1 chore: harden CSP and X-Frame-Options headers (#7062)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-06 06:21:19 +00:00
Matti Nannt
9e1d1c1dc2 feat: implement robust database seeding strategy (#7017)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-05 15:58:58 +00:00
Matti Nannt
8c63a9f7af chore: remove debug log from next.config.mjs (#7063) 2026-01-05 15:52:04 +00:00
Anshuman Pandey
fff0a7f052 fix: fixes duplicate userId issue with the contacts UI (#7051)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-05 09:21:50 +00:00
Anshuman Pandey
0ecc8aabff fix: fixes single use multi lang surveyUrl issue (#7057) 2026-01-05 06:08:15 +00:00
Dhruwang Jariwala
01cc0ab64d fix: correct typo in recontact waiting time description and adjust da… (#7056) 2026-01-05 06:02:28 +00:00
Anshuman Pandey
1d125bdac2 fix: fixes user api attribute override error (#7050) 2026-01-05 05:55:22 +00:00
Anshuman Pandey
ca67c4d5a8 feat: rename projects to workspaces (#7041)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-31 07:24:04 +00:00
Dhruwang Jariwala
d167d591ce fix: make description optional for consent and CTA elements (#7047) 2025-12-30 10:05:26 +00:00
Anshuman Pandey
acc3b0179a fix: defers page view actions to allow user context to be set first (#7048) 2025-12-30 08:56:14 +00:00
Johannes
3434b5cf08 fix: tweak edit attributes for contact UI (#7046)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-29 14:58:15 +00:00
Dhruwang Jariwala
a618f2df95 fix(types): use z.coerce.date() for ZActionClass timestamps (#7045)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-29 14:47:09 +00:00
Dhruwang Jariwala
5b334f6623 feat: UI to change attribute value for contacts (#7040) 2025-12-29 13:09:29 +00:00
Anshuman Pandey
fa2b63d6a1 feat: custom favicon (#7044) 2025-12-29 12:44:32 +00:00
Dhruwang Jariwala
9f0fe69b6b fix: typos (Duplicate of 7042) (#7043)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
2025-12-29 06:19:54 +00:00
Dhruwang Jariwala
98cb2de02b feat: UI to manage attribute keys (#7038) 2025-12-26 10:02:37 +00:00
Anshuman Pandey
f00d0b7e20 fix: setUserId lets users override the previous userId (#7035) 2025-12-25 07:10:56 +00:00
Johannes
65abd4ee07 feat: add pretty URL UI components for surveys (#6969)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-24 06:39:46 +00:00
Johannes
939f135bf4 chore: unify error state for all questions types (#7001)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-24 06:36:48 +00:00
Johannes
729a16854a fix: German translations (#7033)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
2025-12-24 06:36:21 +00:00
Dhruwang Jariwala
a2d3e37d69 fix: CSS variable pollution (#7026) 2025-12-24 05:54:52 +00:00
Dhruwang Jariwala
adf12f551d fix: Swedish translations (#7032) 2025-12-23 12:02:26 +00:00
Dhruwang Jariwala
3f2bddc358 feat: Russian translations (#7027) 2025-12-23 10:31:09 +00:00
Dhruwang Jariwala
ae6d1ac133 chore: improve wording in email text (Duplicate of #7003) (#7025)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-23 09:56:53 +00:00
Dhruwang Jariwala
7c4569cd50 fix: file upload validation (#7028) 2025-12-23 09:36:45 +00:00
Matti Nannt
7354122447 fix: update V2 API OpenAPI paths to include full prefixes (#6983)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-23 06:29:25 +00:00
Matti Nannt
d54dca2b27 docs: update thanks section with chromatic and sentry logos (#7031) 2025-12-22 16:40:39 +00:00
Anshuman Pandey
acd5cff534 feat: email package for client side email components (#6986) 2025-12-22 14:13:06 +00:00
Matti Nannt
834929e766 feat: configure @formbricks/survey-ui for external publishing (#6991) 2025-12-22 12:39:54 +00:00
Dhruwang Jariwala
09f40ad816 fix: required cta issue (#7022) 2025-12-22 08:35:08 +00:00
Harsh Bhat
689b6491b3 docs: Link vs In app surveys (#7006)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-22 08:13:45 +00:00
Johannes
b70b2eef95 fix: vimeo + loom embed (#7018)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-20 08:08:48 +00:00
Harsh Bhat
392a95834b docs: Best practices Panel Management (#7011) 2025-12-20 06:32:57 +00:00
Anshuman Pandey
66d9cc8eac chore: adds docs for min browser version support (#7014) 2025-12-19 10:02:01 +00:00
Johannes
befdc078f1 fix: replace isomorphic-dompurify with sanitize-html in server component (#7002) 2025-12-19 07:34:56 +00:00
Dhruwang Jariwala
13b983b3b2 fix: missing question media (#6997)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-19 07:29:06 +00:00
Harsh Bhat
1e285ebe4e docs: Remove references of delay removal with debug mode (#7009) 2025-12-19 07:03:02 +00:00
Dhruwang Jariwala
a7c4971952 fix: replaced bg-white with survey-bg color in surveys package (#7004)
Co-authored-by: Luis Gustavo S. Barreto <gustavo@ossystems.com.br>
2025-12-19 06:50:33 +00:00
Dhruwang Jariwala
c8689d91d5 fix: empty button in cta question (#6995) 2025-12-18 21:18:48 +00:00
Dhruwang Jariwala
73a2ff7421 fix: border radius for inputs (#6996) 2025-12-18 20:56:47 +00:00
Dhruwang Jariwala
0c28e89b41 fix: missing required question warning (#6998) 2025-12-18 19:12:47 +00:00
Anshuman Pandey
a736436e29 chore: fixes typo (#6993) 2025-12-18 09:25:12 +00:00
Johannes
7dbb0300d3 fix: Pass the isExternalUrlAllowed prop to welcome card (#6992) 2025-12-18 08:51:21 +00:00
650 changed files with 34353 additions and 12510 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -168,6 +168,9 @@ SLACK_CLIENT_SECRET=
# 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
# 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)

View File

@@ -13,13 +13,12 @@ jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
packages: write
id-token: write
actions: read
contents: read
steps:
- 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:
egress-policy: audit
@@ -27,16 +26,34 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
- name: Install pnpm
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
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
workingDir: apps/storybook
zip: true

View File

@@ -111,27 +111,21 @@ jobs:
const additions = ${{ steps.check-size.outputs.total_additions }};
const deletions = ${{ steps.check-size.outputs.total_deletions }};
const body = `## 🚨 PR Size Warning
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.
### 💡 Suggestions:
- **Split by feature or module** - Break down into logical, independent pieces
- **Create a sequence of PRs** - Each building on the previous one
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
### 📊 What was counted:
- ✅ Source files, stylesheets, configuration files
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
### 📚 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.`;
const body = '## 🚨 PR Size Warning\n\n' +
'This PR has approximately **' + totalChanges + ' lines** of changes (' + additions + ' additions, ' + deletions + ' deletions across ' + countedFiles + ' files).\n\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.\n\n' +
'### 💡 Suggestions:\n' +
'- **Split by feature or module** - Break down into logical, independent pieces\n' +
'- **Create a sequence of PRs** - Each building on the previous one\n' +
'- **Branch off PR branches** - Don\'t wait for reviews to continue dependent work\n\n' +
'### 📊 What was counted:\n' +
'- ✅ Source files, stylesheets, configuration files\n' +
'- ❌ Excluded ' + excludedFiles + ' files (tests, locales, locks, generated files)\n\n' +
'### 📚 Guidelines:\n' +
'- **Ideal:** 300-500 lines per PR\n' +
'- **Warning:** 500-800 lines\n' +
'- **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.';
// Check if we already commented
const { data: comments } = await github.rest.issues.listComments({

1
.gitignore vendored
View File

@@ -62,3 +62,4 @@ branch.json
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
.cursorrules
i18n.cache
stats.html

View File

@@ -1,6 +1,3 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Load environment variables from .env files
if [ -f .env ]; then
set -a

View File

@@ -18,11 +18,65 @@ Formbricks runs as a pnpm/turbo monorepo. `apps/web` is the Next.js product surf
## 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.
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
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
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.

View File

@@ -203,6 +203,14 @@ Here are a few options:
</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>
&nbsp;&nbsp;&nbsp;&nbsp;
<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>
## 📆 Contact us

View File

@@ -11,24 +11,24 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"@formbricks/survey-ui": "workspace:*",
"eslint-plugin-react-refresh": "0.4.24"
"@formbricks/survey-ui": "workspace:*"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.3",
"@storybook/addon-a11y": "10.0.8",
"@storybook/addon-links": "10.0.8",
"@storybook/addon-onboarding": "10.0.8",
"@storybook/react-vite": "10.0.8",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@tailwindcss/vite": "4.1.17",
"@typescript-eslint/parser": "8.48.0",
"@vitejs/plugin-react": "5.1.1",
"esbuild": "0.27.0",
"eslint-plugin-storybook": "10.0.8",
"@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-a11y": "10.1.11",
"@storybook/addon-links": "10.1.11",
"@storybook/addon-onboarding": "10.1.11",
"@storybook/react-vite": "10.1.11",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@tailwindcss/vite": "4.1.18",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"esbuild": "0.25.12",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.1.11",
"prop-types": "15.8.1",
"storybook": "10.0.8",
"vite": "7.2.4",
"@storybook/addon-docs": "10.0.8"
"storybook": "10.1.11",
"vite": "7.3.1",
"@storybook/addon-docs": "10.1.11"
}
}

7
apps/web/.eslintignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.next/
public/
playwright/
dist/
coverage/
vendor/

View File

@@ -104,6 +104,9 @@ 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
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
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
@@ -122,6 +125,9 @@ RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./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
RUN chmod -R 755 ./node_modules/@noble/hashes

View File

@@ -25,7 +25,7 @@ const Page = async (props: ConnectPageProps) => {
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.project_not_found"));
throw new Error(t("common.workspace_not_found"));
}
const channel = project.config.channel || null;
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<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"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -38,7 +38,7 @@ const Page = async (props: XMTemplatePageProps) => {
const project = await getProjectByEnvironmentId(environment.id);
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);
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<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"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -50,8 +50,8 @@ const Page = async (props) => {
</div>
<div className="flex h-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.landing.no_projects_warning_title")}
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
title={t("organizations.landing.no_workspaces_warning_title")}
subtitle={t("organizations.landing.no_workspaces_warning_subtitle")}
/>
</div>
</div>

View File

@@ -26,16 +26,16 @@ const Page = async (props: ChannelPageProps) => {
const t = await getTranslate();
const channelOptions = [
{
title: t("organizations.projects.new.channel.link_and_email_surveys"),
description: t("organizations.projects.new.channel.link_and_email_surveys_description"),
title: t("organizations.workspaces.new.channel.link_and_email_surveys"),
description: t("organizations.workspaces.new.channel.link_and_email_surveys_description"),
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"),
description: t("organizations.projects.new.channel.in_product_surveys_description"),
title: t("organizations.workspaces.new.channel.in_product_surveys"),
description: t("organizations.workspaces.new.channel.in_product_surveys_description"),
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 (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.projects.new.channel.channel_select_title")}
subtitle={t("organizations.projects.new.channel.channel_select_subtitle")}
title={t("organizations.workspaces.new.channel.channel_select_title")}
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
/>
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<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"
asChild>
<Link href={"/"}>

View File

@@ -15,7 +15,7 @@ const OnboardingLayout = async (props) => {
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session || !session.user) {
if (!session?.user) {
return redirect(`/auth/login`);
}

View File

@@ -26,16 +26,16 @@ const Page = async (props: ModePageProps) => {
const t = await getTranslate();
const channelOptions = [
{
title: t("organizations.projects.new.mode.formbricks_surveys"),
description: t("organizations.projects.new.mode.formbricks_surveys_description"),
title: t("organizations.workspaces.new.mode.formbricks_surveys"),
description: t("organizations.workspaces.new.mode.formbricks_surveys_description"),
icon: ListTodoIcon,
href: `/organizations/${params.organizationId}/projects/new/channel`,
href: `/organizations/${params.organizationId}/workspaces/new/channel`,
},
{
title: t("organizations.projects.new.mode.formbricks_cx"),
description: t("organizations.projects.new.mode.formbricks_cx_description"),
title: t("organizations.workspaces.new.mode.formbricks_cx"),
description: t("organizations.workspaces.new.mode.formbricks_cx_description"),
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 (
<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} />
{projects.length >= 1 && (
<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"
asChild>
<Link href={"/"}>

View File

@@ -79,7 +79,7 @@ export const ProjectSettings = ({
(environment) => environment.type === "production"
);
if (productionEnvironment) {
if (typeof window !== "undefined") {
if (globalThis.window !== undefined) {
// Rmove filters when creating a new project
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
@@ -96,7 +96,7 @@ export const ProjectSettings = ({
toast.error(errorMessage);
}
} 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);
}
};
@@ -107,7 +107,6 @@ export const ProjectSettings = ({
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [],
},
resolver: zodResolver(ZProjectUpdateInput),
});
const projectName = form.watch("name");
@@ -131,9 +130,9 @@ export const ProjectSettings = ({
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div>
<FormLabel>{t("organizations.projects.new.settings.brand_color")}</FormLabel>
<FormLabel>{t("organizations.workspaces.new.settings.brand_color")}</FormLabel>
<FormDescription>
{t("organizations.projects.new.settings.brand_color_description")}
{t("organizations.workspaces.new.settings.brand_color_description")}
</FormDescription>
</div>
<FormControl>
@@ -155,9 +154,9 @@ export const ProjectSettings = ({
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div>
<FormLabel>{t("organizations.projects.new.settings.project_name")}</FormLabel>
<FormLabel>{t("organizations.workspaces.new.settings.workspace_name")}</FormLabel>
<FormDescription>
{t("organizations.projects.new.settings.project_name_description")}
{t("organizations.workspaces.new.settings.workspace_name_description")}
</FormDescription>
</div>
<FormControl>
@@ -186,7 +185,7 @@ export const ProjectSettings = ({
<div>
<FormLabel>{t("common.teams")}</FormLabel>
<FormDescription>
{t("organizations.projects.new.settings.team_description")}
{t("organizations.workspaces.new.settings.team_description")}
</FormDescription>
</div>
<Button
@@ -194,7 +193,7 @@ export const ProjectSettings = ({
size="sm"
type="button"
onClick={() => setCreateTeamModalOpen(true)}>
{t("organizations.projects.new.settings.create_new_team")}
{t("organizations.workspaces.new.settings.create_new_team")}
</Button>
</div>
<FormControl>
@@ -227,7 +226,7 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
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>

View File

@@ -3,7 +3,7 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
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 { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
@@ -53,8 +53,8 @@ const Page = async (props: ProjectSettingsPageProps) => {
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.projects.new.settings.project_settings_title")}
subtitle={t("organizations.projects.new.settings.project_settings_subtitle")}
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
/>
<ProjectSettings
organizationId={params.organizationId}
@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<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"
asChild>
<Link href={"/"}>

View File

@@ -0,0 +1 @@
export { AttributesPage as default } from "@/modules/ee/contacts/attributes/page";

View File

@@ -57,7 +57,7 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
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) {

View File

@@ -43,7 +43,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// Validate that project permission exists for members
if (isMember && !projectPermission) {
throw new Error(t("common.project_permission_not_found"));
throw new Error(t("common.workspace_permission_not_found"));
}
return (

View File

@@ -113,7 +113,7 @@ export const MainNavigation = ({
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/project/general`,
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/project"),
},
@@ -164,7 +164,7 @@ export const MainNavigation = ({
<aside
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",
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
)}>
<div>
{/* Logo and Toggle */}
@@ -185,7 +185,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus: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 ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -17,13 +17,13 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
const stati = {
notImplemented: {
icon: AlertTriangleIcon,
title: t("environments.project.app-connection.formbricks_sdk_not_connected"),
subtitle: t("environments.project.app-connection.formbricks_sdk_not_connected_description"),
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
},
running: {
icon: CheckIcon,
title: t("environments.project.app-connection.receiving_data"),
subtitle: t("environments.project.app-connection.formbricks_sdk_connected"),
title: t("environments.workspace.app-connection.receiving_data"),
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
},
};
@@ -53,11 +53,11 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon />
</div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-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" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon />
{t("environments.project.app-connection.recheck")}
{t("environments.workspace.app-connection.recheck")}
</Button>
)}
</div>

View File

@@ -2,7 +2,7 @@
import * as Sentry from "@sentry/nextjs";
import {
BuildingIcon,
Building2Icon,
ChevronDownIcon,
ChevronRightIcon,
Loader2,
@@ -144,6 +144,12 @@ export const OrganizationBreadcrumb = ({
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${currentEnvironmentId}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
@@ -166,7 +172,7 @@ export const OrganizationBreadcrumb = ({
id="organizationDropdownTrigger"
asChild>
<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>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isOrganizationDropdownOpen ? (
@@ -180,7 +186,7 @@ export const OrganizationBreadcrumb = ({
{showOrganizationDropdown && (
<>
<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")}
</div>
{isLoadingOrganizations && (
@@ -203,7 +209,7 @@ export const OrganizationBreadcrumb = ({
)}
{!isLoadingOrganizations && !loadError && (
<>
<DropdownMenuGroup>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}

View File

@@ -1,7 +1,7 @@
"use client";
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 { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
@@ -36,12 +36,12 @@ interface ProjectBreadcrumbProps {
}
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/")) {
return false;
}
// Check if path matches /project/{settingId} (with optional trailing path)
const pattern = new RegExp(`/project/${settingId}(?:/|$)`);
// Check if path matches /workspace/{settingId} (with optional trailing path)
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
@@ -90,7 +90,7 @@ export const ProjectBreadcrumb = ({
const error = new Error(errorMessage);
logger.error(error, "Failed to load projects");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_projects"));
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
}
setIsLoadingProjects(false);
});
@@ -101,42 +101,42 @@ export const ProjectBreadcrumb = ({
{
id: "general",
label: t("common.general"),
href: `/environments/${currentEnvironmentId}/project/general`,
href: `/environments/${currentEnvironmentId}/workspace/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${currentEnvironmentId}/project/look`,
href: `/environments/${currentEnvironmentId}/workspace/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${currentEnvironmentId}/project/app-connection`,
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${currentEnvironmentId}/project/integrations`,
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${currentEnvironmentId}/project/teams`,
href: `/environments/${currentEnvironmentId}/workspace/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${currentEnvironmentId}/project/languages`,
href: `/environments/${currentEnvironmentId}/workspace/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/project/tags`,
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
];
if (!currentProject) {
const errorMessage = `Project not found for project id: ${currentProjectId}`;
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
@@ -145,7 +145,7 @@ export const ProjectBreadcrumb = ({
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
startTransition(() => {
router.push(`/projects/${projectId}/`);
router.push(`/workspaces/${projectId}/`);
});
};
@@ -159,7 +159,7 @@ export const ProjectBreadcrumb = ({
const handleProjectSettingsNavigation = (settingId: string) => {
startTransition(() => {
router.push(`/environments/${currentEnvironmentId}/project/${settingId}`);
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
});
};
@@ -198,21 +198,21 @@ export const ProjectBreadcrumb = ({
id="projectDropdownTrigger"
asChild>
<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>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isProjectDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
<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>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-2">
<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} />
{t("common.choose_project")}
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingProjects && (
<div className="flex items-center justify-center py-2">
@@ -234,7 +234,7 @@ export const ProjectBreadcrumb = ({
)}
{!isLoadingProjects && !loadError && (
<>
<DropdownMenuGroup>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
@@ -251,7 +251,7 @@ export const ProjectBreadcrumb = ({
<DropdownMenuCheckboxItem
onClick={handleAddProject}
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} />
</DropdownMenuCheckboxItem>
)}
@@ -261,7 +261,7 @@ export const ProjectBreadcrumb = ({
<DropdownMenuSeparator />
<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} />
{t("common.project_configuration")}
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<DropdownMenuCheckboxItem

View File

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

View File

@@ -21,7 +21,7 @@ const AccountSettingsLayout = async (props) => {
}
if (!project) {
throw new Error(t("common.project_not_found"));
throw new Error(t("common.workspace_not_found"));
}
if (!session) {

View File

@@ -16,7 +16,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
<p className="text-sm">
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
<a
href={`/environments/${environmentId}/project/integrations`}
href={`/environments/${environmentId}/workspace/integrations`}
className="ml-1 cursor-pointer text-sm underline">
{t("environments.settings.notifications.use_the_integration")}
</a>

View File

@@ -58,7 +58,7 @@ async function handleEmailUpdate({
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
await sendVerificationNewEmail(ctx.user.id, inputEmail, ctx.user.locale);
}
return payload;
}

View File

@@ -47,6 +47,13 @@ export const OrganizationSettingsNavbar = ({
current: pathname?.includes("/api-keys"),
hidden: !isOwner,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${environmentId}/settings/domain`,
current: pathname?.includes("/domain"),
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),

View File

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

View File

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

View File

@@ -39,7 +39,7 @@ const Page = async (props) => {
onRequest: false,
},
{
title: t("environments.project.languages.multi_language_surveys"),
title: t("environments.workspace.languages.multi_language_surveys"),
comingSoon: 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">
<svg
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">
<circle
cx={512}

View File

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

View File

@@ -12,6 +12,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
@@ -48,6 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</Alert>
</div>
)}
{!IS_FORMBRICKS_CLOUD && <SecurityListTip />}
<SettingsCard
title={t("environments.settings.general.organization_name")}
description={t("environments.settings.general.organization_name_description")}>

View File

@@ -21,7 +21,7 @@ const Layout = async (props) => {
}
if (!project) {
throw new Error(t("common.project_not_found"));
throw new Error(t("common.workspace_not_found"));
}
if (!session) {

View File

@@ -27,7 +27,7 @@ export const EmptyAppSurveys = ({ environment }: TEmptyAppSurveysProps) => {
{t("environments.surveys.summary.connect_your_website_or_app_with_formbricks_to_get_started")}
</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">
{t("common.connect")}
</Button>

View File

@@ -58,6 +58,7 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
ctx.user.email,
emailHtml,
survey.environmentId,
ctx.user.locale,
organizationLogoUrl || ""
);
});

View File

@@ -213,6 +213,7 @@ export const SurveyAnalysisCTA = ({
isFormbricksCloud={isFormbricksCloud}
isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured}
projectCustomScripts={project.customHeadScripts}
/>
)}
<SuccessMessage environment={environment} survey={survey} />

View File

@@ -3,7 +3,8 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import {
Code2Icon,
LinkIcon,
CodeIcon,
Link2Icon,
MailIcon,
QrCodeIcon,
Settings,
@@ -18,10 +19,12 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
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 { 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 { 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 { 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 { 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";
@@ -50,6 +53,7 @@ interface ShareSurveyModalProps {
isFormbricksCloud: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
projectCustomScripts?: string | null;
}
export const ShareSurveyModal = ({
@@ -64,6 +68,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
isReadOnly,
isStorageConfigured,
projectCustomScripts,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
@@ -80,13 +85,13 @@ export const ShareSurveyModal = ({
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(
() => [
}[] = useMemo(() => {
const tabs = [
{
id: ShareViaType.ANON_LINKS,
type: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.anonymous_links.nav_title"),
icon: LinkIcon,
icon: Link2Icon,
title: t("environments.surveys.share.anonymous_links.nav_title"),
description: t("environments.surveys.share.anonymous_links.description"),
componentType: AnonymousLinksTab,
@@ -180,22 +185,49 @@ export const ShareSurveyModal = ({
componentType: LinkSettingsTab,
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
},
],
[
t,
survey,
publicDomain,
user.locale,
surveyUrl,
isReadOnly,
environmentId,
segments,
isContactsEnabled,
isFormbricksCloud,
email,
isStorageConfigured,
]
);
{
id: ShareSettingsType.PRETTY_URL,
type: LinkTabsType.SHARE_SETTING,
label: t("environments.surveys.share.pretty_url.title"),
icon: Link2Icon,
title: t("environments.surveys.share.pretty_url.title"),
description: t("environments.surveys.share.pretty_url.description"),
componentType: PrettyUrlTab,
componentProps: { publicDomain, isReadOnly },
},
{
id: ShareSettingsType.CUSTOM_HTML,
type: LinkTabsType.SHARE_SETTING,
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(() => {
if (survey.type !== "link") {

View File

@@ -2,7 +2,7 @@
import { CirclePlayIcon, CopyIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -34,7 +34,6 @@ export const AnonymousLinksTab = ({
locale,
isReadOnly,
}: AnonymousLinksTabProps) => {
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
const router = useRouter();
const { t } = useTranslation();
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
pendingAction: () => Promise<void> | void;
} | null>(null);
const surveyUrlWithCustomSuid = useMemo(() => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", "CUSTOM-ID");
return url.toString();
}, [surveyUrl]);
const resetState = () => {
const { singleUse } = survey;
const { enabled, isEncrypted } = singleUse ?? {};
@@ -177,7 +182,11 @@ export const AnonymousLinksTab = ({
if (!!response?.data?.length) {
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
const csvContent = surveyLinks.join("\n");

View File

@@ -163,7 +163,7 @@ export const AppTab = () => {
</AlertDescription>
{!environment.appSetupCompleted && (
<AlertButton asChild>
<Link href={`/environments/${environment.id}/project/app-connection`}>
<Link href={`/environments/${environment.id}/workspace/app-connection`}>
{t("common.connect_formbricks")}
</Link>
</AlertButton>

View File

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

View File

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

View File

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

View File

@@ -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">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{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>
<Link
href={`/environments/${environmentId}/settings/notifications`}
@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
{t("environments.surveys.summary.configure_alerts")}
</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">
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.setup_integrations")}

View File

@@ -13,7 +13,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
}
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Project not found");
throw new Error("Workspace not found");
}
const styling = getStyling(project, survey);

View File

@@ -12,6 +12,8 @@ export enum ShareViaType {
export enum ShareSettingsType {
LINK_SETTINGS = "link-settings",
PRETTY_URL = "pretty-url",
CUSTOM_HTML = "custom-html",
}
export enum LinkTabsType {

View File

@@ -21,6 +21,7 @@ import {
ListOrderedIcon,
MessageSquareTextIcon,
MousePointerClickIcon,
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmartphoneIcon,
@@ -99,6 +100,7 @@ const elementIcons = {
action: MousePointerClickIcon,
country: FlagIcon,
url: LinkIcon,
ipAddress: NetworkIcon,
// others
Language: LanguagesIcon,
@@ -190,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
value={inputValue}
onValueChange={setInputValue}
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

View File

@@ -17,9 +17,9 @@ import {
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -58,12 +58,12 @@ const ElementCheckbox = ({
onChange: (value: string[]) => void;
};
}) => {
const handleCheckedChange = (checked: boolean) => {
if (checked) {
field.onChange([...(field.value || []), element.id]);
} else {
field.onChange(field.value?.filter((value) => value !== element.id) || []);
}
const addElement = () => {
field.onChange([...(field.value || []), element.id]);
};
const removeElement = () => {
field.onChange(field.value?.filter((value) => value !== element.id) || []);
};
return (
@@ -75,7 +75,7 @@ const ElementCheckbox = ({
value={element.id}
className="bg-white"
checked={field.value?.includes(element.id)}
onCheckedChange={handleCheckedChange}
onCheckedChange={(checked) => (checked ? addElement() : removeElement())}
/>
<span className="ml-2">
{getTextContent(recallToHeadline(element.headline, selectedSurvey, false, "default")["default"])}

View File

@@ -5,8 +5,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import airtableLogo from "@/images/airtableLogo.svg";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";

View File

@@ -8,8 +8,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AddIntegrationModal";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";

View File

@@ -1,8 +1,8 @@
import { redirect } from "next/navigation";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
@@ -42,7 +42,7 @@ const Page = async (props) => {
return (
<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")} />
<div className="h-[75vh] w-full">
<AirtableWrapper

View File

@@ -12,13 +12,13 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import {
constructGoogleSheetsUrl,
extractSpreadsheetIdFromUrl,
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 { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -266,7 +266,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-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">
{surveyElements.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -8,8 +8,8 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { AddIntegrationModal } from "./AddIntegrationModal";

View File

@@ -9,7 +9,7 @@ import {
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet";
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 { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6">
<GoBackButton />
<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")}
</Button>
</div>
@@ -51,7 +51,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></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>
<div className="text-center"></div>

View File

@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
@@ -40,7 +40,7 @@ const Page = async (props) => {
return (
<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")} />
<div className="h-[75vh] w-full">
<GoogleSheetWrapper

View File

@@ -1,8 +1,8 @@
"use client";
import { PlusIcon, TrashIcon } from "lucide-react";
import { createId } from "@paralleldrive/cuid2";
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 toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -15,17 +15,15 @@ import {
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
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 {
ERRORS,
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
MappingRow,
TMapping,
createEmptyMapping,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
import NotionLogo from "@/images/notion.png";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getElementTypes } from "@/modules/survey/lib/elements";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -39,59 +37,6 @@ import {
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
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 {
environmentId: string;
surveys: TSurvey[];
@@ -115,21 +60,7 @@ export const AddIntegrationModal = ({
const { handleSubmit } = useForm();
const [selectedDatabase, setSelectedDatabase] = useState<TIntegrationNotionDatabase | null>();
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [mapping, setMapping] = useState<
{
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 [mapping, setMapping] = useState<TMapping[]>([createEmptyMapping()]);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isLinkingDatabase, setIsLinkingDatabase] = useState(false);
const integrationData = {
@@ -234,7 +165,7 @@ export const AddIntegrationModal = ({
return survey.id === selectedIntegration.surveyId;
})!
);
setMapping(selectedIntegration.mapping);
setMapping(selectedIntegration.mapping.map((m) => ({ ...m, id: createId() })));
return;
}
resetForm();
@@ -320,154 +251,11 @@ export const AddIntegrationModal = ({
setSelectedDatabase(null);
setSelectedSurvey(null);
};
const getFilteredElementItems = (selectedIdx) => {
const selectedElementIds = 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>
const getFilteredElementItems = (selectedIdx: number) => {
const selectedElementIds = new Set(
mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id)
);
return elementItems.filter((el) => !selectedElementIds.has(el.id));
};
return (
@@ -539,8 +327,17 @@ export const AddIntegrationModal = ({
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label>
<div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} />
{mapping.map((m, idx) => (
<MappingRow
key={m.id}
idx={idx}
mapping={mapping}
setMapping={setMapping}
filteredElementItems={getFilteredElementItems(idx)}
dbItems={dbItems}
elementItems={elementItems}
t={t}
/>
))}
</div>
</div>

View File

@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
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 { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";

View File

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

View File

@@ -9,8 +9,8 @@ import {
} from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/ManageIntegration";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/ManageIntegration";
import notionLogo from "@/images/notion.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { authorize } from "../lib/notion";

View File

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

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6">
<GoBackButton />
<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")}
</Button>
</div>
@@ -48,7 +48,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></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>
<div className="text-center"></div>

View File

@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/NotionWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
import {
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,

View File

@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
import Image from "next/image";
import { redirect } from "next/navigation";
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 AirtableLogo from "@/images/airtableLogo.svg";
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
@@ -79,7 +79,7 @@ const Page = async (props) => {
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/project/integrations/webhooks`,
connectHref: `/environments/${params.environmentId}/workspace/integrations/webhooks`,
connectText: t("environments.integrations.manage_webhooks"),
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
@@ -93,7 +93,7 @@ const Page = async (props) => {
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")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
@@ -107,7 +107,7 @@ const Page = async (props) => {
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/project/integrations/airtable`,
connectHref: `/environments/${params.environmentId}/workspace/integrations/airtable`,
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
@@ -121,7 +121,7 @@ const Page = async (props) => {
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/project/integrations/slack`,
connectHref: `/environments/${params.environmentId}/workspace/integrations/slack`,
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
@@ -163,7 +163,7 @@ const Page = async (props) => {
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/project/integrations/notion`,
connectHref: `/environments/${params.environmentId}/workspace/integrations/notion`,
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
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",
docsText: t("common.docs"),
docsNewTab: true,
connectHref: `/environments/${params.environmentId}/project/app-connection`,
connectHref: `/environments/${params.environmentId}/workspace/app-connection`,
connectText: t("common.connect"),
connectNewTab: false,
label: "Javascript SDK",
@@ -209,7 +209,7 @@ const Page = async (props) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>
<PageHeader pageTitle={t("common.workspace_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
</PageHeader>
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">

View File

@@ -15,7 +15,7 @@ import {
} from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
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 SlackLogo from "@/images/slacklogo.png";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";

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