Compare commits

..

59 Commits

Author SHA1 Message Date
pandeymangg
1043a6f034 chore: merge with main 2026-02-03 22:14:41 +05:30
pandeymangg
f7a28f8cb3 feat: connectors 2026-02-03 22:12:37 +05:30
Dhruwang
ac8cf7c6b4 dashboard changes 2026-02-03 18:48:34 +05:30
Dhruwang
39e7de7e91 Merge branch 'dashboards' of https://github.com/formbricks/formbricks into feat/unify 2026-02-03 18:44:23 +05:30
Dhruwang
36955ddbb8 implement ui feedback 2026-02-02 10:15:22 +05:30
Dhruwang
ef56e97c95 clean ups 2026-01-29 10:46:32 +04:00
Dhruwang
ea3b4b9413 rfactors 2026-01-28 17:34:03 +04:00
Dhruwang
131a04b77c rfactors 2026-01-28 17:33:33 +04:00
TheodorTomas
ca7e2c64de feat: init commit for dashboard 2026-01-28 14:45:25 +04:00
Dhruwang
e4bd9a839a dashboard apis 2026-01-28 12:01:44 +04:00
Johannes
590c85d1ca add sources UI 2026-01-28 08:54:52 +04:00
Harsh Bhat
39c99baaac feat: Add mock data and UI for taxanomy & knowledge 2026-01-27 16:12:19 +04:00
Harsh Bhat
238b2adf3f feat: Unify POC hackathon 2026-01-27 14:58:20 +04: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
402 changed files with 29143 additions and 7101 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

@@ -225,4 +225,14 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
LINGODOTDEV_API_KEY=your_api_key_here
# ##############################################
# Formbricks Hub API Configuration (for Unify connectors)
# ##############################################
# The base URL of the Formbricks Hub API
# HUB_API_URL=http://localhost:8080
# API key for authenticating with the Hub API
# HUB_API_KEY=

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

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

@@ -0,0 +1,520 @@
"use server";
import { ChartType } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { executeQuery } from "@/app/api/analytics/_lib/cube-client";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZCreateChartAction = z.object({
environmentId: ZId,
name: z.string().min(1),
type: z.enum(["area", "bar", "line", "pie", "big_number", "big_number_total", "table", "funnel", "map"]),
query: z.record(z.any()),
config: z.record(z.any()).optional().default({}),
});
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZCreateChartAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const chart = await prisma.chart.create({
data: {
name: parsedInput.name,
type: parsedInput.type as ChartType,
projectId,
query: parsedInput.query,
config: parsedInput.config || {},
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = chart;
return chart;
}
)
);
const ZUpdateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
name: z.string().min(1).optional(),
type: z
.enum(["area", "bar", "line", "pie", "big_number", "big_number_total", "table", "funnel", "map"])
.optional(),
query: z.record(z.any()).optional(),
config: z.record(z.any()).optional(),
});
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
withAuditLogging(
"updated",
"chart",
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZUpdateChartAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// Verify chart belongs to the project
const chart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!chart) {
throw new Error("Chart not found");
}
const updateData: any = {};
if (parsedInput.name !== undefined) updateData.name = parsedInput.name;
if (parsedInput.type !== undefined) updateData.type = parsedInput.type as ChartType;
if (parsedInput.query !== undefined) updateData.query = parsedInput.query;
if (parsedInput.config !== undefined) updateData.config = parsedInput.config;
const updatedChart = await prisma.chart.update({
where: { id: parsedInput.chartId },
data: updateData,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = chart;
ctx.auditLoggingCtx.newObject = updatedChart;
return updatedChart;
}
)
);
const ZAddChartToDashboardAction = z.object({
environmentId: ZId,
chartId: ZId,
dashboardId: ZId,
title: z.string().optional(),
layout: z
.object({
x: z.number(),
y: z.number(),
w: z.number(),
h: z.number(),
})
.optional()
.default({ x: 0, y: 0, w: 4, h: 3 }),
});
export const addChartToDashboardAction = authenticatedActionClient.schema(ZAddChartToDashboardAction).action(
withAuditLogging(
"created",
"dashboardWidget",
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZAddChartToDashboardAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// Verify chart and dashboard belong to the same project
const [chart, dashboard] = await Promise.all([
prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
}),
prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
}),
]);
if (!chart) {
throw new Error("Chart not found");
}
if (!dashboard) {
throw new Error("Dashboard not found");
}
// Get the max order for widgets in this dashboard
const maxOrder = await prisma.dashboardWidget.aggregate({
where: { dashboardId: parsedInput.dashboardId },
_max: { order: true },
});
const widget = await prisma.dashboardWidget.create({
data: {
dashboardId: parsedInput.dashboardId,
chartId: parsedInput.chartId,
type: "chart",
title: parsedInput.title,
layout: parsedInput.layout,
order: (maxOrder._max.order ?? -1) + 1,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = widget;
return widget;
}
)
);
const ZCreateDashboardAction = z.object({
environmentId: ZId,
name: z.string().min(1),
description: z.string().optional(),
});
export const createDashboardAction = authenticatedActionClient.schema(ZCreateDashboardAction).action(
withAuditLogging(
"created",
"dashboard",
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZCreateDashboardAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const dashboard = await prisma.dashboard.create({
data: {
name: parsedInput.name,
description: parsedInput.description,
projectId,
status: "draft",
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = dashboard;
return dashboard;
}
)
);
const ZUpdateDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
status: z.enum(["draft", "published"]).optional(),
});
export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDashboardAction).action(
withAuditLogging(
"updated",
"dashboard",
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZUpdateDashboardAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// Verify dashboard belongs to the project
const dashboard = await prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
});
if (!dashboard) {
throw new Error("Dashboard not found");
}
const updateData: any = {};
if (parsedInput.name !== undefined) updateData.name = parsedInput.name;
if (parsedInput.description !== undefined) updateData.description = parsedInput.description;
if (parsedInput.status !== undefined) updateData.status = parsedInput.status;
const updatedDashboard = await prisma.dashboard.update({
where: { id: parsedInput.dashboardId },
data: updateData,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = dashboard;
ctx.auditLoggingCtx.newObject = updatedDashboard;
return updatedDashboard;
}
)
);
const ZGetDashboardsAction = z.object({
environmentId: ZId,
});
export const getDashboardsAction = authenticatedActionClient
.schema(ZGetDashboardsAction)
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZGetDashboardsAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId,
},
],
});
const dashboards = await prisma.dashboard.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
description: true,
status: true,
createdAt: true,
updatedAt: true,
},
});
return dashboards;
});
const ZGetChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const getChartAction = authenticatedActionClient
.schema(ZGetChartAction)
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZGetChartAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId,
},
],
});
const chart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
select: {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
},
});
if (!chart) {
throw new Error("Chart not found");
}
return chart;
});
const ZGetChartsAction = z.object({
environmentId: ZId,
});
export const getChartsAction = authenticatedActionClient
.schema(ZGetChartsAction)
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZGetChartsAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId,
},
],
});
const charts = await prisma.chart.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
type: true,
createdAt: true,
updatedAt: true,
query: true,
config: true,
widgets: {
select: {
dashboardId: true,
},
},
},
});
return charts;
});
const ZExecuteQueryAction = z.object({
environmentId: ZId,
query: z.object({
measures: z.array(z.string()),
dimensions: z.array(z.string()).optional(),
timeDimensions: z
.array(
z.object({
dimension: z.string(),
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).optional(),
dateRange: z.union([z.string(), z.array(z.string())]).optional(),
})
)
.optional(),
filters: z
.array(
z.object({
member: z.string(),
operator: z.enum([
"equals",
"notEquals",
"contains",
"notContains",
"set",
"notSet",
"gt",
"gte",
"lt",
"lte",
]),
values: z.array(z.string()).optional().nullable(),
})
)
.optional(),
}),
});
export const executeQueryAction = authenticatedActionClient
.schema(ZExecuteQueryAction)
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZExecuteQueryAction> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId,
},
],
});
try {
const data = await executeQuery(parsedInput.query);
return { data };
} catch (error: any) {
return { error: error.message || "Failed to execute query" };
}
});

View File

@@ -0,0 +1,92 @@
"use client";
import { ActivityIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface AIQuerySectionProps {
onChartGenerated: (data: AnalyticsResponse) => void;
}
export function AIQuerySection({ onChartGenerated }: AIQuerySectionProps) {
const [userQuery, setUserQuery] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const handleGenerate = async () => {
if (!userQuery.trim()) return;
setIsGenerating(true);
try {
const response = await fetch("/api/analytics/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: userQuery }),
});
const data = await response.json();
if (data.error) {
toast.error(data.error);
return;
}
if (data.data) {
onChartGenerated(data);
} else {
toast.error("No data returned from query");
}
} catch (error: any) {
toast.error(error.message || "Failed to generate chart");
} finally {
setIsGenerating(false);
}
};
return (
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center gap-2">
<div className="bg-brand-dark/10 flex h-8 w-8 items-center justify-center rounded-full">
<ActivityIcon className="text-brand-dark h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-gray-900">Ask your data</h2>
<p className="text-sm text-gray-500">Describe what you want to see and let AI build the chart.</p>
</div>
</div>
<div className="flex gap-4">
<Input
placeholder="e.g. How many users signed up last week?"
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && userQuery.trim() && !isGenerating) {
handleGenerate();
}
}}
className="flex-1"
disabled={isGenerating}
/>
<Button
disabled={!userQuery.trim() || isGenerating}
loading={isGenerating}
className="bg-brand-dark hover:bg-brand-dark/90"
onClick={handleGenerate}>
Generate
</Button>
</div>
{isGenerating && (
<div className="flex items-center justify-center py-12">
<LoadingSpinner className="h-8 w-8" />
<span className="ml-3 text-sm text-gray-500">Generating chart...</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface Dashboard {
id: string;
name: string;
}
interface AddToDashboardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
chartName: string;
onChartNameChange: (name: string) => void;
dashboards: Dashboard[];
selectedDashboardId: string;
onDashboardSelect: (id: string) => void;
onAdd: () => void;
isSaving: boolean;
}
export function AddToDashboardDialog({
open,
onOpenChange,
chartName,
onChartNameChange,
dashboards,
selectedDashboardId,
onDashboardSelect,
onAdd,
isSaving,
}: AddToDashboardDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Chart to Dashboard</DialogTitle>
<DialogDescription>
Select a dashboard to add this chart to. The chart will be saved automatically.
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="space-y-4">
<div>
<label htmlFor="chart-name" className="mb-2 block text-sm font-medium text-gray-700">
Chart Name
</label>
<Input
id="chart-name"
placeholder="Chart name"
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
/>
</div>
<div>
<label htmlFor="dashboard-select" className="mb-2 block text-sm font-medium text-gray-700">
Dashboard
</label>
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
<SelectTrigger id="dashboard-select" className="w-full bg-white">
<SelectValue
placeholder={dashboards.length === 0 ? "No dashboards available" : "Select a dashboard"}
/>
</SelectTrigger>
<SelectContent position="popper" className="z-[100] max-h-[200px]">
{dashboards.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-gray-500">No dashboards available</div>
) : (
dashboards.map((dashboard) => (
<SelectItem key={dashboard.id} value={dashboard.id}>
{dashboard.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{dashboards.length === 0 && (
<p className="mt-1 text-xs text-gray-500">Create a dashboard first to add charts to it.</p>
)}
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button onClick={onAdd} loading={isSaving} disabled={!selectedDashboardId}>
Add to Dashboard
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,617 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CodeIcon, DatabaseIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useEffect, useReducer, useState } from "react";
import { toast } from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { Button } from "@/modules/ui/components/button";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import {
addChartToDashboardAction,
createChartAction,
executeQueryAction,
getDashboardsAction,
} from "../../actions";
import { CHART_TYPES } from "../lib/chart-types";
// Filter out table, map, and scatter charts
const AVAILABLE_CHART_TYPES = CHART_TYPES.filter(
(type) => !["table", "map", "scatter"].includes(type.id)
);
import { mapChartType } from "../lib/chart-utils";
import {
ChartBuilderState,
CustomMeasure,
FilterRow,
TimeDimensionConfig,
buildCubeQuery,
parseQueryToState,
} from "../lib/query-builder";
import { AddToDashboardDialog } from "./AddToDashboardDialog";
import { ChartRenderer } from "./ChartRenderer";
import { DimensionsPanel } from "./DimensionsPanel";
import { FiltersPanel } from "./FiltersPanel";
import { MeasuresPanel } from "./MeasuresPanel";
import { SaveChartDialog } from "./SaveChartDialog";
import { TimeDimensionPanel } from "./TimeDimensionPanel";
interface AdvancedChartBuilderProps {
environmentId: string;
initialChartType?: string;
initialQuery?: any; // Prefill with AI-generated query
hidePreview?: boolean; // Hide internal preview when using unified preview
onChartGenerated?: (data: AnalyticsResponse) => void;
onSave?: (chartId: string) => void;
onAddToDashboard?: (chartId: string, dashboardId: string) => void;
}
type Action =
| { type: "SET_CHART_TYPE"; payload: string }
| { type: "ADD_MEASURE"; payload: string }
| { type: "REMOVE_MEASURE"; payload: string }
| { type: "SET_MEASURES"; payload: string[] }
| { type: "ADD_CUSTOM_MEASURE"; payload: CustomMeasure }
| { type: "UPDATE_CUSTOM_MEASURE"; payload: { index: number; measure: CustomMeasure } }
| { type: "REMOVE_CUSTOM_MEASURE"; payload: number }
| { type: "SET_CUSTOM_MEASURES"; payload: CustomMeasure[] }
| { type: "SET_DIMENSIONS"; payload: string[] }
| { type: "ADD_FILTER"; payload: FilterRow }
| { type: "UPDATE_FILTER"; payload: { index: number; filter: FilterRow } }
| { type: "REMOVE_FILTER"; payload: number }
| { type: "SET_FILTERS"; payload: FilterRow[] }
| { type: "SET_FILTER_LOGIC"; payload: "and" | "or" }
| { type: "SET_TIME_DIMENSION"; payload: TimeDimensionConfig | null };
const initialState: ChartBuilderState = {
chartType: "",
selectedMeasures: [],
customMeasures: [],
selectedDimensions: [],
filters: [],
filterLogic: "and",
timeDimension: null,
};
function chartBuilderReducer(state: ChartBuilderState, action: Action): ChartBuilderState {
switch (action.type) {
case "SET_CHART_TYPE":
return { ...state, chartType: action.payload };
case "SET_MEASURES":
return { ...state, selectedMeasures: action.payload };
case "SET_CUSTOM_MEASURES":
return { ...state, customMeasures: action.payload };
case "SET_DIMENSIONS":
return { ...state, selectedDimensions: action.payload };
case "SET_FILTERS":
return { ...state, filters: action.payload };
case "SET_FILTER_LOGIC":
return { ...state, filterLogic: action.payload };
case "SET_TIME_DIMENSION":
return { ...state, timeDimension: action.payload };
default:
return state;
}
}
export function AdvancedChartBuilder({
environmentId,
initialChartType,
initialQuery,
hidePreview = false,
onChartGenerated,
onSave,
onAddToDashboard,
}: AdvancedChartBuilderProps) {
const router = useRouter();
// Initialize state from initialQuery if provided
const getInitialState = (): ChartBuilderState => {
if (initialQuery) {
const parsedState = parseQueryToState(initialQuery, initialChartType);
return {
...initialState,
...parsedState,
chartType: parsedState.chartType || initialChartType || "",
};
}
return {
...initialState,
chartType: initialChartType || "",
};
};
const [state, dispatch] = useReducer(chartBuilderReducer, getInitialState());
const [chartData, setChartData] = useState<Record<string, any>[] | null>(null);
const [query, setQuery] = useState<any>(initialQuery || null);
const [isInitialized, setIsInitialized] = useState(false);
const lastStateRef = React.useRef<string>("");
// Sync initialChartType prop changes to state
useEffect(() => {
if (initialChartType && initialChartType !== state.chartType) {
dispatch({ type: "SET_CHART_TYPE", payload: initialChartType });
// If there's no initialQuery, mark as initialized so reactive updates can work
if (!initialQuery && !isInitialized) {
setIsInitialized(true);
}
}
}, [initialChartType, state.chartType, initialQuery, isInitialized]);
// Initialize: If initialQuery is provided (from AI), execute it and set chart data
useEffect(() => {
if (initialQuery && !isInitialized) {
setIsInitialized(true);
executeQueryAction({
environmentId,
query: initialQuery,
}).then((result) => {
if (result?.data?.data) {
const data = Array.isArray(result.data.data) ? result.data.data : [];
setChartData(data);
setQuery(initialQuery);
// Set initial state hash to prevent reactive update on initial load
lastStateRef.current = JSON.stringify({
chartType: state.chartType,
measures: state.selectedMeasures,
dimensions: state.selectedDimensions,
filters: state.filters,
timeDimension: state.timeDimension,
});
// Call onChartGenerated if provided
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: initialQuery,
chartType: state.chartType as any,
data,
};
onChartGenerated(analyticsResponse);
}
}
});
}
}, [initialQuery, environmentId, isInitialized, state.chartType, state.selectedMeasures, state.selectedDimensions, state.filters, state.timeDimension, onChartGenerated]);
// Update preview reactively when state changes (after initialization)
useEffect(() => {
// Skip if not initialized or no chart type selected
if (!isInitialized || !state.chartType) return;
// Create a hash of relevant state to detect changes
const stateHash = JSON.stringify({
chartType: state.chartType,
measures: state.selectedMeasures,
dimensions: state.selectedDimensions,
filters: state.filters,
timeDimension: state.timeDimension,
});
// Only update if state actually changed
if (stateHash === lastStateRef.current) return;
lastStateRef.current = stateHash;
// If chart type changed but we have existing data, update the chart type in preview immediately
// This handles the case where user changes chart type from ManualChartBuilder
if (chartData && Array.isArray(chartData) && chartData.length > 0 && query) {
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: query, // Keep existing query
chartType: state.chartType as any, // Update chart type
data: chartData, // Keep existing data
};
onChartGenerated(analyticsResponse);
}
}
// Only execute query if we have measures configured
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) {
return; // Don't execute query without measures
}
// Build and execute query with current state
const updatedQuery = buildCubeQuery(state);
setIsLoading(true);
setError(null);
executeQueryAction({
environmentId,
query: updatedQuery,
})
.then((result) => {
if (result?.data?.data) {
const data = Array.isArray(result.data.data) ? result.data.data : [];
setChartData(data);
setQuery(updatedQuery);
// Call onChartGenerated to update parent preview
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: updatedQuery,
chartType: state.chartType as any,
data,
};
onChartGenerated(analyticsResponse);
}
} else if (result?.serverError) {
setError(result.serverError);
}
})
.catch((err: any) => {
setError(err.message || "Failed to execute query");
})
.finally(() => {
setIsLoading(false);
});
}, [state.chartType, state.selectedMeasures, state.selectedDimensions, state.filters, state.timeDimension, isInitialized, environmentId, onChartGenerated, chartData, query]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
const [showQuery, setShowQuery] = useState(false);
const [showData, setShowData] = useState(false);
useEffect(() => {
if (isAddToDashboardDialogOpen) {
getDashboardsAction({ environmentId }).then((result) => {
if (result?.data) {
setDashboards(result.data);
} else if (result?.serverError) {
toast.error(result.serverError);
}
});
}
}, [isAddToDashboardDialogOpen, environmentId]);
const handleRunQuery = async () => {
if (!state.chartType) {
toast.error("Please select a chart type");
return;
}
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) {
toast.error("Please select at least one measure");
return;
}
setIsLoading(true);
setError(null);
try {
const cubeQuery = buildCubeQuery(state);
setQuery(cubeQuery);
const result = await executeQueryAction({
environmentId,
query: cubeQuery,
});
if (result?.serverError) {
setError(result.serverError);
toast.error(result.serverError);
setChartData(null);
} else if (result?.data?.data) {
// Ensure data is always an array - result.data.data contains the actual array
const data = Array.isArray(result.data.data) ? result.data.data : [];
setChartData(data);
setError(null);
toast.success("Query executed successfully");
// Call onChartGenerated callback if provided
if (onChartGenerated) {
const analyticsResponse: AnalyticsResponse = {
query: cubeQuery,
chartType: state.chartType as any,
data,
};
onChartGenerated(analyticsResponse);
}
} else {
throw new Error("No data returned");
}
} catch (err: any) {
const errorMessage = err.message || "Failed to execute query";
setError(errorMessage);
toast.error(errorMessage);
setChartData(null);
} finally {
setIsLoading(false);
}
};
const handleSaveChart = async () => {
if (!chartData || !chartName.trim()) {
toast.error("Please enter a chart name");
return;
}
if (!query) {
toast.error("Please run a query first");
return;
}
setIsSaving(true);
try {
const result = await createChartAction({
environmentId,
name: chartName,
type: mapChartType(state.chartType),
query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to save chart");
return;
}
toast.success("Chart saved successfully!");
setIsSaveDialogOpen(false);
if (onSave) {
onSave(result.data.id);
} else {
router.push(`/environments/${environmentId}/analysis/charts`);
}
} catch (error: any) {
toast.error(error.message || "Failed to save chart");
} finally {
setIsSaving(false);
}
};
const handleAddToDashboard = async () => {
if (!chartData || !selectedDashboardId) {
toast.error("Please select a dashboard");
return;
}
if (!query) {
toast.error("Please run a query first");
return;
}
setIsSaving(true);
console.log(query);
try {
const chartResult = await createChartAction({
environmentId,
name: chartName || `Chart ${new Date().toLocaleString()}`,
type: mapChartType(state.chartType),
query,
config: {},
});
if (!chartResult?.data) {
toast.error(chartResult?.serverError || "Failed to save chart");
return;
}
const widgetResult = await addChartToDashboardAction({
environmentId,
chartId: chartResult.data.id,
dashboardId: selectedDashboardId,
});
if (!widgetResult?.data) {
toast.error(widgetResult?.serverError || "Failed to add chart to dashboard");
return;
}
toast.success("Chart added to dashboard!");
setIsAddToDashboardDialogOpen(false);
if (onAddToDashboard) {
onAddToDashboard(chartResult.data.id, selectedDashboardId);
} else {
router.push(`/environments/${environmentId}/analysis/dashboard/${selectedDashboardId}`);
}
} catch (error: any) {
toast.error(error.message || "Failed to add chart to dashboard");
} finally {
setIsSaving(false);
}
};
return (
<div className={hidePreview ? "space-y-4" : "grid gap-4 lg:grid-cols-2"}>
{/* Left Column: Configuration */}
<div className="space-y-4">
{/* Chart Type Selection - Hidden when hidePreview is true (unified flow) */}
{!hidePreview && (
<div className="space-y-4">
<h2 className="font-medium text-gray-900">Choose chart type</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{AVAILABLE_CHART_TYPES.map((chart) => {
const isSelected = state.chartType === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => dispatch({ type: "SET_CHART_TYPE", payload: chart.id })}
className={`rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 ${isSelected
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
: "border-gray-200 hover:border-gray-300"
}`}>
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-xs font-medium text-gray-700">{chart.name}</span>
</button>
);
})}
</div>
</div>
)}
{/* Measures Panel */}
<MeasuresPanel
selectedMeasures={state.selectedMeasures}
customMeasures={state.customMeasures}
onMeasuresChange={(measures) => dispatch({ type: "SET_MEASURES", payload: measures })}
onCustomMeasuresChange={(measures) => dispatch({ type: "SET_CUSTOM_MEASURES", payload: measures })}
/>
{/* Dimensions Panel */}
<DimensionsPanel
selectedDimensions={state.selectedDimensions}
onDimensionsChange={(dimensions) => dispatch({ type: "SET_DIMENSIONS", payload: dimensions })}
/>
{/* Time Dimension Panel */}
<TimeDimensionPanel
timeDimension={state.timeDimension}
onTimeDimensionChange={(config) => dispatch({ type: "SET_TIME_DIMENSION", payload: config })}
/>
{/* Filters Panel */}
<FiltersPanel
filters={state.filters}
filterLogic={state.filterLogic}
onFiltersChange={(filters) => dispatch({ type: "SET_FILTERS", payload: filters })}
onFilterLogicChange={(logic) => dispatch({ type: "SET_FILTER_LOGIC", payload: logic })}
/>
{/* Action Buttons */}
<div className="flex gap-2">
<Button onClick={handleRunQuery} disabled={isLoading || !state.chartType}>
{isLoading ? <LoadingSpinner /> : "Run Query"}
</Button>
{chartData && !onSave && !onAddToDashboard && (
<>
<Button variant="outline" onClick={() => setIsSaveDialogOpen(true)}>
Save Chart
</Button>
<Button variant="outline" onClick={() => setIsAddToDashboardDialogOpen(true)}>
Add to Dashboard
</Button>
</>
)}
</div>
</div>
{/* Right Column: Preview - Hidden when hidePreview is true */}
{!hidePreview && (
<div className="space-y-4">
<h3 className="font-medium text-gray-900">Chart Preview</h3>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">{error}</div>
)}
{isLoading && (
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
)}
{chartData && Array.isArray(chartData) && chartData.length > 0 && !isLoading && (
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<ChartRenderer chartType={state.chartType} data={chartData} />
</div>
{/* Query Viewer */}
<Collapsible.Root open={showQuery} onOpenChange={setShowQuery}>
<Collapsible.CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<CodeIcon className="mr-2 h-4 w-4" />
{showQuery ? "Hide" : "View"} Query
</Button>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="mt-2">
<pre className="max-h-64 overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-4 text-xs">
{JSON.stringify(query, null, 2)}
</pre>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
{/* Data Viewer */}
<Collapsible.Root open={showData} onOpenChange={setShowData}>
<Collapsible.CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<DatabaseIcon className="mr-2 h-4 w-4" />
{showData ? "Hide" : "View"} Data
</Button>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="mt-2">
<div className="max-h-64 overflow-auto rounded-lg border border-gray-200">
<table className="w-full text-xs">
<thead className="bg-gray-50">
<tr>
{Array.isArray(chartData) &&
chartData.length > 0 &&
Object.keys(chartData[0]).map((key) => (
<th
key={key}
className="border-b border-gray-200 px-3 py-2 text-left font-medium">
{key}
</th>
))}
</tr>
</thead>
<tbody>
{Array.isArray(chartData) &&
chartData.slice(0, 10).map((row, idx) => {
// Create a unique key from row data
const rowKey = Object.values(row)
.slice(0, 3)
.map((v) => String(v || ""))
.join("-");
return (
<tr key={`row-${idx}-${rowKey}`} className="border-b border-gray-100">
{Object.entries(row).map(([key, value]) => (
<td key={`${rowKey}-${key}`} className="px-3 py-2">
{value?.toString() || "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
{Array.isArray(chartData) && chartData.length > 10 && (
<div className="bg-gray-50 px-3 py-2 text-xs text-gray-500">
Showing first 10 of {chartData.length} rows
</div>
)}
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
)}
{!chartData && !isLoading && !error && (
<div className="flex h-64 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-500">
Configure your chart and click "Run Query" to preview
</div>
)}
</div>
)}
{/* Dialogs - Only render when callbacks are not provided (standalone mode) */}
{!onSave && (
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
)}
{!onAddToDashboard && (
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,353 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import {
addChartToDashboardAction,
createChartAction,
executeQueryAction,
getChartAction,
getDashboardsAction,
updateChartAction,
} from "../../actions";
import { mapChartType, mapDatabaseChartTypeToApi } from "../lib/chart-utils";
import { AIQuerySection } from "./AIQuerySection";
import { AddToDashboardDialog } from "./AddToDashboardDialog";
import { AdvancedChartBuilder } from "./AdvancedChartBuilder";
import { ChartPreview } from "./ChartPreview";
import { ConfigureChartDialog } from "./ConfigureChartDialog";
import { ManualChartBuilder } from "./ManualChartBuilder";
import { SaveChartDialog } from "./SaveChartDialog";
interface ChartBuilderClientProps {
environmentId: string;
chartId?: string;
}
export function ChartBuilderClient({ environmentId, chartId }: ChartBuilderClientProps) {
const router = useRouter();
const [selectedChartType, setSelectedChartType] = useState<string>("");
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [isConfigureDialogOpen, setIsConfigureDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
const [showData, setShowData] = useState(false);
const [configuredChartType, setConfiguredChartType] = useState<string | null>(null);
const [showAdvancedBuilder, setShowAdvancedBuilder] = useState(false);
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
useEffect(() => {
if (isAddToDashboardDialogOpen) {
getDashboardsAction({ environmentId }).then((result) => {
if (result?.data) {
setDashboards(result.data);
} else if (result?.serverError) {
toast.error(result.serverError);
}
});
}
}, [isAddToDashboardDialogOpen, environmentId]);
useEffect(() => {
if (chartId) {
setIsLoadingChart(true);
getChartAction({ environmentId, chartId })
.then(async (result) => {
if (result?.data) {
const chart = result.data;
setChartName(chart.name);
// Execute the chart's query to get the data
const queryResult = await executeQueryAction({
environmentId,
query: chart.query as any,
});
if (queryResult?.error || queryResult?.serverError) {
toast.error(queryResult.error || queryResult.serverError || "Failed to load chart data");
setIsLoadingChart(false);
return;
}
if (queryResult?.data?.data) {
// Format as AnalyticsResponse
const chartData: AnalyticsResponse = {
query: chart.query as any,
chartType: mapDatabaseChartTypeToApi(chart.type),
data: Array.isArray(queryResult.data.data) ? queryResult.data.data : [],
};
setChartData(chartData);
setConfiguredChartType(mapDatabaseChartTypeToApi(chart.type));
setCurrentChartId(chart.id);
} else {
toast.error("No data returned for chart");
}
} else if (result?.serverError) {
toast.error(result.serverError);
}
setIsLoadingChart(false);
})
.catch((error: any) => {
toast.error(error.message || "Failed to load chart");
setIsLoadingChart(false);
});
}
}, [chartId, environmentId]);
const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
};
const handleSaveChart = async () => {
if (!chartData || !chartName.trim()) {
toast.error("Please enter a chart name");
return;
}
setIsSaving(true);
try {
// If we have a currentChartId, update the existing chart; otherwise create a new one
if (currentChartId) {
const result = await updateChartAction({
environmentId,
chartId: currentChartId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to update chart");
return;
}
toast.success("Chart updated successfully!");
setIsSaveDialogOpen(false);
router.push(`/environments/${environmentId}/analysis/charts`);
} else {
const result = await createChartAction({
environmentId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to save chart");
return;
}
setCurrentChartId(result.data.id);
toast.success("Chart saved successfully!");
setIsSaveDialogOpen(false);
router.push(`/environments/${environmentId}/analysis/charts`);
}
} catch (error: any) {
toast.error(error.message || "Failed to save chart");
} finally {
setIsSaving(false);
}
};
const handleAddToDashboard = async () => {
if (!chartData || !selectedDashboardId) {
toast.error("Please select a dashboard");
return;
}
setIsSaving(true);
try {
let chartIdToUse = currentChartId;
// If we don't have a chartId (creating new chart), create it first
if (!chartIdToUse) {
if (!chartName.trim()) {
toast.error("Please enter a chart name");
setIsSaving(false);
return;
}
const chartResult = await createChartAction({
environmentId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!chartResult?.data) {
toast.error(chartResult?.serverError || "Failed to save chart");
setIsSaving(false);
return;
}
chartIdToUse = chartResult.data.id;
setCurrentChartId(chartIdToUse);
}
// Add the chart (existing or newly created) to the dashboard
const widgetResult = await addChartToDashboardAction({
environmentId,
chartId: chartIdToUse,
dashboardId: selectedDashboardId,
});
if (!widgetResult?.data) {
toast.error(widgetResult?.serverError || "Failed to add chart to dashboard");
return;
}
toast.success("Chart added to dashboard!");
setIsAddToDashboardDialogOpen(false);
router.push(`/environments/${environmentId}/analysis/dashboard/${selectedDashboardId}`);
} catch (error: any) {
toast.error(error.message || "Failed to add chart to dashboard");
} finally {
setIsSaving(false);
}
};
const handleManualCreate = () => {
if (!selectedChartType) {
toast.error("Please select a chart type first");
return;
}
setShowAdvancedBuilder(true);
};
// If loading an existing chart, show loading state
if (chartId && isLoadingChart) {
return (
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
);
}
// If viewing an existing chart, show only the chart preview
if (chartId && chartData) {
return (
<div className="grid gap-8">
<ChartPreview
chartData={chartData}
configuredChartType={configuredChartType}
showData={showData}
onToggleData={() => setShowData(!showData)}
onConfigure={() => setIsConfigureDialogOpen(true)}
/>
{/* Dialogs */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
<ConfigureChartDialog
open={isConfigureDialogOpen}
onOpenChange={setIsConfigureDialogOpen}
currentChartType={chartData?.chartType || "bar"}
configuredChartType={configuredChartType}
onChartTypeSelect={setConfiguredChartType}
onReset={() => setConfiguredChartType(null)}
/>
</div>
);
}
return (
<div className="grid gap-8">
{/* Option 1: Ask AI */}
<AIQuerySection onChartGenerated={handleChartGenerated} />
{/* Chart Preview */}
{chartData && (
<ChartPreview
chartData={chartData}
configuredChartType={configuredChartType}
showData={showData}
onToggleData={() => setShowData(!showData)}
onConfigure={() => setIsConfigureDialogOpen(true)}
/>
)}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-gray-50 px-2 text-sm text-gray-500">OR</span>
</div>
</div>
{/* Option 2: Build Manually */}
{showAdvancedBuilder ? (
<AdvancedChartBuilder environmentId={environmentId} initialChartType={selectedChartType} />
) : (
<ManualChartBuilder
selectedChartType={selectedChartType}
onChartTypeSelect={setSelectedChartType}
onCreate={handleManualCreate}
/>
)}
{/* Dialogs */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
<ConfigureChartDialog
open={isConfigureDialogOpen}
onOpenChange={setIsConfigureDialogOpen}
currentChartType={chartData?.chartType || "bar"}
configuredChartType={configuredChartType}
onChartTypeSelect={setConfiguredChartType}
onReset={() => setConfiguredChartType(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import { BarChart, DatabaseIcon } from "lucide-react";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { ChartRenderer } from "./ChartRenderer";
import { DataViewer } from "./DataViewer";
interface ChartPreviewProps {
chartData: AnalyticsResponse;
}
export function ChartPreview({ chartData }: ChartPreviewProps) {
const [activeTab, setActiveTab] = useState<"chart" | "data">("chart");
return (
<div className="mt-6 space-y-4">
<div className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-semibold text-gray-900">Chart Preview</h3>
</div>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "chart" | "data")}>
<div className="flex justify-end mb-4">
<TabsList>
<TabsTrigger value="chart" icon={<BarChart className="h-4 w-4" />}>
Chart
</TabsTrigger>
<TabsTrigger value="data" icon={<DatabaseIcon className="h-4 w-4" />}>
Data
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chart" className="mt-0">
<ChartRenderer chartType={chartData.chartType} data={chartData.data || []} />
</TabsContent>
<TabsContent value="data" className="mt-0">
<DataViewer data={chartData.data || []} />
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -0,0 +1,229 @@
"use client";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
Pie,
PieChart,
XAxis,
YAxis,
} from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
// Formbricks brand colors
const BRAND_DARK = "#00C4B8";
const BRAND_LIGHT = "#00E6CA";
interface ChartRendererProps {
chartType: string;
data: Record<string, any>[];
}
export function ChartRenderer({ chartType, data }: ChartRendererProps) {
if (!data || data.length === 0) {
return <div className="flex h-64 items-center justify-center text-gray-500">No data available</div>;
}
// Get the first data point to determine keys
const firstRow = data[0];
const allKeys = Object.keys(firstRow);
const keys = allKeys.filter((key) => key !== "date" && key !== "time");
// For pie charts, we need to identify dimension (nameKey) and measure (dataKey)
let xAxisKey = "key";
let dataKey = "value";
if (chartType === "pie" || chartType === "donut") {
// Find first numeric key (measure)
const numericKey = keys.find((key) => {
const firstValue = firstRow[key];
if (firstValue === null || firstValue === undefined || firstValue === "") return false;
const numValue = Number(firstValue);
return !Number.isNaN(numValue) && Number.isFinite(numValue);
});
// Find first non-numeric key (dimension)
const nonNumericKey = keys.find((key) => {
if (key === numericKey) return false;
const firstValue = firstRow[key];
return firstValue !== undefined;
});
xAxisKey = nonNumericKey || (numericKey ? keys.find((k) => k !== numericKey) : null) || keys[0] || "key";
dataKey = numericKey || keys[1] || keys[0] || "value";
} else {
// For other chart types, use existing logic
if (firstRow.date) {
xAxisKey = "date";
} else if (firstRow.time) {
xAxisKey = "time";
} else if (keys[0]) {
xAxisKey = keys[0];
}
dataKey = keys.find((k) => k !== xAxisKey) || keys[0] || "value";
}
switch (chartType) {
case "bar":
return (
<div className="h-64 min-h-[256px] w-full">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full">
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey={dataKey} fill={BRAND_DARK} radius={4} />
</BarChart>
</ChartContainer>
</div>
);
case "line":
return (
<div className="h-64 min-h-[256px] w-full">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey={dataKey}
stroke={BRAND_DARK}
strokeWidth={3}
dot={{ fill: BRAND_DARK, r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ChartContainer>
</div>
);
case "area":
return (
<div className="h-64 min-h-[256px] w-full">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full">
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey={dataKey}
stroke={BRAND_DARK}
fill={BRAND_LIGHT}
fillOpacity={0.4}
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
</div>
);
case "pie":
case "donut": {
if (!dataKey || !xAxisKey) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">
Unable to determine chart data structure
</div>
);
}
// Filter out rows where the dataKey value is null, undefined, or empty
const validData = data.filter((row) => {
const value = row[dataKey];
if (value === null || value === undefined || value === "") return false;
const numValue = Number(value);
return !Number.isNaN(numValue) && Number.isFinite(numValue);
});
// Convert dataKey values to numbers for proper rendering
const processedData = validData.map((row) => ({
...row,
[dataKey]: Number(row[dataKey]),
}));
if (processedData.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">No valid data to display</div>
);
}
// Generate colors using Formbricks brand palette
const colors = processedData.map((_, index) => {
const hue = 180; // Teal base hue
const saturation = 70 + (index % 3) * 10; // Vary saturation
const lightness = 45 + (index % 2) * 15; // Vary lightness
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
});
// Use brand colors for first two slices
if (colors.length > 0) colors[0] = BRAND_DARK;
if (colors.length > 1) colors[1] = BRAND_LIGHT;
return (
<div className="h-64 min-h-[256px] w-full min-w-0">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full min-w-0">
<PieChart width={400} height={256}>
<Pie
data={processedData}
dataKey={dataKey}
nameKey={xAxisKey}
cx="50%"
cy="50%"
outerRadius={80}
label={({ name, percent }) => {
if (!percent) return "";
return `${name}: ${(percent * 100).toFixed(0)}%`;
}}>
{processedData.map((row, index) => {
const rowKey = row[xAxisKey] ?? `row-${index}`;
const uniqueKey = `${xAxisKey}-${String(rowKey)}-${index}`;
return <Cell key={uniqueKey} fill={colors[index] || BRAND_DARK} />;
})}
</Pie>
<ChartTooltip
content={<ChartTooltipContent />}
formatter={(value: any, name: string) => {
const numValue = Number(value);
return [numValue.toLocaleString(), name];
}}
/>
</PieChart>
</ChartContainer>
</div>
);
}
case "kpi":
case "big_number": {
const total = data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0);
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="text-4xl font-bold text-gray-900">{total.toLocaleString()}</div>
<div className="mt-2 text-sm text-gray-500">{dataKey}</div>
</div>
</div>
);
}
default:
return (
<div className="flex h-64 items-center justify-center text-gray-500">
Chart type "{chartType}" not yet supported
</div>
);
}
}

View File

@@ -0,0 +1,94 @@
"use client";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { CHART_TYPES } from "../lib/chart-types";
interface ConfigureChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentChartType: string;
configuredChartType: string | null;
onChartTypeSelect: (type: string) => void;
onReset: () => void;
}
export function ConfigureChartDialog({
open,
onOpenChange,
currentChartType,
configuredChartType,
onChartTypeSelect,
onReset,
}: ConfigureChartDialogProps) {
const availableTypes = CHART_TYPES.filter((type) =>
["bar", "line", "area", "pie", "big_number"].includes(type.id)
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Configure Chart</DialogTitle>
<DialogDescription>
Modify the chart type and other settings for this visualization.
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="space-y-6">
<div>
<h4 className="mb-3 text-sm font-medium text-gray-900">Chart Type</h4>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{availableTypes.map((chart) => {
const isSelected = (configuredChartType || currentChartType) === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => onChartTypeSelect(chart.id)}
className={cn(
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-all hover:bg-gray-50",
isSelected
? "border-brand-dark bg-brand-dark/5 ring-brand-dark ring-2"
: "border-gray-200"
)}>
<div className="flex h-10 w-10 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-5 w-5 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-xs font-medium text-gray-700">{chart.name}</span>
</button>
);
})}
</div>
<div className="mt-3 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onReset} className="text-xs">
Reset to AI suggestion
</Button>
{configuredChartType && (
<span className="text-xs text-gray-500">
Original: {CHART_TYPES.find((t) => t.id === currentChartType)?.name || currentChartType}
</span>
)}
</div>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={() => onOpenChange(false)}>Apply Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { DatabaseIcon } from "lucide-react";
interface DataViewerProps {
data: Record<string, any>[];
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export function DataViewer({ data }: Omit<DataViewerProps, "isOpen" | "onOpenChange">) {
if (!data || data.length === 0) {
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<p className="text-sm text-gray-500">No data available</p>
</div>
);
}
const columns = Object.keys(data[0]);
const displayData = data.slice(0, 50);
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="mb-2 flex items-center gap-2">
<DatabaseIcon className="h-4 w-4 text-gray-600" />
<h4 className="text-sm font-semibold text-gray-900">Chart Data</h4>
</div>
<div className="max-h-64 overflow-auto rounded bg-white">
<table className="w-full text-xs">
<thead className="bg-gray-100">
<tr>
{columns.map((key) => (
<th key={key} className="border-b border-gray-200 px-3 py-2 text-left font-semibold">
{key}
</th>
))}
</tr>
</thead>
<tbody>
{displayData.map((row, index) => {
const rowKey = Object.values(row)[0] ? String(Object.values(row)[0]) : `row-${index}`;
return (
<tr
key={`data-row-${rowKey}-${index}`}
className="border-b border-gray-100 hover:bg-gray-50">
{Object.entries(row).map(([key, value]) => (
<td key={`cell-${key}-${rowKey}`} className="px-3 py-2">
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
{data.length > 50 && (
<div className="px-3 py-2 text-xs text-gray-500">Showing first 50 of {data.length} rows</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { FEEDBACK_FIELDS } from "../lib/schema-definition";
interface DimensionsPanelProps {
selectedDimensions: string[];
onDimensionsChange: (dimensions: string[]) => void;
}
export function DimensionsPanel({ selectedDimensions, onDimensionsChange }: DimensionsPanelProps) {
const dimensionOptions = FEEDBACK_FIELDS.dimensions.map((d) => ({
value: d.id,
label: `${d.label}${d.description ? ` - ${d.description}` : ""}`,
}));
return (
<div className="space-y-4">
<h3 className="font-medium text-gray-900">Dimensions</h3>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Group By</label>
<MultiSelect
options={dimensionOptions}
value={selectedDimensions}
onChange={onDimensionsChange}
placeholder="Select dimensions to group by..."
/>
<p className="text-xs text-gray-500">
Select dimensions to break down your data. The order matters for multi-dimensional charts.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
"use client";
import { Plus, TrashIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { FilterRow } from "../lib/query-builder";
import { FEEDBACK_FIELDS, getFieldById, getFilterOperatorsForType } from "../lib/schema-definition";
interface FiltersPanelProps {
filters: FilterRow[];
filterLogic: "and" | "or";
onFiltersChange: (filters: FilterRow[]) => void;
onFilterLogicChange: (logic: "and" | "or") => void;
}
export function FiltersPanel({
filters,
filterLogic,
onFiltersChange,
onFilterLogicChange,
}: FiltersPanelProps) {
const fieldOptions = [
...FEEDBACK_FIELDS.dimensions.map((d) => ({
value: d.id,
label: d.label,
type: d.type,
})),
...FEEDBACK_FIELDS.measures.map((m) => ({
value: m.id,
label: m.label,
type: m.type === "count" ? "number" : "number",
})),
];
const handleAddFilter = () => {
const firstField = fieldOptions[0];
onFiltersChange([
...filters,
{
field: firstField?.value || "",
operator: "equals",
values: null,
},
]);
};
const handleRemoveFilter = (index: number) => {
onFiltersChange(filters.filter((_, i) => i !== index));
};
const handleUpdateFilter = (index: number, updates: Partial<FilterRow>) => {
const updated = [...filters];
updated[index] = { ...updated[index], ...updates };
// Reset values if operator changed to set/notSet
if (updates.operator && (updates.operator === "set" || updates.operator === "notSet")) {
updated[index].values = null;
}
onFiltersChange(updated);
};
const getValueInput = (filter: FilterRow, index: number) => {
const field = getFieldById(filter.field);
const fieldType = field?.type || "string";
const operators = getFilterOperatorsForType(fieldType as "string" | "number" | "time");
// For set/notSet operators, no value input needed
if (filter.operator === "set" || filter.operator === "notSet") {
return null;
}
// For number fields with comparison operators, use number input
if (
fieldType === "number" &&
(filter.operator === "gt" ||
filter.operator === "gte" ||
filter.operator === "lt" ||
filter.operator === "lte")
) {
return (
<Input
type="number"
placeholder="Enter value"
value={filter.values?.[0] || ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [Number(e.target.value)] : null,
})
}
className="w-[150px]"
/>
);
}
// For equals/notEquals with string fields, allow single value
if ((filter.operator === "equals" || filter.operator === "notEquals") && fieldType === "string") {
return (
<Input
placeholder="Enter value"
value={filter.values?.[0] || ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [e.target.value] : null,
})
}
className="w-[200px]"
/>
);
}
// For contains/notContains, allow multiple values (multi-select)
if (filter.operator === "contains" || filter.operator === "notContains") {
// For now, use a simple input - could be enhanced with multi-select
return (
<Input
placeholder="Enter value"
value={filter.values?.[0] || ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [e.target.value] : null,
})
}
className="w-[200px]"
/>
);
}
// Default: single value input
return (
<Input
placeholder="Enter value"
value={filter.values?.[0] || ""}
onChange={(e) =>
handleUpdateFilter(index, {
values: e.target.value ? [e.target.value] : null,
})
}
className="w-[200px]"
/>
);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900">Filters</h3>
<Select value={filterLogic} onValueChange={(value) => onFilterLogicChange(value as "and" | "or")}>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="and">AND</SelectItem>
<SelectItem value="or">OR</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
{filters.map((filter, index) => {
const field = getFieldById(filter.field);
const fieldType = field?.type || "string";
const operators = getFilterOperatorsForType(fieldType as "string" | "number" | "time");
return (
<div
key={index}
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
<Select
value={filter.field}
onValueChange={(value) => {
const newField = getFieldById(value);
const newType = newField?.type || "string";
const newOperators = getFilterOperatorsForType(newType as "string" | "number" | "time");
handleUpdateFilter(index, {
field: value,
operator: newOperators[0] || "equals",
values: null,
});
}}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent>
{fieldOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filter.operator}
onValueChange={(value) =>
handleUpdateFilter(index, {
operator: value as FilterRow["operator"],
})
}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op} value={op}>
{op === "equals" && "equals"}
{op === "notEquals" && "not equals"}
{op === "contains" && "contains"}
{op === "notContains" && "not contains"}
{op === "set" && "is set"}
{op === "notSet" && "is not set"}
{op === "gt" && "greater than"}
{op === "gte" && "greater than or equal"}
{op === "lt" && "less than"}
{op === "lte" && "less than or equal"}
</SelectItem>
))}
</SelectContent>
</Select>
{getValueInput(filter, index)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFilter(index)}
className="h-8 w-8">
<TrashIcon className="h-4 w-4" />
</Button>
</div>
);
})}
<Button type="button" variant="outline" size="sm" onClick={handleAddFilter} className="h-8">
<Plus className="h-4 w-4" />
Add Filter
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { cn } from "@/lib/cn";
import { CHART_TYPES } from "../lib/chart-types";
interface ManualChartBuilderProps {
selectedChartType: string;
onChartTypeSelect: (type: string) => void;
}
// Filter out table, map, and scatter charts
const AVAILABLE_CHART_TYPES = CHART_TYPES.filter(
(type) => !["table", "map", "scatter"].includes(type.id)
);
export function ManualChartBuilder({
selectedChartType,
onChartTypeSelect,
}: Omit<ManualChartBuilderProps, "onCreate">) {
return (
<div className="space-y-4">
<h2 className="font-medium text-gray-900">Choose chart type</h2>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{AVAILABLE_CHART_TYPES.map((chart) => {
const isSelected = selectedChartType === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => onChartTypeSelect(chart.id)}
className={cn(
"focus:ring-brand-dark rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2",
isSelected
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
: "border-gray-200 hover:border-gray-300"
)}>
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-xs font-medium text-gray-700">{chart.name}</span>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,155 @@
"use client";
import { Plus, TrashIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Input } from "@/modules/ui/components/input";
import { FEEDBACK_FIELDS } from "../lib/schema-definition";
import { CustomMeasure } from "../lib/query-builder";
interface MeasuresPanelProps {
selectedMeasures: string[];
customMeasures: CustomMeasure[];
onMeasuresChange: (measures: string[]) => void;
onCustomMeasuresChange: (measures: CustomMeasure[]) => void;
}
export function MeasuresPanel({
selectedMeasures,
customMeasures,
onMeasuresChange,
onCustomMeasuresChange,
}: MeasuresPanelProps) {
const measureOptions = FEEDBACK_FIELDS.measures.map((m) => ({
value: m.id,
label: `${m.label}${m.description ? ` - ${m.description}` : ""}`,
}));
const dimensionOptions = FEEDBACK_FIELDS.dimensions
.filter((d) => d.type === "number")
.map((d) => ({
value: d.id,
label: d.label,
}));
const aggregationOptions = FEEDBACK_FIELDS.customAggregations.map((agg) => ({
value: agg,
label: agg.charAt(0).toUpperCase() + agg.slice(1),
}));
const handleAddCustomMeasure = () => {
onCustomMeasuresChange([
...customMeasures,
{
field: dimensionOptions[0]?.value || "",
aggregation: "avg",
},
]);
};
const handleRemoveCustomMeasure = (index: number) => {
onCustomMeasuresChange(customMeasures.filter((_, i) => i !== index));
};
const handleUpdateCustomMeasure = (index: number, updates: Partial<CustomMeasure>) => {
const updated = [...customMeasures];
updated[index] = { ...updated[index], ...updates };
onCustomMeasuresChange(updated);
};
return (
<div className="space-y-4">
<h3 className="font-medium text-gray-900">Measures</h3>
<div className="space-y-4">
{/* Predefined Measures */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Predefined Measures</label>
<MultiSelect
options={measureOptions}
value={selectedMeasures}
onChange={(selected) => onMeasuresChange(selected)}
placeholder="Select measures..."
/>
</div>
{/* Custom Measures */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Custom Aggregations</label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddCustomMeasure}
className="h-8">
<Plus className="h-4 w-4" />
Add Custom Measure
</Button>
</div>
{customMeasures.length > 0 && (
<div className="space-y-2">
{customMeasures.map((measure, index) => (
<div key={index} className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
<Select
value={measure.field}
onValueChange={(value) => handleUpdateCustomMeasure(index, { field: value })}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent>
{dimensionOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={measure.aggregation}
onValueChange={(value) => handleUpdateCustomMeasure(index, { aggregation: value })}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{aggregationOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Alias (optional)"
value={measure.alias || ""}
onChange={(e) => handleUpdateCustomMeasure(index, { alias: e.target.value })}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveCustomMeasure(index)}
className="h-8 w-8">
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { CodeIcon } from "lucide-react";
import * as Collapsible from "@radix-ui/react-collapsible";
interface QueryViewerProps {
query: Record<string, any>;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export function QueryViewer({ query, isOpen, onOpenChange }: QueryViewerProps) {
return (
<Collapsible.Root open={isOpen} onOpenChange={onOpenChange}>
<Collapsible.CollapsibleContent className="mt-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="mb-2 flex items-center gap-2">
<CodeIcon className="h-4 w-4 text-gray-600" />
<h4 className="text-sm font-semibold text-gray-900">Cube.js Query</h4>
</div>
<pre className="max-h-64 overflow-auto rounded bg-white p-3 text-xs">
{JSON.stringify(query, null, 2)}
</pre>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
interface SaveChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
chartName: string;
onChartNameChange: (name: string) => void;
onSave: () => void;
isSaving: boolean;
}
export function SaveChartDialog({
open,
onOpenChange,
chartName,
onChartNameChange,
onSave,
isSaving,
}: SaveChartDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save Chart</DialogTitle>
<DialogDescription>Enter a name for your chart to save it.</DialogDescription>
</DialogHeader>
<DialogBody>
<Input
placeholder="Chart name"
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && chartName.trim() && !isSaving) {
onSave();
}
}}
/>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button onClick={onSave} loading={isSaving} disabled={!chartName.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,224 @@
"use client";
import { CalendarIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/modules/ui/components/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Input } from "@/modules/ui/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import Calendar from "react-calendar";
import { format } from "date-fns";
import { FEEDBACK_FIELDS, TIME_GRANULARITIES, DATE_PRESETS } from "../lib/schema-definition";
import { TimeDimensionConfig, getDateRangeFromPreset } from "../lib/query-builder";
import "@/modules/ui/components/date-picker/styles.css";
interface TimeDimensionPanelProps {
timeDimension: TimeDimensionConfig | null;
onTimeDimensionChange: (config: TimeDimensionConfig | null) => void;
}
export function TimeDimensionPanel({
timeDimension,
onTimeDimensionChange,
}: TimeDimensionPanelProps) {
const [dateRangeType, setDateRangeType] = useState<"preset" | "custom">(
timeDimension && typeof timeDimension.dateRange === "string" ? "preset" : "custom"
);
const [customStartDate, setCustomStartDate] = useState<Date | null>(
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[0] : null
);
const [customEndDate, setCustomEndDate] = useState<Date | null>(
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[1] : null
);
const [presetValue, setPresetValue] = useState<string>(
timeDimension && typeof timeDimension.dateRange === "string" ? timeDimension.dateRange : ""
);
const timeFieldOptions = FEEDBACK_FIELDS.dimensions.filter((d) => d.type === "time");
const handleEnableTimeDimension = () => {
if (!timeDimension) {
onTimeDimensionChange({
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: "last 30 days",
});
setPresetValue("last 30 days");
setDateRangeType("preset");
}
};
const handleDisableTimeDimension = () => {
onTimeDimensionChange(null);
};
const handleDimensionChange = (dimension: string) => {
if (timeDimension) {
onTimeDimensionChange({ ...timeDimension, dimension });
}
};
const handleGranularityChange = (granularity: TimeDimensionConfig["granularity"]) => {
if (timeDimension) {
onTimeDimensionChange({ ...timeDimension, granularity });
}
};
const handlePresetChange = (preset: string) => {
setPresetValue(preset);
if (timeDimension) {
onTimeDimensionChange({ ...timeDimension, dateRange: preset });
}
};
const handleCustomDateChange = () => {
if (customStartDate && customEndDate && timeDimension) {
onTimeDimensionChange({
...timeDimension,
dateRange: [customStartDate, customEndDate],
});
}
};
if (!timeDimension) {
return (
<div className="space-y-4">
<h3 className="font-medium text-gray-900">Time Dimension</h3>
<div>
<Button type="button" variant="outline" onClick={handleEnableTimeDimension}>
Enable Time Dimension
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900">Time Dimension</h3>
<Button type="button" variant="ghost" size="sm" onClick={handleDisableTimeDimension}>
Disable
</Button>
</div>
<div className="space-y-3">
{/* Field Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Field</label>
<Select value={timeDimension.dimension} onValueChange={handleDimensionChange}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeFieldOptions.map((field) => (
<SelectItem key={field.id} value={field.id}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Granularity Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Granularity</label>
<Select
value={timeDimension.granularity}
onValueChange={(value) => handleGranularityChange(value as TimeDimensionConfig["granularity"])}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_GRANULARITIES.map((gran) => (
<SelectItem key={gran} value={gran}>
{gran.charAt(0).toUpperCase() + gran.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Date Range */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Date Range</label>
<div className="space-y-2">
<Select value={dateRangeType} onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="preset">Preset</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{dateRangeType === "preset" ? (
<Select value={presetValue} onValueChange={handlePresetChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select preset" />
</SelectTrigger>
<SelectContent>
{DATE_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="grid grid-cols-2 gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{customStartDate ? format(customStartDate, "MMM dd, yyyy") : "Start date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
onChange={(date: Date) => {
setCustomStartDate(date);
if (date && customEndDate) {
handleCustomDateChange();
}
}}
value={customStartDate || undefined}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{customEndDate ? format(customEndDate, "MMM dd, yyyy") : "End date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
onChange={(date: Date) => {
setCustomEndDate(date);
if (customStartDate && date) {
handleCustomDateChange();
}
}}
value={customEndDate || undefined}
minDate={customStartDate || undefined}
/>
</PopoverContent>
</Popover>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export { ChartRenderer } from "./ChartRenderer";
export { QueryViewer } from "./QueryViewer";
export { DataViewer } from "./DataViewer";
export { AIQuerySection } from "./AIQuerySection";
export { ManualChartBuilder } from "./ManualChartBuilder";
export { ChartPreview } from "./ChartPreview";
export { SaveChartDialog } from "./SaveChartDialog";
export { AddToDashboardDialog } from "./AddToDashboardDialog";
export { ConfigureChartDialog } from "./ConfigureChartDialog";
export { ChartBuilderClient } from "./ChartBuilderClient";

View File

@@ -0,0 +1,21 @@
import {
ActivityIcon,
AreaChartIcon,
BarChart3Icon,
LineChartIcon,
MapIcon,
PieChartIcon,
ScatterChart,
TableIcon,
} from "lucide-react";
export const CHART_TYPES = [
{ id: "area", name: "Area Chart", icon: AreaChartIcon },
{ id: "bar", name: "Bar Chart", icon: BarChart3Icon },
{ id: "line", name: "Line Chart", icon: LineChartIcon },
{ id: "pie", name: "Pie Chart", icon: PieChartIcon },
{ id: "table", name: "Table", icon: TableIcon },
{ id: "big_number", name: "Big Number", icon: ActivityIcon },
{ id: "scatter", name: "Scatter Plot", icon: ScatterChart },
{ id: "map", name: "World Map", icon: MapIcon },
] as const;

View File

@@ -0,0 +1,35 @@
// Chart type mapping from API to database
export const mapChartType = (
apiType: string
): "area" | "bar" | "line" | "pie" | "big_number" | "big_number_total" | "table" | "funnel" | "map" => {
const mapping: Record<
string,
"area" | "bar" | "line" | "pie" | "big_number" | "big_number_total" | "table" | "funnel" | "map"
> = {
bar: "bar",
line: "line",
area: "area",
pie: "pie",
donut: "pie",
kpi: "big_number",
};
return mapping[apiType] || "bar";
};
// Reverse mapping from database chart type to API chart type
export const mapDatabaseChartTypeToApi = (
dbType: string
): "bar" | "line" | "donut" | "kpi" | "area" | "pie" => {
const mapping: Record<string, "bar" | "line" | "donut" | "kpi" | "area" | "pie"> = {
bar: "bar",
line: "line",
area: "area",
pie: "pie",
big_number: "kpi",
big_number_total: "kpi",
table: "bar", // Default fallback
funnel: "bar", // Default fallback
map: "bar", // Default fallback
};
return mapping[dbType] || "bar";
};

View File

@@ -0,0 +1,185 @@
/**
* Query builder utility to construct Cube.js queries from chart builder state
*/
import { CubeQuery, TimeDimension, Filter } from "@/app/api/analytics/_lib/types";
export interface CustomMeasure {
field: string; // e.g., "FeedbackRecords.npsValue"
aggregation: string; // e.g., "avg", "sum", "countDistinct"
alias?: string; // optional display name
}
export interface FilterRow {
field: string;
operator: Filter["operator"];
values: string[] | number[] | null;
}
export interface TimeDimensionConfig {
dimension: string;
granularity: "hour" | "day" | "week" | "month" | "quarter" | "year";
dateRange: string | [Date, Date]; // "last 7 days" or [startDate, endDate]
}
export interface ChartBuilderState {
chartType: string;
selectedMeasures: string[];
customMeasures: CustomMeasure[];
selectedDimensions: string[];
filters: FilterRow[];
filterLogic: "and" | "or";
timeDimension: TimeDimensionConfig | null;
limit?: number;
orderBy?: { field: string; direction: "asc" | "desc" };
}
/**
* Build a Cube.js query from chart builder state
*/
export function buildCubeQuery(config: ChartBuilderState): CubeQuery {
const query: CubeQuery = {
measures: [
...config.selectedMeasures,
// Custom measures would need to be handled differently in Cube.js
// For now, we'll just include the predefined measures
],
};
if (config.selectedDimensions.length > 0) {
query.dimensions = config.selectedDimensions;
}
if (config.timeDimension) {
const timeDim: TimeDimension = {
dimension: config.timeDimension.dimension,
granularity: config.timeDimension.granularity,
};
// Handle date range
if (typeof config.timeDimension.dateRange === "string") {
timeDim.dateRange = config.timeDimension.dateRange;
} else if (Array.isArray(config.timeDimension.dateRange)) {
// Convert Date objects to ISO strings (Cube.js expects YYYY-MM-DD format or ISO strings)
const [startDate, endDate] = config.timeDimension.dateRange;
// Format as YYYY-MM-DD for better compatibility
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
}
query.timeDimensions = [timeDim];
}
if (config.filters.length > 0) {
query.filters = config.filters.map((f) => {
const filter: Filter = {
member: f.field,
operator: f.operator,
};
// Only include values if operator requires them
if (f.operator !== "set" && f.operator !== "notSet" && f.values) {
filter.values = f.values.map((v) => String(v));
}
return filter;
});
}
return query;
}
/**
* Parse a Cube.js query back into ChartBuilderState
*/
export function parseQueryToState(query: CubeQuery, chartType?: string): Partial<ChartBuilderState> {
const state: Partial<ChartBuilderState> = {
chartType: chartType || "",
selectedMeasures: query.measures || [],
customMeasures: [],
selectedDimensions: query.dimensions || [],
filters: [],
filterLogic: "and",
timeDimension: null,
};
// Parse filters
if (query.filters && query.filters.length > 0) {
state.filters = query.filters.map((f) => ({
field: f.member,
operator: f.operator,
values: f.values || null,
}));
}
// Parse time dimensions
if (query.timeDimensions && query.timeDimensions.length > 0) {
const timeDim = query.timeDimensions[0];
state.timeDimension = {
dimension: timeDim.dimension,
granularity: (timeDim.granularity || "day") as TimeDimensionConfig["granularity"],
dateRange: timeDim.dateRange || "last 30 days",
};
}
return state;
}
/**
* Convert date preset string to date range
*/
export function getDateRangeFromPreset(preset: string): [Date, Date] | null {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
switch (preset) {
case "today": {
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
return [today, tomorrow];
}
case "yesterday": {
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
return [yesterday, today];
}
case "last 7 days": {
const sevenDaysAgo = new Date(today);
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
return [sevenDaysAgo, today];
}
case "last 30 days": {
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
return [thirtyDaysAgo, today];
}
case "this month": {
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return [firstDay, lastDay];
}
case "last month": {
const firstDayLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const firstDayThisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return [firstDayLastMonth, firstDayThisMonth];
}
case "this quarter": {
const quarter = Math.floor(now.getMonth() / 3);
const firstDay = new Date(now.getFullYear(), quarter * 3, 1);
const lastDay = new Date(now.getFullYear(), (quarter + 1) * 3, 1);
return [firstDay, lastDay];
}
case "this year": {
const firstDay = new Date(now.getFullYear(), 0, 1);
const lastDay = new Date(now.getFullYear() + 1, 0, 1);
return [firstDay, lastDay];
}
default:
return null;
}
}

View File

@@ -0,0 +1,171 @@
/**
* Schema definitions for FeedbackRecords fields
* Used by the advanced chart builder to provide field metadata and operators
*/
export interface FieldDefinition {
id: string;
label: string;
type: "string" | "number" | "time";
description?: string;
}
export interface MeasureDefinition {
id: string;
label: string;
type: "count" | "number";
description?: string;
}
export const FEEDBACK_FIELDS = {
dimensions: [
{
id: "FeedbackRecords.sentiment",
label: "Sentiment",
type: "string",
description: "Sentiment extracted from feedback",
},
{
id: "FeedbackRecords.sourceType",
label: "Source Type",
type: "string",
description: "Source type of the feedback (e.g., nps_campaign, survey)",
},
{
id: "FeedbackRecords.sourceName",
label: "Source Name",
type: "string",
description: "Human-readable name of the source",
},
{
id: "FeedbackRecords.fieldType",
label: "Field Type",
type: "string",
description: "Type of feedback field (e.g., nps, text, rating)",
},
{
id: "FeedbackRecords.emotion",
label: "Emotion",
type: "string",
description: "Emotion extracted from metadata JSONB field",
},
{
id: "FeedbackRecords.userIdentifier",
label: "User Identifier",
type: "string",
description: "Identifier of the user who provided feedback",
},
{
id: "FeedbackRecords.responseId",
label: "Response ID",
type: "string",
description: "Unique identifier linking related feedback records",
},
{
id: "FeedbackRecords.npsValue",
label: "NPS Value",
type: "number",
description: "Raw NPS score value (0-10)",
},
{
id: "FeedbackRecords.collectedAt",
label: "Collected At",
type: "time",
description: "Timestamp when the feedback was collected",
},
{
id: "TopicsUnnested.topic",
label: "Topic",
type: "string",
description: "Individual topic from the topics array",
},
] as FieldDefinition[],
measures: [
{
id: "FeedbackRecords.count",
label: "Count",
type: "count",
description: "Total number of feedback responses",
},
{
id: "FeedbackRecords.promoterCount",
label: "Promoter Count",
type: "count",
description: "Number of promoters (NPS score 9-10)",
},
{
id: "FeedbackRecords.detractorCount",
label: "Detractor Count",
type: "count",
description: "Number of detractors (NPS score 0-6)",
},
{
id: "FeedbackRecords.passiveCount",
label: "Passive Count",
type: "count",
description: "Number of passives (NPS score 7-8)",
},
{
id: "FeedbackRecords.npsScore",
label: "NPS Score",
type: "number",
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
},
{
id: "FeedbackRecords.averageScore",
label: "Average Score",
type: "number",
description: "Average NPS score",
},
] as MeasureDefinition[],
customAggregations: ["count", "countDistinct", "sum", "avg", "min", "max"],
};
export type FilterOperator =
| "equals"
| "notEquals"
| "contains"
| "notContains"
| "set"
| "notSet"
| "gt"
| "gte"
| "lt"
| "lte";
export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
};
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
export type TimeGranularity = (typeof TIME_GRANULARITIES)[number];
export const DATE_PRESETS = [
{ label: "Today", value: "today" },
{ label: "Yesterday", value: "yesterday" },
{ label: "Last 7 days", value: "last 7 days" },
{ label: "Last 30 days", value: "last 30 days" },
{ label: "This month", value: "this month" },
{ label: "Last month", value: "last month" },
{ label: "This quarter", value: "this quarter" },
{ label: "This year", value: "this year" },
] as const;
/**
* Get filter operators for a given field type
*/
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
}
/**
* Get field definition by ID
*/
export function getFieldById(id: string): FieldDefinition | MeasureDefinition | undefined {
const dimension = FEEDBACK_FIELDS.dimensions.find((d) => d.id === id);
if (dimension) return dimension;
return FEEDBACK_FIELDS.measures.find((m) => m.id === id);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { TChart } from "../../types/analysis";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
interface ChartDropdownMenuProps {
environmentId: string;
chart: TChart;
disabled?: boolean;
deleteChart: (chartId: string) => void;
onEdit?: () => void;
}
export const ChartDropdownMenu = ({
environmentId,
chart,
disabled,
deleteChart,
onEdit,
}: ChartDropdownMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const handleDeleteChart = async (chartId: string) => {
setLoading(true);
try {
// TODO: Implement deleteChartAction
// await deleteChartAction({ chartId });
deleteChart(chartId);
toast.success("Chart deleted successfully");
} catch (error) {
toast.error("Error deleting chart");
} finally {
setLoading(false);
}
};
return (
<div
id={`${chart.name.toLowerCase().split(" ").join("-")}-chart-actions`}
data-testid="chart-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
<div
className={cn(
"rounded-lg border bg-white p-2",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
)}
onClick={(e) => e.stopPropagation()}>
<span className="sr-only">Open options</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max">
<DropdownMenuGroup>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
onEdit?.();
}}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
// TODO: Implement duplicate functionality
toast.success("Duplicate functionality coming soon");
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat="Chart"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteChart(chart.id)}
text="Are you sure you want to delete this chart? This action cannot be undone."
isDeleting={loading}
/>
</div>
);
};

View File

@@ -0,0 +1,133 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { useState } from "react";
import {
ActivityIcon,
AreaChartIcon,
BarChart3Icon,
LineChartIcon,
MapIcon,
PieChartIcon,
ScatterChart,
TableIcon,
} from "lucide-react";
import { TChart } from "../../types/analysis";
import { ChartDropdownMenu } from "./ChartDropdownMenu";
import { CreateChartDialog } from "./CreateChartDialog";
interface ChartsListClientProps {
charts: TChart[];
dashboards: any[];
environmentId: string;
}
const CHART_TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
area: AreaChartIcon,
bar: BarChart3Icon,
line: LineChartIcon,
pie: PieChartIcon,
table: TableIcon,
big_number: ActivityIcon,
big_number_total: ActivityIcon,
scatter: ScatterChart,
map: MapIcon,
};
export function ChartsListClient({ charts: initialCharts, dashboards, environmentId }: ChartsListClientProps) {
const [charts, setCharts] = useState(initialCharts);
const [editingChartId, setEditingChartId] = useState<string | undefined>(undefined);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const filteredCharts = charts;
const deleteChart = (chartId: string) => {
setCharts(charts.filter((c) => c.id !== chartId));
};
const getChartIcon = (type: string) => {
const IconComponent = CHART_TYPE_ICONS[type] || BarChart3Icon;
return <IconComponent className="h-5 w-5" />;
};
const handleChartClick = (chartId: string) => {
setEditingChartId(chartId);
setIsEditDialogOpen(true);
};
const handleEditSuccess = () => {
// Refresh charts list if needed
setIsEditDialogOpen(false);
setEditingChartId(undefined);
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">Title</div>
<div className="col-span-1 hidden text-center sm:block">Created By</div>
<div className="col-span-1 hidden text-center sm:block">Created</div>
<div className="col-span-1 hidden text-center sm:block">Updated</div>
<div className="col-span-1"></div>
</div>
{filteredCharts.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">No charts found.</p>
) : (
<>
{filteredCharts.map((chart) => (
<div
key={chart.id}
onClick={() => handleChartClick(chart.id)}
className="grid h-12 w-full cursor-pointer grid-cols-7 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
{getChartIcon(chart.type)}
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{chart.createdByName || "-"}</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{format(new Date(chart.createdAt), "do 'of' MMMM, yyyy")}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{formatDistanceToNow(new Date(chart.updatedAt), {
addSuffix: true,
}).replace("about", "")}
</div>
</div>
<div
className="col-span-1 my-auto flex items-center justify-end pr-6"
onClick={(e) => e.stopPropagation()}>
<ChartDropdownMenu
environmentId={environmentId}
chart={chart}
deleteChart={deleteChart}
onEdit={() => {
setEditingChartId(chart.id);
setIsEditDialogOpen(true);
}}
/>
</div>
</div>
))}
</>
)}
<CreateChartDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
environmentId={environmentId}
chartId={editingChartId}
onSuccess={handleEditSuccess}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/modules/ui/components/button";
import { CreateChartDialog } from "./CreateChartDialog";
interface CreateChartButtonProps {
environmentId: string;
}
export function CreateChartButton({ environmentId }: CreateChartButtonProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
return (
<>
<Button onClick={() => setIsDialogOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
Chart
</Button>
<CreateChartDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
environmentId={environmentId}
/>
</>
);
}

View File

@@ -0,0 +1,431 @@
"use client";
import { useState, useEffect } from "react";
import { toast } from "react-hot-toast";
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
addChartToDashboardAction,
createChartAction,
executeQueryAction,
getChartAction,
getDashboardsAction,
updateChartAction,
} from "../../actions";
import { PlusIcon, SaveIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
import { mapChartType, mapDatabaseChartTypeToApi } from "../../chart-builder/lib/chart-utils";
import { AIQuerySection } from "../../chart-builder/components/AIQuerySection";
import { AddToDashboardDialog } from "../../chart-builder/components/AddToDashboardDialog";
import { AdvancedChartBuilder } from "../../chart-builder/components/AdvancedChartBuilder";
import { ChartPreview } from "../../chart-builder/components/ChartPreview";
import { ManualChartBuilder } from "../../chart-builder/components/ManualChartBuilder";
import { SaveChartDialog } from "../../chart-builder/components/SaveChartDialog";
interface CreateChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId?: string;
onSuccess?: () => void;
}
export function CreateChartDialog({
open,
onOpenChange,
environmentId,
chartId,
onSuccess,
}: CreateChartDialogProps) {
const [selectedChartType, setSelectedChartType] = useState<string>("");
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
// Determine if we should show AdvancedChartBuilder
const shouldShowAdvancedBuilder = !!selectedChartType || !!chartData;
useEffect(() => {
if (isAddToDashboardDialogOpen) {
getDashboardsAction({ environmentId }).then((result) => {
if (result?.data) {
setDashboards(result.data);
} else if (result?.serverError) {
toast.error(result.serverError);
}
});
}
}, [isAddToDashboardDialogOpen, environmentId]);
useEffect(() => {
if (open && chartId) {
setIsLoadingChart(true);
getChartAction({ environmentId, chartId })
.then(async (result) => {
if (result?.data) {
const chart = result.data;
setChartName(chart.name);
// Execute the chart's query to get the data
const queryResult = await executeQueryAction({
environmentId,
query: chart.query as any,
});
if (queryResult?.error || queryResult?.serverError) {
toast.error(queryResult.error || queryResult.serverError || "Failed to load chart data");
setIsLoadingChart(false);
return;
}
if (queryResult?.data?.data) {
// Format as AnalyticsResponse
const chartData: AnalyticsResponse = {
query: chart.query as any,
chartType: mapDatabaseChartTypeToApi(chart.type),
data: Array.isArray(queryResult.data.data) ? queryResult.data.data : [],
};
setChartData(chartData);
setCurrentChartId(chart.id);
} else {
toast.error("No data returned for chart");
}
} else if (result?.serverError) {
toast.error(result.serverError);
}
setIsLoadingChart(false);
})
.catch((error: any) => {
toast.error(error.message || "Failed to load chart");
setIsLoadingChart(false);
});
} else if (open && !chartId) {
// Reset state for new chart
setChartData(null);
setChartName("");
setSelectedChartType("");
setCurrentChartId(undefined);
}
}, [open, chartId, environmentId]);
const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
// Set chart type so AdvancedChartBuilder shows with the AI-generated chart type
if (data.chartType) {
setSelectedChartType(data.chartType);
}
};
const handleSaveChart = async () => {
if (!chartData || !chartName.trim()) {
toast.error("Please enter a chart name");
return;
}
setIsSaving(true);
try {
// If we have a currentChartId, update the existing chart; otherwise create a new one
if (currentChartId) {
const result = await updateChartAction({
environmentId,
chartId: currentChartId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to update chart");
return;
}
toast.success("Chart updated successfully!");
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
} else {
const result = await createChartAction({
environmentId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to save chart");
return;
}
setCurrentChartId(result.data.id);
toast.success("Chart saved successfully!");
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
}
} catch (error: any) {
toast.error(error.message || "Failed to save chart");
} finally {
setIsSaving(false);
}
};
const handleAddToDashboard = async () => {
if (!chartData || !selectedDashboardId) {
toast.error("Please select a dashboard");
return;
}
setIsSaving(true);
try {
let chartIdToUse = currentChartId;
// If we don't have a chartId (creating new chart), create it first
if (!chartIdToUse) {
if (!chartName.trim()) {
toast.error("Please enter a chart name");
setIsSaving(false);
return;
}
const chartResult = await createChartAction({
environmentId,
name: chartName.trim(),
type: mapChartType(chartData.chartType),
query: chartData.query,
config: {},
});
if (!chartResult?.data) {
toast.error(chartResult?.serverError || "Failed to save chart");
setIsSaving(false);
return;
}
chartIdToUse = chartResult.data.id;
setCurrentChartId(chartIdToUse);
}
// Add the chart (existing or newly created) to the dashboard
const widgetResult = await addChartToDashboardAction({
environmentId,
chartId: chartIdToUse,
dashboardId: selectedDashboardId,
title: chartName.trim(),
layout: { x: 0, y: 0, w: 4, h: 3 },
});
if (!widgetResult?.data) {
toast.error(widgetResult?.serverError || "Failed to add chart to dashboard");
return;
}
toast.success("Chart added to dashboard!");
setIsAddToDashboardDialogOpen(false);
onOpenChange(false);
onSuccess?.();
} catch (error: any) {
toast.error(error.message || "Failed to add chart to dashboard");
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
if (!isSaving) {
setChartData(null);
setChartName("");
setSelectedChartType("");
setCurrentChartId(undefined);
onOpenChange(false);
}
};
// If loading an existing chart, show loading state
if (chartId && isLoadingChart) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-[90vw] max-h-[90vh] overflow-y-auto">
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
</DialogContent>
</Dialog>
);
}
// If viewing an existing chart, show only the chart preview
if (chartId && chartData) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-7xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Chart</DialogTitle>
<DialogDescription>View and edit your chart configuration.</DialogDescription>
</DialogHeader>
<DialogBody>
<ChartPreview chartData={chartData} />
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddToDashboardDialogOpen(true)} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
Add to Dashboard
</Button>
<Button onClick={() => setIsSaveDialogOpen(true)} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
Save Chart
</Button>
</DialogFooter>
{/* Dialogs */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
<DialogHeader>
<DialogTitle>{chartId ? "Edit Chart" : "Create Chart"}</DialogTitle>
<DialogDescription>
{chartId
? "View and edit your chart configuration."
: "Use AI to generate a chart or build one manually."}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4">
{/* AI Query Section */}
<AIQuerySection onChartGenerated={handleChartGenerated} />
{/* OR Separator */}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-gray-50 px-2 text-sm text-gray-500">OR</span>
</div>
</div>
{/* Chart Type Selection */}
<ManualChartBuilder
selectedChartType={selectedChartType}
onChartTypeSelect={setSelectedChartType}
/>
{/* Advanced Builder - shown when chart type selected OR AI chart generated */}
{shouldShowAdvancedBuilder && (
<AdvancedChartBuilder
environmentId={environmentId}
initialChartType={selectedChartType || chartData?.chartType || ""}
initialQuery={chartData?.query}
hidePreview={true}
onChartGenerated={(data) => {
setChartData(data);
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
// Update selectedChartType when chart type changes in AdvancedChartBuilder
if (data.chartType) {
setSelectedChartType(data.chartType);
}
}}
onSave={(chartId) => {
setCurrentChartId(chartId);
setIsSaveDialogOpen(false);
onOpenChange(false);
onSuccess?.();
}}
onAddToDashboard={(chartId, dashboardId) => {
setCurrentChartId(chartId);
setIsAddToDashboardDialogOpen(false);
onOpenChange(false);
onSuccess?.();
}}
/>
)}
{/* Single Chart Preview - shown when chartData exists */}
{chartData && <ChartPreview chartData={chartData} />}
</div>
</DialogBody>
{chartData && (
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddToDashboardDialogOpen(true)} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
Add to Dashboard
</Button>
<Button onClick={() => setIsSaveDialogOpen(true)} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
Save Chart
</Button>
</DialogFooter>
)}
{/* Dialogs */}
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
open={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onAdd={handleAddToDashboard}
isSaving={isSaving}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,13 @@
import { getCharts, getDashboards } from "../lib/data";
import { ChartsListClient } from "./components/ChartsListClient";
export default async function ChartsListPage({
params,
}: {
params: Promise<{ environmentId: string }>;
}) {
const { environmentId } = await params;
const [charts, dashboards] = await Promise.all([getCharts(environmentId), getDashboards(environmentId)]);
return <ChartsListClient charts={charts} dashboards={dashboards} environmentId={environmentId} />;
}

View File

@@ -0,0 +1,42 @@
"use client";
import { usePathname } from "next/navigation";
import { use } from "react";
import { CreateChartButton } from "../charts/components/CreateChartButton";
import { CreateDashboardButton } from "../dashboards/components/CreateDashboardButton";
import { AnalysisPageLayout } from "./analysis-page-layout";
interface AnalysisLayoutClientProps {
children: React.ReactNode;
params: Promise<{ environmentId: string }>;
}
export function AnalysisLayoutClient({ children, params }: AnalysisLayoutClientProps) {
const pathname = usePathname();
const { environmentId } = use(params);
// Determine active tab based on pathname
let activeId = "dashboards"; // default
if (pathname?.includes("/charts")) {
activeId = "charts";
} else if (pathname?.includes("/dashboards") || pathname?.includes("/dashboard/")) {
activeId = "dashboards";
}
// Show CTA button based on current page
const isDashboardsPage = pathname?.includes("/dashboards") && !pathname?.includes("/dashboard/");
const isChartsPage = pathname?.includes("/charts");
let cta;
if (isDashboardsPage) {
cta = <CreateDashboardButton environmentId={environmentId} />;
} else if (isChartsPage) {
cta = <CreateChartButton environmentId={environmentId} />;
}
return (
<AnalysisPageLayout pageTitle="Analysis" activeId={activeId} environmentId={environmentId} cta={cta}>
{children}
</AnalysisPageLayout>
);
}

View File

@@ -0,0 +1,29 @@
import { ReactNode } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { AnalysisSecondaryNavigation } from "./analysis-secondary-navigation";
interface AnalysisPageLayoutProps {
pageTitle: string;
activeId: string;
environmentId: string;
cta?: ReactNode;
children: ReactNode;
}
export function AnalysisPageLayout({
pageTitle,
activeId,
environmentId,
cta,
children,
}: AnalysisPageLayoutProps) {
return (
<PageContentWrapper>
<PageHeader pageTitle={pageTitle} cta={cta}>
<AnalysisSecondaryNavigation activeId={activeId} environmentId={environmentId} />
</PageHeader>
{children}
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,26 @@
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface AnalysisSecondaryNavigationProps {
activeId: string;
environmentId: string;
}
export function AnalysisSecondaryNavigation({
activeId,
environmentId,
}: AnalysisSecondaryNavigationProps) {
const navigation = [
{
id: "dashboards",
label: "Dashboards",
href: `/environments/${environmentId}/analysis/dashboards`,
},
{
id: "charts",
label: "Charts",
href: `/environments/${environmentId}/analysis/charts`,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} />;
}

View File

@@ -0,0 +1,106 @@
"use client";
import { CopyIcon, PencilIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDashboard } from "../../../types/analysis";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
import { EditDashboardDialog } from "./EditDashboardDialog";
interface DashboardControlBarProps {
environmentId: string;
dashboard: TDashboard;
onDashboardUpdate?: () => void;
}
export const DashboardControlBar = ({
environmentId,
dashboard,
onDashboardUpdate,
}: DashboardControlBarProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const handleDeleteDashboard = async () => {
setIsDeleting(true);
try {
// TODO: Implement deleteDashboardAction when available
// const result = await deleteDashboardAction({ environmentId, dashboardId: dashboard.id });
// if (result?.data) {
// router.push(`/environments/${environmentId}/analysis/dashboards`);
// toast.success("Dashboard deleted successfully");
// } else {
// toast.error(result?.serverError || "Failed to delete dashboard");
// }
toast.error("Delete functionality coming soon");
} catch (error: any) {
toast.error(error.message || "Failed to delete dashboard");
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
}
};
const handleDuplicate = async () => {
// TODO: Implement duplicate functionality
toast.success("Duplicate functionality coming soon");
};
const iconActions = [
{
icon: PencilIcon,
tooltip: t("common.edit"),
onClick: () => {
setIsEditDialogOpen(true);
},
isVisible: true,
},
{
icon: CopyIcon,
tooltip: t("common.duplicate"),
onClick: handleDuplicate,
isVisible: true,
},
{
icon: TrashIcon,
tooltip: t("common.delete"),
onClick: () => {
setDeleteDialogOpen(true);
},
isVisible: true,
},
];
return (
<>
<IconBar actions={iconActions} />
<DeleteDialog
deleteWhat="Dashboard"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={handleDeleteDashboard}
text="Are you sure you want to delete this dashboard? This action cannot be undone."
isDeleting={isDeleting}
/>
<EditDashboardDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
dashboardId={dashboard.id}
environmentId={environmentId}
initialName={dashboard.name}
initialDescription={dashboard.description}
onSuccess={() => {
setIsEditDialogOpen(false);
onDashboardUpdate?.();
router.refresh();
}}
/>
</>
);
};

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { TDashboard } from "../../../types/analysis";
import { CreateChartButton } from "../../../charts/components/CreateChartButton";
import { DashboardWidget } from "./DashboardWidget";
import { DashboardControlBar } from "./DashboardControlBar";
interface DashboardDetailClientProps {
dashboard: TDashboard;
environmentId: string;
}
export function DashboardDetailClient({ dashboard: initialDashboard, environmentId }: DashboardDetailClientProps) {
const router = useRouter();
const [dashboard] = useState(initialDashboard);
const isEmpty = dashboard.widgets.length === 0;
const handleDashboardUpdate = () => {
router.refresh();
};
// Calculate grid column span based on widget layout width
const getColSpan = (w: number) => {
// Assuming w is in a 12-column grid system
// Map widget width to Tailwind col-span classes
if (w <= 2) return "col-span-12 md:col-span-2";
if (w <= 3) return "col-span-12 md:col-span-3";
if (w <= 4) return "col-span-12 md:col-span-4";
if (w <= 6) return "col-span-12 md:col-span-6";
if (w <= 8) return "col-span-12 md:col-span-8";
if (w <= 9) return "col-span-12 md:col-span-9";
return "col-span-12";
};
return (
<PageContentWrapper>
<GoBackButton url={`/environments/${environmentId}/analysis/dashboards`} />
<PageHeader
pageTitle={dashboard.name}
cta={
<DashboardControlBar
environmentId={environmentId}
dashboard={dashboard}
onDashboardUpdate={handleDashboardUpdate}
/>
}>
{dashboard.description && (
<p className="mt-2 text-sm text-gray-500">{dashboard.description}</p>
)}
</PageHeader>
<section className="pt-6 pb-24">
{isEmpty ? (
// Empty State
<div className="flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/50">
<div className="mb-4 rounded-full bg-gray-100 p-4">
<div className="h-12 w-12 rounded-md bg-gray-300 opacity-20" />
</div>
<h3 className="text-lg font-medium text-gray-900">No Data</h3>
<p className="mt-2 max-w-sm text-center text-gray-500">
There is currently no information to display. Add charts to build your dashboard.
</p>
<CreateChartButton environmentId={environmentId} />
</div>
) : (
// Grid Layout - Render widgets dynamically
<div className="grid grid-cols-12 gap-6">
{dashboard.widgets.map((widget) => (
<div key={widget.id} className={getColSpan(widget.layout.w)}>
<DashboardWidget widget={widget} environmentId={environmentId} />
</div>
))}
</div>
)}
</section>
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useEffect, useState } from "react";
import { MoreHorizontalIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { executeQueryAction } from "../../../actions";
import { ChartRenderer } from "../../../chart-builder/components/ChartRenderer";
import { TDashboardWidget } from "../../../types/analysis";
interface DashboardWidgetProps {
widget: TDashboardWidget;
environmentId: string;
}
export function DashboardWidget({ widget, environmentId }: DashboardWidgetProps) {
const [chartData, setChartData] = useState<Record<string, any>[] | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (widget.type === "chart" && widget.chart) {
setIsLoading(true);
setError(null);
executeQueryAction({
environmentId,
query: widget.chart.query,
})
.then((result) => {
if (result?.serverError || result?.error) {
setError(result.serverError || result.error || "Failed to load chart data");
setChartData(null);
} else if (result?.data?.data) {
const data = Array.isArray(result.data.data) ? result.data.data : [];
setChartData(data);
setError(null);
} else {
setError("No data returned");
setChartData(null);
}
})
.catch((err: any) => {
setError(err.message || "Failed to load chart data");
setChartData(null);
})
.finally(() => {
setIsLoading(false);
});
}
}, [widget, environmentId]);
const renderContent = () => {
if (widget.type === "chart") {
if (!widget.chart) {
return (
<div className="flex h-full items-center justify-center text-gray-500">
Chart not found
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (error) {
return (
<div className="flex h-full w-full flex-col items-start justify-center rounded-md border border-red-100 bg-red-50 p-4">
<div className="mb-1 flex items-center gap-2 font-semibold text-red-700">
<div className="rounded-full bg-red-600 p-0.5">
<span className="block h-3 w-3 text-center text-[10px] leading-3 text-white"></span>
</div>
Data error
</div>
<p className="text-xs text-red-600">{error}</p>
</div>
);
}
if (!chartData || chartData.length === 0) {
return (
<div className="flex h-full items-center justify-center text-gray-500">
No data available
</div>
);
}
return <ChartRenderer chartType={widget.chart.type} data={chartData} />;
}
if (widget.type === "markdown") {
return (
<div className="prose prose-sm max-w-none">
{/* TODO: Render markdown content */}
<p className="text-gray-500">Markdown content not yet implemented</p>
</div>
);
}
if (widget.type === "header") {
return (
<div className="flex h-full items-center">
<h2 className="text-2xl font-semibold text-gray-900">{widget.title || "Header"}</h2>
</div>
);
}
if (widget.type === "divider") {
return <div className="h-full w-full border-t border-gray-200" />;
}
return null;
};
return (
<div className="flex h-full flex-col rounded-sm border border-gray-200 bg-white shadow-sm ring-1 ring-black/5">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-2">
<h3 className="text-sm font-semibold text-gray-800">
{widget.title || widget.chart?.name || "Widget"}
</h3>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-gray-400 hover:text-gray-600">
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Force refresh</DropdownMenuItem>
<DropdownMenuItem>View as table</DropdownMenuItem>
<DropdownMenuItem>Maximize</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Body */}
<div className="relative min-h-[300px] flex-1 p-4">{renderContent()}</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useState, useEffect } from "react";
import { toast } from "react-hot-toast";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { updateDashboardAction } from "../../../actions";
interface EditDashboardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
dashboardId: string;
environmentId: string;
initialName: string;
initialDescription?: string;
onSuccess: () => void;
}
export function EditDashboardDialog({
open,
onOpenChange,
dashboardId,
environmentId,
initialName,
initialDescription,
onSuccess,
}: EditDashboardDialogProps) {
const [dashboardName, setDashboardName] = useState(initialName);
const [dashboardDescription, setDashboardDescription] = useState(initialDescription || "");
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (open) {
setDashboardName(initialName);
setDashboardDescription(initialDescription || "");
}
}, [open, initialName, initialDescription]);
const handleSave = async () => {
if (!dashboardName.trim()) {
toast.error("Please enter a dashboard name");
return;
}
setIsSaving(true);
try {
const result = await updateDashboardAction({
environmentId,
dashboardId,
name: dashboardName.trim(),
description: dashboardDescription.trim() || null,
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to update dashboard");
return;
}
toast.success("Dashboard updated successfully!");
onSuccess();
onOpenChange(false);
} catch (error: any) {
toast.error(error.message || "Failed to update dashboard");
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Dashboard</DialogTitle>
<DialogDescription>Update dashboard name and description.</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4">
<div className="space-y-2">
<label htmlFor="edit-dashboard-name" className="text-sm font-medium text-gray-900">
Dashboard Name
</label>
<Input
id="edit-dashboard-name"
placeholder="Dashboard name"
value={dashboardName}
onChange={(e) => setDashboardName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && dashboardName.trim() && !isSaving) {
handleSave();
}
}}
autoFocus
/>
</div>
<div className="space-y-2">
<label htmlFor="edit-dashboard-description" className="text-sm font-medium text-gray-900">
Description (Optional)
</label>
<Input
id="edit-dashboard-description"
placeholder="Dashboard description"
value={dashboardDescription}
onChange={(e) => setDashboardDescription(e.target.value)}
/>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button onClick={handleSave} loading={isSaving} disabled={!dashboardName.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,8 @@
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// This layout bypasses the analysis layout, allowing the dashboard page to have its own layout
return children;
}

View File

@@ -0,0 +1,18 @@
import { notFound } from "next/navigation";
import { getDashboard } from "../../lib/data";
import { DashboardDetailClient } from "./components/DashboardDetailClient";
export default async function DashboardPage({
params,
}: {
params: Promise<{ environmentId: string; dashboardId: string }>;
}) {
const { environmentId, dashboardId } = await params;
const dashboard = await getDashboard(environmentId, dashboardId);
if (!dashboard) {
return notFound();
}
return <DashboardDetailClient dashboard={dashboard} environmentId={environmentId} />;
}

View File

@@ -0,0 +1,76 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/modules/ui/components/button";
import { createDashboardAction } from "../../actions";
import { CreateDashboardDialog } from "./CreateDashboardDialog";
interface CreateDashboardButtonProps {
environmentId: string;
}
export function CreateDashboardButton({ environmentId }: CreateDashboardButtonProps) {
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [dashboardName, setDashboardName] = useState("");
const [dashboardDescription, setDashboardDescription] = useState("");
const [isCreating, setIsCreating] = useState(false);
const handleCreateDashboard = () => {
setIsCreateDialogOpen(true);
};
const handleCreate = async () => {
if (!dashboardName.trim()) {
toast.error("Please enter a dashboard name");
return;
}
setIsCreating(true);
try {
const result = await createDashboardAction({
environmentId,
name: dashboardName.trim(),
description: dashboardDescription.trim() || undefined,
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to create dashboard");
return;
}
toast.success("Dashboard created successfully!");
setIsCreateDialogOpen(false);
setDashboardName("");
setDashboardDescription("");
// Navigate to the new dashboard
router.push(`/environments/${environmentId}/analysis/dashboard/${result.data.id}`);
} catch (error: any) {
toast.error(error.message || "Failed to create dashboard");
} finally {
setIsCreating(false);
}
};
return (
<>
<Button onClick={handleCreateDashboard}>
<PlusIcon className="mr-2 h-4 w-4" />
Create Dashboard
</Button>
<CreateDashboardDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
dashboardName={dashboardName}
onDashboardNameChange={setDashboardName}
dashboardDescription={dashboardDescription}
onDashboardDescriptionChange={setDashboardDescription}
onCreate={handleCreate}
isCreating={isCreating}
/>
</>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
interface CreateDashboardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
dashboardName: string;
onDashboardNameChange: (name: string) => void;
dashboardDescription: string;
onDashboardDescriptionChange: (description: string) => void;
onCreate: () => void;
isCreating: boolean;
}
export function CreateDashboardDialog({
open,
onOpenChange,
dashboardName,
onDashboardNameChange,
dashboardDescription,
onDashboardDescriptionChange,
onCreate,
isCreating,
}: CreateDashboardDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create Dashboard</DialogTitle>
<DialogDescription>Enter a name for your dashboard to create it.</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4">
<div className="space-y-2">
<label htmlFor="dashboard-name" className="text-sm font-medium text-gray-900">
Dashboard Name
</label>
<Input
id="dashboard-name"
placeholder="Dashboard name"
value={dashboardName}
onChange={(e) => onDashboardNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && dashboardName.trim() && !isCreating) {
onCreate();
}
}}
autoFocus
/>
</div>
<div className="space-y-2">
<label htmlFor="dashboard-description" className="text-sm font-medium text-gray-900">
Description (Optional)
</label>
<Input
id="dashboard-description"
placeholder="Dashboard description"
value={dashboardDescription}
onChange={(e) => onDashboardDescriptionChange(e.target.value)}
/>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
Cancel
</Button>
<Button onClick={onCreate} loading={isCreating} disabled={!dashboardName.trim()}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { TDashboard } from "../../types/analysis";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
interface DashboardDropdownMenuProps {
environmentId: string;
dashboard: TDashboard;
disabled?: boolean;
deleteDashboard: (dashboardId: string) => void;
}
export const DashboardDropdownMenu = ({
environmentId,
dashboard,
disabled,
deleteDashboard,
}: DashboardDropdownMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const handleDeleteDashboard = async (dashboardId: string) => {
setLoading(true);
try {
// TODO: Implement deleteDashboardAction
// await deleteDashboardAction({ dashboardId });
deleteDashboard(dashboardId);
toast.success("Dashboard deleted successfully");
} catch (error) {
toast.error("Error deleting dashboard");
} finally {
setLoading(false);
}
};
return (
<div
id={`${dashboard.name.toLowerCase().split(" ").join("-")}-dashboard-actions`}
data-testid="dashboard-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
<div
className={cn(
"rounded-lg border bg-white p-2",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
)}
onClick={(e) => e.stopPropagation()}>
<span className="sr-only">Open options</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max">
<DropdownMenuGroup>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/analysis/dashboard/${dashboard.id}`}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
// TODO: Implement duplicate functionality
toast.success("Duplicate functionality coming soon");
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat="Dashboard"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteDashboard(dashboard.id)}
text="Are you sure you want to delete this dashboard? This action cannot be undone."
isDeleting={loading}
/>
</div>
);
};

View File

@@ -0,0 +1,97 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { BarChart3Icon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { TDashboard } from "../../types/analysis";
import { DashboardDropdownMenu } from "./DashboardDropdownMenu";
interface DashboardsListClientProps {
dashboards: TDashboard[];
environmentId: string;
}
export function DashboardsListClient({
dashboards: initialDashboards,
environmentId,
}: DashboardsListClientProps) {
const [searchQuery, setSearchQuery] = useState("");
const [dashboards, setDashboards] = useState(initialDashboards);
const filteredDashboards = dashboards.filter((dashboard) =>
dashboard.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const deleteDashboard = (dashboardId: string) => {
setDashboards(dashboards.filter((d) => d.id !== dashboardId));
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">Title</div>
<div className="col-span-1 hidden text-center sm:block">Charts</div>
<div className="col-span-1 hidden text-center sm:block">Created By</div>
<div className="col-span-1 hidden text-center sm:block">Created</div>
<div className="col-span-1 hidden text-center sm:block">Updated</div>
<div className="col-span-1"></div>
</div>
{filteredDashboards.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">No dashboards found.</p>
) : (
<>
{filteredDashboards.map((dashboard) => (
<Link
key={dashboard.id}
href={`dashboard/${dashboard.id}`}
className="grid h-12 w-full cursor-pointer grid-cols-8 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
<BarChart3Icon className="h-5 w-5" />
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{dashboard.name}</div>
{dashboard.description && (
<div className="ph-no-capture text-xs font-medium text-slate-500">
{dashboard.description}
</div>
)}
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{dashboard.chartCount}</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{dashboard.createdByName || "-"}</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{format(new Date(dashboard.createdAt), "do 'of' MMMM, yyyy")}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{formatDistanceToNow(new Date(dashboard.updatedAt), {
addSuffix: true,
}).replace("about", "")}
</div>
</div>
<div
className="col-span-1 my-auto flex items-center justify-end pr-6"
onClick={(e) => e.stopPropagation()}>
<DashboardDropdownMenu
environmentId={environmentId}
dashboard={dashboard}
deleteDashboard={deleteDashboard}
/>
</div>
</Link>
))}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { getDashboards } from "../lib/data";
import { DashboardsListClient } from "./components/DashboardsListClient";
export default async function DashboardsListPage({
params,
}: {
params: Promise<{ environmentId: string }>;
}) {
const { environmentId } = await params;
const dashboards = await getDashboards(environmentId);
return <DashboardsListClient dashboards={dashboards} environmentId={environmentId} />;
}

View File

@@ -0,0 +1,11 @@
import { AnalysisLayoutClient } from "./components/analysis-layout-client";
export default function AnalysisLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ environmentId: string }>;
}) {
return <AnalysisLayoutClient params={params}>{children}</AnalysisLayoutClient>;
}

View File

@@ -0,0 +1,178 @@
"use server";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getUser } from "@/lib/user/service";
import { TDashboard, TChart } from "../types/analysis";
/**
* Fetches all dashboards for the given environment
*/
export const getDashboards = reactCache(async (environmentId: string): Promise<TDashboard[]> => {
const { project } = await getEnvironmentAuth(environmentId);
const dashboards = await prisma.dashboard.findMany({
where: { projectId: project.id },
orderBy: { createdAt: "desc" },
include: {
widgets: {
select: {
id: true,
type: true,
title: true,
chartId: true,
layout: true,
},
},
},
});
// Fetch user names for createdBy fields
const userIds = [...new Set(dashboards.map((d) => d.createdBy).filter(Boolean) as string[])];
const users = await Promise.all(userIds.map((id) => getUser(id)));
const userMap = new Map(users.filter(Boolean).map((u) => [u!.id, u!.name]));
// Transform to match TDashboard type
return dashboards.map((dashboard) => {
const chartCount = dashboard.widgets.filter((widget) => widget.type === "chart").length;
const createdByName = dashboard.createdBy ? userMap.get(dashboard.createdBy) : undefined;
return {
id: dashboard.id,
name: dashboard.name,
description: dashboard.description || undefined,
status: dashboard.status,
owners: [], // TODO: Fetch owners if needed
lastModified: dashboard.updatedAt.toISOString(),
createdAt: dashboard.createdAt.toISOString(),
updatedAt: dashboard.updatedAt.toISOString(),
createdBy: dashboard.createdBy || undefined,
createdByName,
chartCount,
isFavorite: false, // TODO: Add favorite functionality if needed
widgets: dashboard.widgets.map((widget) => ({
id: widget.id,
type: widget.type as "chart" | "markdown" | "header" | "divider",
title: widget.title || undefined,
chartId: widget.chartId || undefined,
layout: widget.layout as { x: number; y: number; w: number; h: number },
})),
};
});
});
/**
* Fetches all charts for the given environment
*/
export const getCharts = reactCache(async (environmentId: string): Promise<TChart[]> => {
const { project } = await getEnvironmentAuth(environmentId);
const charts = await prisma.chart.findMany({
where: { projectId: project.id },
orderBy: { createdAt: "desc" },
include: {
widgets: {
select: {
dashboardId: true,
},
},
},
});
// Fetch user names for createdBy fields
const userIds = [...new Set(charts.map((c) => c.createdBy).filter(Boolean) as string[])];
const users = await Promise.all(userIds.map((id) => getUser(id)));
const userMap = new Map(users.filter(Boolean).map((u) => [u!.id, u!.name]));
// Transform to match TChart type
return charts.map((chart) => {
const createdByName = chart.createdBy ? userMap.get(chart.createdBy) : undefined;
return {
id: chart.id,
name: chart.name,
type: chart.type as TChart["type"],
dataset: "FeedbackRecords", // TODO: Make this dynamic if needed
owners: [], // TODO: Fetch owners if needed
lastModified: chart.updatedAt.toISOString(),
createdAt: chart.createdAt.toISOString(),
updatedAt: chart.updatedAt.toISOString(),
createdBy: chart.createdBy || undefined,
createdByName,
dashboardIds: chart.widgets.map((widget) => widget.dashboardId),
config: (chart.config as Record<string, any>) || {},
};
});
});
/**
* Fetches a single dashboard by ID
*/
export const getDashboard = reactCache(
async (environmentId: string, dashboardId: string): Promise<TDashboard | null> => {
const { project } = await getEnvironmentAuth(environmentId);
const dashboard = await prisma.dashboard.findFirst({
where: {
id: dashboardId,
projectId: project.id,
},
include: {
widgets: {
include: {
chart: {
select: {
id: true,
name: true,
type: true,
query: true,
config: true,
},
},
},
orderBy: {
order: "asc",
},
},
},
});
if (!dashboard) {
return null;
}
const chartCount = dashboard.widgets.filter((widget) => widget.type === "chart").length;
return {
id: dashboard.id,
name: dashboard.name,
description: dashboard.description || undefined,
status: dashboard.status,
owners: [], // TODO: Fetch owners if needed
lastModified: dashboard.updatedAt.toISOString(),
createdAt: dashboard.createdAt.toISOString(),
updatedAt: dashboard.updatedAt.toISOString(),
createdBy: dashboard.createdBy || undefined,
createdByName: undefined, // Will be fetched if needed
chartCount,
isFavorite: false, // TODO: Add favorite functionality if needed
widgets: dashboard.widgets.map((widget) => ({
id: widget.id,
type: widget.type as "chart" | "markdown" | "header" | "divider",
title: widget.title || undefined,
chartId: widget.chartId || undefined,
layout: widget.layout as { x: number; y: number; w: number; h: number },
chart: widget.chart
? {
id: widget.chart.id,
name: widget.chart.name,
type: widget.chart.type as TChart["type"],
query: widget.chart.query as Record<string, any>,
config: (widget.chart.config as Record<string, any>) || {},
}
: undefined,
})),
};
}
);

View File

@@ -0,0 +1,9 @@
import { redirect } from "next/navigation";
export default async function AnalysisPage({ params }: { params: Promise<{ environmentId: string }> }) {
const { environmentId } = await params;
if (!environmentId || environmentId === "undefined") {
redirect("/");
}
redirect(`/environments/${environmentId}/analysis/dashboards`);
}

View File

@@ -0,0 +1,84 @@
export type TDashboardStatus = "published" | "draft";
export interface TAnalysisUser {
id: string;
name: string;
}
export interface TDashboard {
id: string;
name: string;
description?: string;
status: TDashboardStatus;
owners: TAnalysisUser[];
lastModified: string; // ISO Date string
createdAt: string; // ISO Date string
updatedAt: string; // ISO Date string
createdBy?: string; // User ID
createdByName?: string; // User name for display
chartCount: number;
isFavorite: boolean;
widgets: TDashboardWidget[];
}
export interface TDashboardWidget {
id: string;
type: "chart" | "markdown" | "header" | "divider";
title?: string;
chartId?: string; // If type is chart
layout: {
x: number;
y: number;
w: number;
h: number;
};
chart?: {
id: string;
name: string;
type: TChartType;
query: Record<string, any>;
config: Record<string, any>;
};
}
export type TChartType =
| "area"
| "bar"
| "line"
| "pie"
| "big_number"
| "big_number_total"
| "table"
| "funnel"
| "map";
export interface TChart {
id: string;
name: string;
type: TChartType;
dataset: string;
owners: TAnalysisUser[];
lastModified: string;
createdAt: string; // ISO Date string
updatedAt: string; // ISO Date string
createdBy?: string; // User ID
createdByName?: string; // User name for display
dashboardIds: string[];
config: Record<string, any>; // Flexible config for specific chart props
}
export interface TAnalysisState {
dashboards: TDashboard[];
charts: TChart[];
activeDashboard: TDashboard | null;
layoutMode: "view" | "edit";
isLoading: boolean;
// Actions
setDashboards: (dashboards: TDashboard[]) => void;
setCharts: (charts: TChart[]) => void;
setActiveDashboard: (dashboard: TDashboard | null) => void;
setLayoutMode: (mode: "view" | "edit") => void;
addDashboard: (dashboard: TDashboard) => void;
updateDashboard: (id: string, updates: Partial<TDashboard>) => void;
}

View File

@@ -0,0 +1 @@
export * from "./analysis";

View File

@@ -2,13 +2,16 @@
import {
ArrowUpRightIcon,
BarChartIcon,
ChevronRightIcon,
Cog,
LogOutIcon,
MessageCircle,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PieChart,
RocketIcon,
ShapesIcon,
UserCircleIcon,
UserIcon,
} from "lucide-react";
@@ -99,20 +102,33 @@ export const MainNavigation = ({
const mainNavigation = useMemo(
() => [
{
name: t("common.surveys"),
name: "Ask",
href: `/environments/${environment.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
},
{
name: t("common.analysis"),
href: `/environments/${environment.id}/analysis`,
icon: PieChart,
isActive: pathname?.includes("/analysis"),
isHidden: false,
},
{
href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"),
name: "Distribute",
icon: UserIcon,
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
},
{
name: t("common.configuration"),
name: "Unify",
href: `/environments/${environment.id}/workspace/unify`,
icon: ShapesIcon,
isActive: pathname?.includes("/unify") && !pathname?.includes("/analyze"),
},
{
name: "Configure",
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/project"),
@@ -185,7 +201,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

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

@@ -133,6 +133,11 @@ export const ProjectBreadcrumb = ({
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
{
id: "unify",
label: "Unify Feedback",
href: `/environments/${currentEnvironmentId}/workspace/unify`,
},
];
if (!currentProject) {
@@ -234,7 +239,7 @@ export const ProjectBreadcrumb = ({
)}
{!isLoadingProjects && !loadError && (
<>
<DropdownMenuGroup>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}

View File

@@ -11,6 +11,12 @@ const EnvLayout = async (props: {
children: React.ReactNode;
}) => {
const params = await props.params;
const { environmentId } = params;
if (environmentId === "undefined") {
return redirect("/");
}
const { children } = props;
// Check session first (required for userId)

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

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

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

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

@@ -0,0 +1,39 @@
"use client";
import { usePathname } from "next/navigation";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface UnifyConfigNavigationProps {
environmentId: string;
activeId?: string;
loading?: boolean;
}
export const UnifyConfigNavigation = ({
environmentId,
activeId: activeIdProp,
loading,
}: UnifyConfigNavigationProps) => {
const pathname = usePathname();
const activeId =
activeIdProp ??
(pathname?.includes("/unify/sources")
? "sources"
: pathname?.includes("/unify/knowledge")
? "knowledge"
: pathname?.includes("/unify/taxonomy")
? "taxonomy"
: "controls");
const baseHref = `/environments/${environmentId}/workspace/unify`;
const navigation = [
{ id: "controls", label: "Controls", href: `${baseHref}/controls` },
{ id: "sources", label: "Sources", href: `${baseHref}/sources` },
{ id: "knowledge", label: "Knowledge", href: `${baseHref}/knowledge` },
{ id: "taxonomy", label: "Taxonomy", href: `${baseHref}/taxonomy` },
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};

View File

@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { Badge } from "@/modules/ui/components/badge";
import { Label } from "@/modules/ui/components/label";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
// Common languages for the base language selector
const COMMON_LANGUAGES = [
{ code: "en", label: "English" },
{ code: "de", label: "German" },
{ code: "fr", label: "French" },
{ code: "es", label: "Spanish" },
{ code: "pt", label: "Portuguese" },
{ code: "it", label: "Italian" },
{ code: "nl", label: "Dutch" },
{ code: "pl", label: "Polish" },
{ code: "ru", label: "Russian" },
{ code: "ja", label: "Japanese" },
{ code: "ko", label: "Korean" },
{ code: "zh-Hans", label: "Chinese (Simplified)" },
{ code: "zh-Hant", label: "Chinese (Traditional)" },
{ code: "ar", label: "Arabic" },
{ code: "hi", label: "Hindi" },
{ code: "tr", label: "Turkish" },
{ code: "sv", label: "Swedish" },
{ code: "no", label: "Norwegian" },
{ code: "da", label: "Danish" },
{ code: "fi", label: "Finnish" },
];
interface ControlsSectionProps {
environmentId: string;
}
export function ControlsSection({ environmentId }: ControlsSectionProps) {
const [baseLanguage, setBaseLanguage] = useState("en");
return (
<PageContentWrapper>
<PageHeader pageTitle="Unify Feedback">
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="max-w-4xl">
<SettingsCard
title="Feedback Controls"
description="Configure how feedback is processed and consolidated across all sources.">
<div className="space-y-6">
{/* Base Language Setting */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="baseLanguage">Base Language</Label>
<Badge text="AI" type="gray" size="tiny" />
</div>
<p className="text-sm text-slate-500">
All feedback will be consolidated and analyzed in this language. Feedback in other languages
will be automatically translated.
</p>
<div className="w-64">
<Select value={baseLanguage} onValueChange={setBaseLanguage}>
<SelectTrigger id="baseLanguage">
<SelectValue placeholder="Select a language" />
</SelectTrigger>
<SelectContent>
{COMMON_LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</SettingsCard>
</div>
</PageContentWrapper>
);
}

View File

@@ -0,0 +1 @@
export { ControlsSection } from "./ControlsSection";

View File

@@ -0,0 +1,10 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ControlsSection } from "./components";
export default async function UnifyControlsPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
return <ControlsSection environmentId={params.environmentId} />;
}

View File

@@ -0,0 +1,256 @@
"use client";
import { FileTextIcon, LinkIcon, PlusIcon, StickyNoteIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import type { TAllowedFileExtension } from "@formbricks/types/storage";
import { cn } from "@/lib/cn";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import type { KnowledgeItem } from "../types";
const DOC_EXTENSIONS: TAllowedFileExtension[] = ["pdf", "doc", "docx", "txt", "csv"];
const MAX_DOC_SIZE_MB = 5;
interface AddKnowledgeModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (item: KnowledgeItem) => void;
environmentId: string;
isStorageConfigured: boolean;
}
export function AddKnowledgeModal({
open,
onOpenChange,
onAdd,
environmentId,
isStorageConfigured,
}: AddKnowledgeModalProps) {
const [linkUrl, setLinkUrl] = useState("");
const [linkTitle, setLinkTitle] = useState("");
const [noteContent, setNoteContent] = useState("");
const [uploadedDocUrl, setUploadedDocUrl] = useState<string | null>(null);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const resetForm = () => {
setLinkUrl("");
setLinkTitle("");
setNoteContent("");
setUploadedDocUrl(null);
setUploadedFileName(null);
};
const handleDocUpload = async (files: File[]) => {
if (!isStorageConfigured) {
toast.error("File storage is not configured.");
return;
}
const file = files[0];
if (!file) return;
setIsUploading(true);
setUploadedDocUrl(null);
setUploadedFileName(null);
const result = await handleFileUpload(file, environmentId, DOC_EXTENSIONS);
setIsUploading(false);
if (result.error) {
toast.error("Upload failed. Please try again.");
return;
}
setUploadedDocUrl(result.url);
setUploadedFileName(file.name);
toast.success("Document uploaded. Click Add to save.");
};
const handleAddLink = () => {
if (!linkUrl.trim()) {
toast.error("Please enter a URL.");
return;
}
const now = new Date();
onAdd({
id: crypto.randomUUID(),
type: "link",
title: linkTitle.trim() || undefined,
url: linkUrl.trim(),
size: linkUrl.trim().length * 100, // Simulated size for links
createdAt: now,
indexedAt: now, // Links are indexed immediately
});
resetForm();
onOpenChange(false);
toast.success("Link added.");
};
const handleAddNote = () => {
if (!noteContent.trim()) {
toast.error("Please enter some text.");
return;
}
const now = new Date();
onAdd({
id: crypto.randomUUID(),
type: "note",
content: noteContent.trim(),
size: new Blob([noteContent.trim()]).size,
createdAt: now,
indexedAt: now, // Notes are indexed immediately
});
resetForm();
onOpenChange(false);
toast.success("Note added.");
};
const handleAddFile = () => {
if (!uploadedDocUrl) {
toast.error("Please upload a document first.");
return;
}
const now = new Date();
onAdd({
id: crypto.randomUUID(),
type: "file",
title: uploadedFileName ?? undefined,
fileUrl: uploadedDocUrl,
fileName: uploadedFileName ?? undefined,
size: Math.floor(Math.random() * 500000) + 10000, // Simulated file size (10KB - 500KB)
createdAt: now,
indexedAt: undefined, // Files take time to index - will show as "Pending"
});
resetForm();
onOpenChange(false);
toast.success("Document added.");
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) handleDocUpload(files);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
};
return (
<>
<Button onClick={() => onOpenChange(true)} size="sm">
Add knowledge
<PlusIcon className="ml-2 size-4" />
</Button>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) resetForm();
onOpenChange(o);
}}>
<DialogContent className="sm:max-w-lg" disableCloseOnOutsideClick>
<DialogHeader>
<PlusIcon className="size-5 text-slate-600" />
<DialogTitle>Add knowledge</DialogTitle>
<DialogDescription>Add knowledge via a link, document upload, or a text note.</DialogDescription>
</DialogHeader>
<DialogBody>
<Tabs defaultValue="link" className="w-full">
<TabsList width="fill" className="mb-4 w-full">
<TabsTrigger value="link" icon={<LinkIcon className="size-4" />}>
Link
</TabsTrigger>
<TabsTrigger value="upload" icon={<FileTextIcon className="size-4" />}>
Upload doc
</TabsTrigger>
<TabsTrigger value="note" icon={<StickyNoteIcon className="size-4" />}>
Note
</TabsTrigger>
</TabsList>
<TabsContent value="link" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="link-title">Title (optional)</Label>
<Input
id="link-title"
placeholder="e.g. Product docs"
value={linkTitle}
onChange={(e) => setLinkTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="link-url">URL</Label>
<Input
id="link-url"
type="url"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
/>
</div>
<Button type="button" onClick={handleAddLink} size="sm">
Add link
</Button>
</TabsContent>
<TabsContent value="upload" className="space-y-4">
<Uploader
id="knowledge-doc-modal"
name="knowledge-doc-modal"
uploaderClassName="h-32 w-full"
allowedFileExtensions={DOC_EXTENSIONS}
multiple={false}
handleUpload={handleDocUpload}
handleDrop={handleDrop}
handleDragOver={handleDragOver}
isStorageConfigured={isStorageConfigured}
/>
<p className="text-xs text-slate-500">PDF, Word, text, or CSV. Max {MAX_DOC_SIZE_MB} MB.</p>
{isUploading && <p className="text-sm text-slate-600">Uploading</p>}
{uploadedDocUrl && (
<p className="text-sm text-slate-700">
Ready: <span className="font-medium">{uploadedFileName ?? uploadedDocUrl}</span>
</p>
)}
<Button type="button" onClick={handleAddFile} size="sm" disabled={!uploadedDocUrl}>
Add document
</Button>
</TabsContent>
<TabsContent value="note" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="knowledge-note-modal">Note</Label>
<textarea
id="knowledge-note-modal"
rows={5}
placeholder="Paste or type knowledge content here..."
className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
)}
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
/>
</div>
<Button type="button" onClick={handleAddNote} size="sm">
Add note
</Button>
</TabsContent>
</Tabs>
</DialogBody>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import type { KnowledgeItem } from "../types";
import { AddKnowledgeModal } from "./AddKnowledgeModal";
import { KnowledgeTable } from "./KnowledgeTable";
interface KnowledgeSectionProps {
environmentId: string;
isStorageConfigured: boolean;
}
export function KnowledgeSection({ environmentId, isStorageConfigured }: KnowledgeSectionProps) {
const [items, setItems] = useState<KnowledgeItem[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const handleDeleteItem = (itemId: string) => {
setItems((prev) => prev.filter((item) => item.id !== itemId));
};
return (
<PageContentWrapper>
<PageHeader
pageTitle="Unify Feedback"
cta={
<AddKnowledgeModal
open={modalOpen}
onOpenChange={setModalOpen}
onAdd={(item) => {
setItems((prev) => [...prev, item]);
setModalOpen(false);
}}
environmentId={environmentId}
isStorageConfigured={isStorageConfigured}
/>
}>
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="space-y-6">
<KnowledgeTable items={items} onDeleteItem={handleDeleteItem} />
</div>
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { FileTextIcon, LinkIcon, MoreHorizontalIcon, StickyNoteIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { type KnowledgeItem, formatFileSize } from "../types";
interface KnowledgeTableProps {
items: KnowledgeItem[];
onDeleteItem?: (itemId: string) => void;
}
function getTypeIcon(type: KnowledgeItem["type"]) {
switch (type) {
case "link":
return <LinkIcon className="size-4 text-slate-500" />;
case "file":
return <FileTextIcon className="size-4 text-slate-500" />;
case "note":
return <StickyNoteIcon className="size-4 text-slate-500" />;
default:
return <FileTextIcon className="size-4 text-slate-500" />;
}
}
function getTypeLabel(type: KnowledgeItem["type"]) {
switch (type) {
case "link":
return "Link";
case "file":
return "Document";
case "note":
return "Note";
default:
return type;
}
}
function getTitleOrPreview(item: KnowledgeItem): string {
if (item.title) return item.title;
if (item.type === "link" && item.url) return item.url;
if (item.type === "file" && item.fileName) return item.fileName;
if (item.type === "note" && item.content) {
return item.content.length > 60 ? `${item.content.slice(0, 60)}` : item.content;
}
return "—";
}
export function KnowledgeTable({ items, onDeleteItem }: KnowledgeTableProps) {
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-5 pl-6">Name</div>
<div className="col-span-2 hidden text-center sm:block">Type</div>
<div className="col-span-2 hidden text-center sm:block">Size</div>
<div className="col-span-2 hidden text-center sm:block">Indexed At</div>
<div className="col-span-1 pr-6 text-right">Actions</div>
</div>
{items.length === 0 ? (
<p className="py-12 text-center text-sm text-slate-400">
No knowledge yet. Add a link, upload a document, or add a note.
</p>
) : (
<div className="divide-y divide-slate-100">
{items.map((item) => (
<div
key={item.id}
className="grid h-12 min-h-12 grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50">
{/* Name */}
<div className="col-span-5 flex items-center gap-3 pl-6">
{getTypeIcon(item.type)}
<div className="flex flex-col overflow-hidden">
<div className="truncate text-sm font-medium text-slate-900">{getTitleOrPreview(item)}</div>
{item.type === "link" && item.url && item.title && (
<div className="truncate text-xs text-slate-500">{item.url}</div>
)}
</div>
</div>
{/* Type */}
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-600 sm:flex">
{getTypeLabel(item.type)}
</div>
{/* Size */}
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{formatFileSize(item.size)}
</div>
{/* Indexed At */}
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{item.indexedAt ? (
<span title={format(item.indexedAt, "PPpp")}>
{formatDistanceToNow(item.indexedAt, { addSuffix: true }).replace("about ", "")}
</span>
) : (
<span className="text-slate-400">Pending</span>
)}
</div>
{/* Actions */}
<div className="col-span-1 flex items-center justify-end pr-6">
<DropdownMenu
open={openMenuId === item.id}
onOpenChange={(open) => setOpenMenuId(open ? item.id : null)}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-red-600 focus:bg-red-50 focus:text-red-700"
onClick={() => {
onDeleteItem?.(item.id);
setOpenMenuId(null);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { KnowledgeSection } from "./components/KnowledgeSection";
export default async function UnifyKnowledgePage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
return (
<KnowledgeSection
environmentId={params.environmentId}
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
);
}

View File

@@ -0,0 +1,22 @@
export type KnowledgeItemType = "link" | "note" | "file";
export interface KnowledgeItem {
id: string;
type: KnowledgeItemType;
title?: string;
url?: string;
content?: string;
fileUrl?: string;
fileName?: string;
size?: number; // Size in bytes
createdAt: Date;
indexedAt?: Date;
}
// Format file size to human readable string
export function formatFileSize(bytes?: number): string {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default async function UnifyPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
redirect(`/environments/${params.environmentId}/workspace/unify/controls`);
}

View File

@@ -0,0 +1,160 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getSurveys } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { TUnifySurvey, TUnifySurveyElement } from "./components/types";
// Helper to extract elements from a survey (from blocks or legacy questions)
function extractElementsFromSurvey(survey: TSurvey): TUnifySurveyElement[] {
const elements: TUnifySurveyElement[] = [];
// Try to get elements from blocks first (new structure)
if (survey.blocks && survey.blocks.length > 0) {
for (const block of survey.blocks) {
if (block.elements) {
for (const element of block.elements) {
// Skip non-question elements (like CTA)
if (element.type === TSurveyElementTypeEnum.CTA) continue;
elements.push({
id: element.id,
type: element.type,
headline: getElementHeadline(element),
required: element.required ?? false,
});
}
}
}
}
// Fallback to legacy questions if no blocks
if (elements.length === 0 && survey.questions && Array.isArray(survey.questions)) {
for (const question of survey.questions as Array<{
id: string;
type: string;
headline?: string | { default?: string };
required?: boolean;
}>) {
elements.push({
id: question.id,
type: question.type,
headline: getQuestionHeadline(question),
required: question.required ?? false,
});
}
}
return elements;
}
// Helper to strip HTML tags from a string
function stripHtmlTags(html: string): string {
// Remove HTML tags and decode common entities
return html
.replace(/<[^>]*>/g, "") // Remove HTML tags
.replace(/&nbsp;/g, " ") // Replace non-breaking spaces
.replace(/&amp;/g, "&") // Replace ampersands
.replace(/&lt;/g, "<") // Replace less than
.replace(/&gt;/g, ">") // Replace greater than
.replace(/&quot;/g, '"') // Replace quotes
.replace(/&#39;/g, "'") // Replace apostrophes
.trim();
}
// Helper to get element headline (handles i18n structure)
function getElementHeadline(element: { headline?: string | { default?: string } }): string {
if (!element.headline) return "Untitled";
let headline: string;
if (typeof element.headline === "string") {
headline = element.headline;
} else if (typeof element.headline === "object" && element.headline.default) {
headline = element.headline.default;
} else {
return "Untitled";
}
// Strip HTML tags if present
return stripHtmlTags(headline);
}
// Helper to get question headline (handles i18n structure)
function getQuestionHeadline(question: { headline?: string | { default?: string } }): string {
if (!question.headline) return "Untitled";
let headline: string;
if (typeof question.headline === "string") {
headline = question.headline;
} else if (typeof question.headline === "object" && question.headline.default) {
headline = question.headline.default;
} else {
return "Untitled";
}
// Strip HTML tags if present
return stripHtmlTags(headline);
}
// Map survey status
function mapSurveyStatus(status: string): TUnifySurvey["status"] {
switch (status) {
case "inProgress":
return "active";
case "paused":
return "paused";
case "draft":
return "draft";
case "completed":
return "completed";
default:
return "draft";
}
}
// Transform TSurvey to TUnifySurvey for the UI
function transformToUnifySurvey(survey: TSurvey, responseCount: number): TUnifySurvey {
return {
id: survey.id,
name: survey.name,
status: mapSurveyStatus(survey.status),
responseCount,
elements: extractElementsFromSurvey(survey),
createdAt: survey.createdAt,
};
}
// Get surveys for environment action
const ZGetSurveysForUnifyAction = z.object({
environmentId: ZId,
});
export const getSurveysForUnifyAction = authenticatedActionClient
.schema(ZGetSurveysForUnifyAction)
.action(async ({ ctx, parsedInput }): Promise<TUnifySurvey[]> => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "member"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
// Get surveys from the database
const surveys = await getSurveys(parsedInput.environmentId);
// Transform to TUnifySurvey format
// Note: We don't have response counts readily available, so using 0 for now
// In a production implementation, we'd fetch response counts separately
return surveys.map((survey) => transformToUnifySurvey(survey, 0));
});

View File

@@ -0,0 +1,617 @@
"use client";
import { CheckIcon, CopyIcon, PlusIcon, SparklesIcon, WebhookIcon } from "lucide-react";
import { nanoid } from "nanoid";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { CsvSourceUI } from "./csv-source-ui";
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
import { MappingUI } from "./mapping-ui";
import { SourceTypeSelector } from "./source-type-selector";
import {
AI_SUGGESTED_MAPPINGS,
EMAIL_SOURCE_FIELDS,
FEEDBACK_RECORD_FIELDS,
SAMPLE_CSV_COLUMNS,
SAMPLE_WEBHOOK_PAYLOAD,
TCreateSourceStep,
TFieldMapping,
TSourceConnection,
TSourceField,
TSourceType,
TUnifySurvey,
parseCSVColumnsToFields,
parsePayloadToFields,
} from "./types";
// Polling interval in milliseconds (3 seconds)
const WEBHOOK_POLL_INTERVAL = 3000;
// Sample webhook payload for cURL example
const SAMPLE_CURL_PAYLOAD = {
timestamp: new Date().toISOString(),
source_type: "webhook",
field_id: "satisfaction_score",
field_type: "rating",
value_number: 4,
user_id: "user_123",
metadata: {
source: "api",
},
};
interface CreateSourceModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateSource: (
source: TSourceConnection,
selectedSurveyId?: string,
selectedElementIds?: string[]
) => void;
surveys: TUnifySurvey[];
}
function getDefaultSourceName(type: TSourceType): string {
switch (type) {
case "formbricks":
return "Formbricks Survey Connection";
case "webhook":
return "Webhook Connection";
case "email":
return "Email Connection";
case "csv":
return "CSV Import";
case "slack":
return "Slack Connection";
default:
return "New Source";
}
}
export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys }: CreateSourceModalProps) {
const [currentStep, setCurrentStep] = useState<TCreateSourceStep>("selectType");
const [selectedType, setSelectedType] = useState<TSourceType | null>(null);
const [sourceName, setSourceName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [deriveFromAttachments, setDeriveFromAttachments] = useState(false);
// Formbricks-specific state
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
// Webhook listener state
const [webhookSessionId, setWebhookSessionId] = useState<string | null>(null);
const [isListening, setIsListening] = useState(false);
const [webhookReceived, setWebhookReceived] = useState(false);
const [copied, setCopied] = useState(false);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Generate webhook URL
const webhookUrl = webhookSessionId
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/unify/webhook-listener/${webhookSessionId}`
: "";
// Poll for webhook payload
const pollForWebhook = useCallback(async () => {
if (!webhookSessionId) return;
try {
const response = await fetch(`/api/unify/webhook-listener/${webhookSessionId}`);
if (response.status === 200) {
const data = await response.json();
if (data.payload) {
// Parse the received payload into source fields
const fields = parsePayloadToFields(data.payload);
setSourceFields(fields);
setWebhookReceived(true);
setIsListening(false);
toast.success("Webhook received! Fields loaded.");
// Stop polling
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}
}
// 204 means no payload yet, keep polling
} catch (error) {
console.error("Error polling for webhook:", error);
}
}, [webhookSessionId]);
// Start/stop polling based on listening state
useEffect(() => {
if (isListening && webhookSessionId) {
// Start polling
pollingIntervalRef.current = setInterval(pollForWebhook, WEBHOOK_POLL_INTERVAL);
// Also poll immediately
pollForWebhook();
}
return () => {
// Cleanup polling on unmount or when listening stops
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [isListening, webhookSessionId, pollForWebhook]);
// Generate session ID when webhook type is selected and modal opens
useEffect(() => {
if (open && selectedType === "webhook" && currentStep === "mapping" && !webhookSessionId) {
setWebhookSessionId(nanoid(21));
setIsListening(true);
}
}, [open, selectedType, currentStep, webhookSessionId]);
// Copy cURL command to clipboard
const handleCopyWebhookUrl = async () => {
if (!webhookUrl) return;
const curlCommand = `curl -X POST \\
"${webhookUrl}" \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(SAMPLE_CURL_PAYLOAD, null, 2)}'`;
try {
await navigator.clipboard.writeText(curlCommand);
setCopied(true);
toast.success("cURL command copied to clipboard");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy");
}
};
const resetForm = () => {
setCurrentStep("selectType");
setSelectedType(null);
setSourceName("");
setMappings([]);
setSourceFields([]);
setDeriveFromAttachments(false);
setSelectedSurveyId(null);
setSelectedElementIds([]);
// Reset webhook state
setWebhookSessionId(null);
setIsListening(false);
setWebhookReceived(false);
setCopied(false);
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
const handleNextStep = () => {
if (currentStep === "selectType" && selectedType && selectedType !== "slack") {
if (selectedType === "formbricks") {
// For Formbricks, use the survey name if selected
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
setSourceName(
selectedSurvey ? `${selectedSurvey.name} Connection` : getDefaultSourceName(selectedType)
);
} else {
setSourceName(getDefaultSourceName(selectedType));
}
setCurrentStep("mapping");
}
};
// Formbricks handlers
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleElementToggle = (elementId: string) => {
setSelectedElementIds((prev) =>
prev.includes(elementId) ? prev.filter((id) => id !== elementId) : [...prev, elementId]
);
};
const handleSelectAllElements = (surveyId: string) => {
const survey = surveys.find((s) => s.id === surveyId);
if (survey) {
setSelectedElementIds(survey.elements.map((e) => e.id));
}
};
const handleDeselectAllElements = () => {
setSelectedElementIds([]);
};
const handleBack = () => {
if (currentStep === "mapping") {
setCurrentStep("selectType");
setMappings([]);
setSourceFields([]);
}
};
const handleCreateSource = () => {
if (!selectedType || !sourceName.trim()) return;
// Check if all required fields are mapped (for non-Formbricks connectors)
if (selectedType !== "formbricks") {
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const allRequiredMapped = requiredFields.every((field) =>
mappings.some((m) => m.targetFieldId === field.id)
);
if (!allRequiredMapped) {
// For now, we'll allow creating without all required fields for POC
console.warn("Not all required fields are mapped");
}
}
const newSource: TSourceConnection = {
id: crypto.randomUUID(),
name: sourceName.trim(),
type: selectedType,
mappings,
createdAt: new Date(),
updatedAt: new Date(),
};
// Pass the Formbricks-specific data if applicable
onCreateSource(
newSource,
selectedType === "formbricks" ? (selectedSurveyId ?? undefined) : undefined,
selectedType === "formbricks" ? selectedElementIds : undefined
);
resetForm();
onOpenChange(false);
};
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const allRequiredMapped = requiredFields.every((field) =>
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
);
// Formbricks validation - need survey and at least one element selected
const isFormbricksValid =
selectedType === "formbricks" && selectedSurveyId && selectedElementIds.length > 0;
// CSV validation - need sourceFields loaded (CSV uploaded or sample loaded)
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
const handleLoadSourceFields = () => {
if (!selectedType) return;
let fields: TSourceField[];
if (selectedType === "webhook") {
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
} else if (selectedType === "email") {
fields = EMAIL_SOURCE_FIELDS;
} else if (selectedType === "csv") {
fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
} else {
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
}
setSourceFields(fields);
};
const handleSuggestMapping = () => {
if (!selectedType) return;
const suggestions = AI_SUGGESTED_MAPPINGS[selectedType];
if (!suggestions) return;
const newMappings: TFieldMapping[] = [];
// Add field mappings from source fields
for (const sourceField of sourceFields) {
const suggestedTarget = suggestions.fieldMappings[sourceField.id];
if (suggestedTarget) {
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === suggestedTarget);
if (targetExists) {
newMappings.push({
sourceFieldId: sourceField.id,
targetFieldId: suggestedTarget,
});
}
}
}
// Add static value mappings
for (const [targetFieldId, staticValue] of Object.entries(suggestions.staticValues)) {
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === targetFieldId);
if (targetExists) {
if (!newMappings.some((m) => m.targetFieldId === targetFieldId)) {
newMappings.push({
targetFieldId,
staticValue,
});
}
}
}
setMappings(newMappings);
};
const getLoadButtonLabel = () => {
switch (selectedType) {
case "webhook":
return "Simulate webhook";
case "email":
return "Load email fields";
case "csv":
return "Load sample CSV";
default:
return "Load sample";
}
};
return (
<>
<Button onClick={() => onOpenChange(true)} size="sm">
Add source
<PlusIcon className="ml-2 h-4 w-4" />
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
{currentStep === "selectType"
? "Add Feedback Source"
: selectedType === "formbricks"
? "Select Survey & Questions"
: selectedType === "csv"
? "Import CSV Data"
: "Configure Mapping"}
</DialogTitle>
<DialogDescription>
{currentStep === "selectType"
? "Select the type of feedback source you want to connect."
: selectedType === "formbricks"
? "Choose which survey questions should create FeedbackRecords."
: selectedType === "csv"
? "Upload a CSV file or set up automated S3 imports."
: "Map source fields to Hub Feedback Record fields."}
</DialogDescription>
</DialogHeader>
<div className="py-4">
{currentStep === "selectType" ? (
<SourceTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
) : selectedType === "formbricks" ? (
/* Formbricks Survey Selector UI */
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sourceName">Source Name</Label>
<Input
id="sourceName"
value={sourceName}
onChange={(e) => setSourceName(e.target.value)}
placeholder="Enter a name for this source"
/>
</div>
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
surveys={surveys}
selectedSurveyId={selectedSurveyId}
selectedElementIds={selectedElementIds}
onSurveySelect={handleSurveySelect}
onElementToggle={handleElementToggle}
onSelectAllElements={handleSelectAllElements}
onDeselectAllElements={handleDeselectAllElements}
/>
</div>
</div>
) : selectedType === "csv" ? (
/* CSV Upload & S3 Integration UI */
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sourceName">Source Name</Label>
<Input
id="sourceName"
value={sourceName}
onChange={(e) => setSourceName(e.target.value)}
placeholder="Enter a name for this source"
/>
</div>
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<CsvSourceUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
onSourceFieldsChange={setSourceFields}
onLoadSampleCSV={handleLoadSourceFields}
/>
</div>
</div>
) : (
/* Other source types (webhook, email) - Mapping UI */
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sourceName">Source Name</Label>
<Input
id="sourceName"
value={sourceName}
onChange={(e) => setSourceName(e.target.value)}
placeholder="Enter a name for this source"
/>
</div>
{/* Webhook Listener UI */}
{selectedType === "webhook" && !webhookReceived && (
<div className="space-y-6">
{/* Centered waiting indicator */}
<div className="flex flex-col items-center justify-center rounded-lg border border-slate-200 bg-slate-50 py-12">
<span className="relative mb-4 flex h-16 w-16">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-slate-300 opacity-75"></span>
<span className="relative inline-flex h-16 w-16 items-center justify-center rounded-full bg-slate-200">
<WebhookIcon className="h-8 w-8 text-slate-600" />
</span>
</span>
<p className="text-lg font-medium text-slate-700">Waiting for webhook...</p>
<p className="mt-1 text-sm text-slate-500">Send a request to the URL below</p>
</div>
{/* cURL example at bottom */}
<div className="space-y-2">
<Label className="text-sm font-medium text-slate-700">Test with cURL</Label>
<div className="relative">
<pre className="overflow-auto rounded-lg border border-slate-300 bg-slate-900 p-3 text-xs text-slate-100">
<code>{`curl -X POST "${webhookUrl || "..."}" \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(SAMPLE_CURL_PAYLOAD, null, 2)}'`}</code>
</pre>
<Button
variant="secondary"
size="sm"
onClick={handleCopyWebhookUrl}
disabled={!webhookUrl}
className="absolute right-2 top-2">
{copied ? (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Copied
</>
) : (
<>
<CopyIcon className="mr-1 h-3 w-3" />
Copy
</>
)}
</Button>
</div>
</div>
</div>
)}
{/* Webhook received - show success + mapping UI */}
{selectedType === "webhook" && webhookReceived && (
<div className="space-y-4">
{/* Success indicator */}
<div className="flex flex-col items-center justify-center rounded-lg border border-green-200 bg-green-50 py-6">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-500">
<CheckIcon className="h-6 w-6 text-white" />
</div>
<p className="text-lg font-medium text-green-700">Webhook received!</p>
<p className="mt-1 text-sm text-green-600">
{sourceFields.length} fields detected. Map them below.
</p>
</div>
{/* AI suggest mapping button */}
{sourceFields.length > 0 && (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
<SparklesIcon className="h-4 w-4 text-purple-500" />
Suggest mapping
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
</Button>
</div>
)}
{/* Mapping UI */}
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
sourceType={selectedType}
deriveFromAttachments={deriveFromAttachments}
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
/>
</div>
</div>
)}
{/* Non-webhook types */}
{selectedType !== "webhook" && (
<>
{/* Action buttons */}
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleLoadSourceFields}>
{getLoadButtonLabel()}
</Button>
{sourceFields.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleSuggestMapping}
className="gap-2">
<SparklesIcon className="h-4 w-4 text-purple-500" />
Suggest mapping
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
</Button>
)}
</div>
</div>
{/* Mapping UI */}
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
sourceType={selectedType!}
deriveFromAttachments={deriveFromAttachments}
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
/>
</div>
</>
)}
</div>
)}
</div>
<DialogFooter>
{currentStep === "mapping" && (
<Button variant="outline" onClick={handleBack}>
Back
</Button>
)}
{currentStep === "selectType" ? (
<Button onClick={handleNextStep} disabled={!selectedType || selectedType === "slack"}>
{selectedType === "formbricks"
? "Select questions"
: selectedType === "csv"
? "Configure import"
: "Create mapping"}
</Button>
) : (
<Button
onClick={handleCreateSource}
disabled={
!sourceName.trim() ||
(selectedType === "formbricks"
? !isFormbricksValid
: selectedType === "csv"
? !isCsvValid
: !allRequiredMapped)
}>
Setup connection
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,327 @@
"use client";
import {
ArrowUpFromLineIcon,
CloudIcon,
CopyIcon,
FolderIcon,
RefreshCwIcon,
SettingsIcon,
} from "lucide-react";
import { useState } from "react";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import { MappingUI } from "./mapping-ui";
import { TFieldMapping, TSourceField } from "./types";
interface CsvSourceUIProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
onSourceFieldsChange: (fields: TSourceField[]) => void;
onLoadSampleCSV: () => void;
}
export function CsvSourceUI({
sourceFields,
mappings,
onMappingsChange,
onSourceFieldsChange,
onLoadSampleCSV,
}: CsvSourceUIProps) {
const [csvFile, setCsvFile] = useState<File | null>(null);
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
const [showMapping, setShowMapping] = useState(false);
const [s3AutoSync, setS3AutoSync] = useState(false);
const [s3Copied, setS3Copied] = useState(false);
// Mock S3 bucket details
const s3BucketName = "formbricks-feedback-imports";
const s3Path = `s3://${s3BucketName}/feedback/incoming/`;
const handleCopyS3Path = () => {
navigator.clipboard.writeText(s3Path);
setS3Copied(true);
setTimeout(() => setS3Copied(false), 2000);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target?.files?.[0];
if (file) {
processCSVFile(file);
}
};
const processCSVFile = (file: File) => {
if (!file.name.endsWith(".csv")) {
return;
}
setCsvFile(file);
const reader = new FileReader();
reader.onload = (e) => {
const csv = e.target?.result as string;
const lines = csv.split("\n").slice(0, 6); // Preview first 5 rows + header
const preview = lines.map((line) => line.split(",").map((cell) => cell.trim()));
setCsvPreview(preview);
// Extract columns and create source fields
if (preview.length > 0) {
const headers = preview[0];
const fields: TSourceField[] = headers.map((header) => ({
id: header,
name: header,
type: "string",
sampleValue: preview[1]?.[headers.indexOf(header)] || "",
}));
onSourceFieldsChange(fields);
setShowMapping(true);
}
};
reader.readAsText(file);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
if (file) {
processCSVFile(file);
}
};
const handleLoadSample = () => {
onLoadSampleCSV();
setShowMapping(true);
};
// If mapping is shown, show the mapping UI
if (showMapping && sourceFields.length > 0) {
return (
<div className="space-y-4">
{/* File info bar */}
{csvFile && (
<div className="flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-4 py-2">
<div className="flex items-center gap-2">
<FolderIcon className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-800">{csvFile.name}</span>
<Badge text={`${csvPreview.length - 1} rows`} type="success" size="tiny" />
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setCsvFile(null);
setCsvPreview([]);
setShowMapping(false);
onSourceFieldsChange([]);
}}>
Change file
</Button>
</div>
)}
{/* CSV Preview Table */}
{csvPreview.length > 0 && (
<div className="overflow-hidden rounded-lg border border-slate-200">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
{csvPreview[0]?.map((header, i) => (
<th key={i} className="px-3 py-2 text-left font-medium text-slate-700">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{csvPreview.slice(1, 4).map((row, rowIndex) => (
<tr key={rowIndex} className="border-t border-slate-100">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="px-3 py-2 text-slate-600">
{cell || <span className="text-slate-300"></span>}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{csvPreview.length > 4 && (
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
Showing 3 of {csvPreview.length - 1} rows
</div>
)}
</div>
)}
{/* Mapping UI */}
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={onMappingsChange}
sourceType="csv"
/>
</div>
);
}
// Upload and S3 setup UI
return (
<div className="space-y-6">
{/* Manual Upload Section */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">Upload CSV File</h4>
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
<label
htmlFor="csv-file-upload"
className="flex cursor-pointer flex-col items-center justify-center"
onDragOver={handleDragOver}
onDrop={handleDrop}>
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
<p className="mt-2 text-sm text-slate-600">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="mt-1 text-xs text-slate-400">CSV files only</p>
<input
type="file"
id="csv-file-upload"
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</label>
</div>
<div className="flex justify-between">
<Button variant="secondary" size="sm" onClick={handleLoadSample}>
Load sample CSV
</Button>
</div>
</div>
{/* Divider */}
<div className="flex items-center gap-4">
<div className="h-px flex-1 bg-slate-200" />
<span className="text-xs font-medium uppercase text-slate-400">or</span>
<div className="h-px flex-1 bg-slate-200" />
</div>
{/* S3 Integration Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<CloudIcon className="h-5 w-5 text-slate-500" />
<h4 className="text-sm font-medium text-slate-700">S3 Bucket Integration</h4>
<Badge text="Automated" type="gray" size="tiny" />
</div>
<div className="rounded-lg border border-slate-200 bg-white p-4">
<p className="mb-4 text-sm text-slate-600">
Drop CSV files into your S3 bucket to automatically import feedback. Files are processed every 15
minutes.
</p>
{/* S3 Path Display */}
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Drop zone path</Label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-slate-100 px-3 py-2 font-mono text-sm text-slate-700">
{s3Path}
</code>
<Button variant="outline" size="sm" onClick={handleCopyS3Path}>
<CopyIcon className="h-4 w-4" />
{s3Copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
{/* S3 Settings */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">AWS Region</Label>
<Select defaultValue="eu-central-1">
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
<SelectItem value="us-west-2">US West (Oregon)</SelectItem>
<SelectItem value="eu-central-1">EU (Frankfurt)</SelectItem>
<SelectItem value="eu-west-1">EU (Ireland)</SelectItem>
<SelectItem value="ap-southeast-1">Asia Pacific (Singapore)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Processing interval</Label>
<Select defaultValue="15">
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">Every 5 minutes</SelectItem>
<SelectItem value="15">Every 15 minutes</SelectItem>
<SelectItem value="30">Every 30 minutes</SelectItem>
<SelectItem value="60">Every hour</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Auto-sync toggle */}
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 p-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-slate-900">Enable auto-sync</span>
<span className="text-xs text-slate-500">
Automatically process new files dropped in the bucket
</span>
</div>
<Switch checked={s3AutoSync} onCheckedChange={setS3AutoSync} />
</div>
{/* IAM Instructions */}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<div className="flex items-start gap-2">
<SettingsIcon className="mt-0.5 h-4 w-4 text-amber-600" />
<div>
<p className="text-sm font-medium text-amber-800">IAM Configuration Required</p>
<p className="mt-1 text-xs text-amber-700">
Add the Formbricks IAM role to your S3 bucket policy to enable access.{" "}
<button type="button" className="font-medium underline hover:no-underline">
View setup guide
</button>
</p>
</div>
</div>
</div>
{/* Test Connection */}
<div className="flex justify-end">
<Button variant="outline" size="sm" className="gap-2">
<RefreshCwIcon className="h-4 w-4" />
Test connection
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,603 @@
"use client";
import {
CheckIcon,
CopyIcon,
FileSpreadsheetIcon,
GlobeIcon,
MailIcon,
MessageSquareIcon,
SparklesIcon,
WebhookIcon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
import { MappingUI } from "./mapping-ui";
import {
AI_SUGGESTED_MAPPINGS,
EMAIL_SOURCE_FIELDS,
FEEDBACK_RECORD_FIELDS,
SAMPLE_CSV_COLUMNS,
SAMPLE_WEBHOOK_PAYLOAD,
TFieldMapping,
TSourceConnection,
TSourceField,
TSourceType,
TUnifySurvey,
parseCSVColumnsToFields,
parsePayloadToFields,
} from "./types";
// Polling interval in milliseconds (3 seconds)
const WEBHOOK_POLL_INTERVAL = 3000;
// Sample webhook payload for cURL example
const SAMPLE_CURL_PAYLOAD = {
timestamp: new Date().toISOString(),
source_type: "webhook",
field_id: "satisfaction_score",
field_type: "rating",
value_number: 4,
user_id: "user_123",
metadata: {
source: "api",
},
};
interface EditSourceModalProps {
source: TSourceConnection | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onUpdateSource: (
source: TSourceConnection,
selectedSurveyId?: string,
selectedElementIds?: string[]
) => void;
onDeleteSource: (sourceId: string) => void;
surveys: TUnifySurvey[];
// For Formbricks connectors - the currently selected survey/elements
initialSurveyId?: string | null;
initialElementIds?: string[];
}
function getSourceIcon(type: TSourceType) {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
case "webhook":
return <WebhookIcon className="h-5 w-5 text-slate-500" />;
case "email":
return <MailIcon className="h-5 w-5 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
case "slack":
return <MessageSquareIcon className="h-5 w-5 text-slate-500" />;
default:
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
}
}
function getSourceTypeLabel(type: TSourceType) {
switch (type) {
case "formbricks":
return "Formbricks Surveys";
case "webhook":
return "Webhook";
case "email":
return "Email";
case "csv":
return "CSV Import";
case "slack":
return "Slack Message";
default:
return type;
}
}
function getInitialSourceFields(type: TSourceType): TSourceField[] {
switch (type) {
case "webhook":
return parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
case "email":
return EMAIL_SOURCE_FIELDS;
case "csv":
return parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
default:
return [];
}
}
export function EditSourceModal({
source,
open,
onOpenChange,
onUpdateSource,
onDeleteSource,
surveys,
initialSurveyId,
initialElementIds = [],
}: EditSourceModalProps) {
const [sourceName, setSourceName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deriveFromAttachments, setDeriveFromAttachments] = useState(false);
// Formbricks-specific state
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
// Webhook listener state
const [isListening, setIsListening] = useState(false);
const [webhookReceived, setWebhookReceived] = useState(false);
const [copied, setCopied] = useState(false);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Permanent webhook URL using connector ID
const webhookUrl = source
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/unify/webhook/${source.id}`
: "";
// Poll for webhook payload using connector ID
const pollForWebhook = useCallback(async () => {
if (!source?.id) return;
try {
const response = await fetch(`/api/unify/webhook/${source.id}`);
if (response.status === 200) {
const data = await response.json();
if (data.payload) {
// Parse the received payload into source fields
const fields = parsePayloadToFields(data.payload);
setSourceFields(fields);
setWebhookReceived(true);
setIsListening(false);
toast.success("Webhook received! Fields loaded.");
// Stop polling
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}
}
// 204 means no payload yet, keep polling
} catch (error) {
console.error("Error polling for webhook:", error);
}
}, [source?.id]);
// Start/stop polling based on listening state
useEffect(() => {
if (isListening && source?.id) {
// Start polling
pollingIntervalRef.current = setInterval(pollForWebhook, WEBHOOK_POLL_INTERVAL);
// Also poll immediately
pollForWebhook();
}
return () => {
// Cleanup polling on unmount or when listening stops
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [isListening, source?.id, pollForWebhook]);
// Copy webhook URL to clipboard
const handleCopyWebhookUrl = async () => {
if (!webhookUrl) return;
try {
await navigator.clipboard.writeText(webhookUrl);
setCopied(true);
toast.success("Webhook URL copied to clipboard");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy");
}
};
useEffect(() => {
if (source) {
setSourceName(source.name);
setMappings(source.mappings);
setDeriveFromAttachments(false);
// For Formbricks connectors, set the initial survey/element selection
if (source.type === "formbricks") {
setSelectedSurveyId(initialSurveyId ?? null);
setSelectedElementIds(initialElementIds);
setSourceFields(getInitialSourceFields(source.type));
} else if (source.type === "webhook") {
// Webhook: if we already have mappings, show them; otherwise show listening state
if (source.mappings.length > 0) {
// Build source fields from existing mapping source IDs so the mapping UI can display them
const sourceFieldIds = new Set<string>();
for (const m of source.mappings) {
if (m.sourceFieldId) sourceFieldIds.add(m.sourceFieldId);
}
const fieldsFromMappings = Array.from(sourceFieldIds).map((id) => ({
id,
name: id,
type: "string",
sampleValue: "",
}));
setSourceFields(fieldsFromMappings);
setWebhookReceived(true);
setIsListening(false);
} else {
setSourceFields([]);
setIsListening(true);
setWebhookReceived(false);
}
} else {
setSourceFields(getInitialSourceFields(source.type));
}
}
}, [source, initialSurveyId, initialElementIds]);
const resetForm = () => {
setSourceName("");
setMappings([]);
setSourceFields([]);
setShowDeleteConfirm(false);
setDeriveFromAttachments(false);
setSelectedSurveyId(null);
setSelectedElementIds([]);
// Reset webhook state
setIsListening(false);
setWebhookReceived(false);
setCopied(false);
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
// Formbricks handlers
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleElementToggle = (elementId: string) => {
setSelectedElementIds((prev) =>
prev.includes(elementId) ? prev.filter((id) => id !== elementId) : [...prev, elementId]
);
};
const handleSelectAllElements = (surveyId: string) => {
const survey = surveys.find((s) => s.id === surveyId);
if (survey) {
setSelectedElementIds(survey.elements.map((e) => e.id));
}
};
const handleDeselectAllElements = () => {
setSelectedElementIds([]);
};
const handleUpdateSource = () => {
if (!source || !sourceName.trim()) return;
const updatedSource: TSourceConnection = {
...source,
name: sourceName.trim(),
mappings,
updatedAt: new Date(),
};
// For Formbricks, pass the survey/element selection
if (source.type === "formbricks") {
onUpdateSource(updatedSource, selectedSurveyId ?? undefined, selectedElementIds);
} else {
onUpdateSource(updatedSource);
}
handleOpenChange(false);
};
const handleDeleteSource = () => {
if (!source) return;
onDeleteSource(source.id);
handleOpenChange(false);
};
const handleLoadSourceFields = () => {
if (!source) return;
let fields: TSourceField[];
if (source.type === "webhook") {
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
} else if (source.type === "email") {
fields = EMAIL_SOURCE_FIELDS;
} else if (source.type === "csv") {
fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
} else {
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
}
setSourceFields(fields);
};
const handleSuggestMapping = () => {
if (!source) return;
const suggestions = AI_SUGGESTED_MAPPINGS[source.type];
if (!suggestions) return;
const newMappings: TFieldMapping[] = [];
for (const sourceField of sourceFields) {
const suggestedTarget = suggestions.fieldMappings[sourceField.id];
if (suggestedTarget) {
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === suggestedTarget);
if (targetExists) {
newMappings.push({
sourceFieldId: sourceField.id,
targetFieldId: suggestedTarget,
});
}
}
}
for (const [targetFieldId, staticValue] of Object.entries(suggestions.staticValues)) {
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === targetFieldId);
if (targetExists) {
if (!newMappings.some((m) => m.targetFieldId === targetFieldId)) {
newMappings.push({
targetFieldId,
staticValue,
});
}
}
}
setMappings(newMappings);
};
const getLoadButtonLabel = () => {
switch (source?.type) {
case "webhook":
return "Simulate webhook";
case "email":
return "Load email fields";
case "csv":
return "Load sample CSV";
default:
return "Load sample";
}
};
if (!source) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Edit Source Connection</DialogTitle>
<DialogDescription>Update the mapping configuration for this source.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Source Type Display */}
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
{getSourceIcon(source.type)}
<div>
<p className="text-sm font-medium text-slate-900">{getSourceTypeLabel(source.type)}</p>
<p className="text-xs text-slate-500">Source type cannot be changed</p>
</div>
</div>
{/* Source Name */}
<div className="space-y-2">
<Label htmlFor="editSourceName">Source Name</Label>
<Input
id="editSourceName"
value={sourceName}
onChange={(e) => setSourceName(e.target.value)}
placeholder="Enter a name for this source"
/>
</div>
{source.type === "formbricks" ? (
/* Formbricks Survey Selector UI */
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
surveys={surveys}
selectedSurveyId={selectedSurveyId}
selectedElementIds={selectedElementIds}
onSurveySelect={handleSurveySelect}
onElementToggle={handleElementToggle}
onSelectAllElements={handleSelectAllElements}
onDeselectAllElements={handleDeselectAllElements}
/>
</div>
) : (
/* Other source types - Mapping UI */
<>
{/* Webhook Listener UI - Waiting state */}
{source.type === "webhook" && !webhookReceived && (
<div className="space-y-4">
{/* Permanent Webhook URL */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100">
<WebhookIcon className="h-4 w-4 text-blue-600" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-blue-900">Your Webhook URL</p>
<p className="mt-0.5 text-xs text-blue-700">
This is your permanent webhook endpoint. Use it in your integrations.
</p>
<div className="mt-2 flex items-center gap-2">
<code className="flex-1 rounded bg-white px-2 py-1 text-xs text-blue-800">
{webhookUrl || "Loading..."}
</code>
<Button
variant="outline"
size="sm"
onClick={handleCopyWebhookUrl}
disabled={!webhookUrl}
className="shrink-0 border-blue-300 text-blue-700 hover:bg-blue-100">
{copied ? <CheckIcon className="h-3 w-3" /> : <CopyIcon className="h-3 w-3" />}
</Button>
</div>
</div>
</div>
</div>
{/* Centered waiting indicator */}
<div className="flex flex-col items-center justify-center rounded-lg border border-slate-200 bg-slate-50 py-8">
<span className="relative mb-3 flex h-12 w-12">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-slate-300 opacity-75"></span>
<span className="relative inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-200">
<WebhookIcon className="h-6 w-6 text-slate-600" />
</span>
</span>
<p className="text-sm font-medium text-slate-700">Listening for test payload...</p>
<p className="mt-1 text-xs text-slate-500">Send a request to update field mappings</p>
</div>
{/* cURL example */}
<div className="space-y-2">
<Label className="text-sm font-medium text-slate-700">Test with cURL</Label>
<div className="relative">
<pre className="overflow-auto rounded-lg border border-slate-300 bg-slate-900 p-3 text-xs text-slate-100">
<code>{`curl -X POST "${webhookUrl || "..."}" \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(SAMPLE_CURL_PAYLOAD, null, 2)}'`}</code>
</pre>
</div>
</div>
</div>
)}
{/* Webhook configured - show mapping UI */}
{source.type === "webhook" && webhookReceived && (
<div className="space-y-4">
{/* Webhook URL + copy (when already configured) */}
<div className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<span className="text-xs font-medium text-slate-600">Webhook URL:</span>
<code className="min-w-0 flex-1 truncate text-xs text-slate-700">
{webhookUrl || "..."}
</code>
<Button
variant="ghost"
size="sm"
onClick={handleCopyWebhookUrl}
disabled={!webhookUrl}
className="h-7 shrink-0 px-2">
{copied ? <CheckIcon className="h-3 w-3" /> : <CopyIcon className="h-3 w-3" />}
</Button>
</div>
{/* AI suggest mapping button */}
{sourceFields.length > 0 && (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
<SparklesIcon className="h-4 w-4 text-purple-500" />
Suggest mapping
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
</Button>
</div>
)}
{/* Mapping UI */}
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
sourceType={source.type}
deriveFromAttachments={deriveFromAttachments}
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
/>
</div>
</div>
)}
{/* Non-webhook types */}
{source.type !== "webhook" && (
<>
{/* Action buttons */}
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleLoadSourceFields}>
{getLoadButtonLabel()}
</Button>
{sourceFields.length > 0 && (
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
<SparklesIcon className="h-4 w-4 text-purple-500" />
Suggest mapping
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
</Button>
)}
</div>
</div>
{/* Mapping UI */}
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
sourceType={source.type}
deriveFromAttachments={deriveFromAttachments}
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
/>
</div>
</>
)}
</>
)}
</div>
<DialogFooter className="flex justify-between">
<div>
{showDeleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-red-600">Are you sure?</span>
<Button variant="destructive" size="sm" onClick={handleDeleteSource}>
Yes, delete
</Button>
<Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="outline" onClick={() => setShowDeleteConfirm(true)}>
Delete source
</Button>
)}
</div>
<Button
onClick={handleUpdateSource}
disabled={
!sourceName.trim() ||
(source.type === "formbricks" && (!selectedSurveyId || selectedElementIds.length === 0))
}>
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,207 @@
"use client";
import {
CheckCircle2Icon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleIcon,
FileTextIcon,
MessageSquareTextIcon,
StarIcon,
} from "lucide-react";
import { useState } from "react";
import { Badge } from "@/modules/ui/components/badge";
import { TUnifySurvey, getElementTypeLabel } from "./types";
interface FormbricksSurveySelectorProps {
surveys: TUnifySurvey[];
selectedSurveyId: string | null;
selectedElementIds: string[];
onSurveySelect: (surveyId: string | null) => void;
onElementToggle: (elementId: string) => void;
onSelectAllElements: (surveyId: string) => void;
onDeselectAllElements: () => void;
}
function getElementIcon(type: string) {
switch (type) {
case "openText":
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
case "rating":
case "nps":
return <StarIcon className="h-4 w-4 text-amber-500" />;
default:
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
}
}
function getStatusBadge(status: TUnifySurvey["status"]) {
switch (status) {
case "active":
return <Badge text="Active" type="success" size="tiny" />;
case "paused":
return <Badge text="Paused" type="warning" size="tiny" />;
case "draft":
return <Badge text="Draft" type="gray" size="tiny" />;
case "completed":
return <Badge text="Completed" type="gray" size="tiny" />;
default:
return null;
}
}
export function FormbricksSurveySelector({
surveys,
selectedSurveyId,
selectedElementIds,
onSurveySelect,
onElementToggle,
onSelectAllElements,
onDeselectAllElements,
}: FormbricksSurveySelectorProps) {
const [expandedSurveyId, setExpandedSurveyId] = useState<string | null>(null);
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
const handleSurveyClick = (survey: TUnifySurvey) => {
if (selectedSurveyId === survey.id) {
// Toggle expand/collapse if already selected
setExpandedSurveyId(expandedSurveyId === survey.id ? null : survey.id);
} else {
// Select the survey and expand it
onSurveySelect(survey.id);
onDeselectAllElements();
setExpandedSurveyId(survey.id);
}
};
const allElementsSelected = selectedSurvey && selectedElementIds.length === selectedSurvey.elements.length;
return (
<div className="grid grid-cols-2 gap-6">
{/* Left: Survey List */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">Select Survey</h4>
<div className="space-y-2">
{surveys.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">No surveys found in this environment</p>
</div>
) : (
surveys.map((survey) => {
const isSelected = selectedSurveyId === survey.id;
const isExpanded = expandedSurveyId === survey.id;
return (
<div key={survey.id}>
<button
type="button"
onClick={() => handleSurveyClick(survey)}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
isSelected
? "border-brand-dark bg-slate-50"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-100">
{isExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-slate-600" />
) : (
<ChevronRightIcon className="h-4 w-4 text-slate-600" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-900">{survey.name}</span>
{getStatusBadge(survey.status)}
</div>
<p className="text-xs text-slate-500">
{survey.elements.length} elements
{survey.responseCount > 0 && ` · ${survey.responseCount.toLocaleString()} responses`}
</p>
</div>
{isSelected && <CheckCircle2Icon className="text-brand-dark h-5 w-5" />}
</button>
</div>
);
})
)}
</div>
</div>
{/* Right: Element Selection */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-slate-700">Select Elements</h4>
{selectedSurvey && (
<button
type="button"
onClick={() =>
allElementsSelected ? onDeselectAllElements() : onSelectAllElements(selectedSurvey.id)
}
className="text-xs text-slate-500 hover:text-slate-700">
{allElementsSelected ? "Deselect all" : "Select all"}
</button>
)}
</div>
{!selectedSurvey ? (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">Select a survey to see its elements</p>
</div>
) : selectedSurvey.elements.length === 0 ? (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">This survey has no question elements</p>
</div>
) : (
<div className="space-y-2">
{selectedSurvey.elements.map((element) => {
const isSelected = selectedElementIds.includes(element.id);
return (
<button
key={element.id}
type="button"
onClick={() => onElementToggle(element.id)}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
isSelected
? "border-green-300 bg-green-50"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<div
className={`flex h-5 w-5 items-center justify-center rounded ${
isSelected ? "bg-green-500 text-white" : "border border-slate-300 bg-white"
}`}>
{isSelected && <CheckIcon className="h-3 w-3" />}
</div>
<div className="flex items-center gap-2">{getElementIcon(element.type)}</div>
<div className="flex-1">
<p className="text-sm text-slate-900">{element.headline}</p>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">{getElementTypeLabel(element.type)}</span>
{element.required && (
<span className="text-xs text-red-500">
<CircleIcon className="inline h-1.5 w-1.5 fill-current" /> Required
</span>
)}
</div>
</div>
</button>
);
})}
{selectedElementIds.length > 0 && (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
<p className="text-xs text-blue-700">
<strong>{selectedElementIds.length}</strong> element
{selectedElementIds.length !== 1 ? "s" : ""} selected. Each response to these elements will
create a FeedbackRecord in the Hub.
</p>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export { CreateSourceModal } from "./create-source-modal";
export { CsvSourceUI } from "./csv-source-ui";
export { EditSourceModal } from "./edit-source-modal";
export { FormbricksSurveySelector } from "./formbricks-survey-selector";
export { MappingUI } from "./mapping-ui";
export { SourcesSection } from "./sources-page-client";
export { SourcesTable } from "./sources-table";
export { SourcesTableDataRow } from "./sources-table-data-row";
export { SourceTypeSelector } from "./source-type-selector";
export * from "./types";

View File

@@ -0,0 +1,305 @@
"use client";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { TFieldMapping, TSourceField, TTargetField } from "./types";
interface DraggableSourceFieldProps {
field: TSourceField;
isMapped: boolean;
}
export function DraggableSourceField({ field, isMapped }: DraggableSourceFieldProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: field.id,
data: field,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isDragging
? "border-brand-dark bg-slate-100 opacity-50"
: isMapped
? "border-green-300 bg-green-50 text-green-800"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
<div className="flex-1 truncate">
<span className="font-medium">{field.name}</span>
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
</div>
{field.sampleValue && (
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
)}
</div>
);
}
interface DroppableTargetFieldProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
isOver?: boolean;
}
export function DroppableTargetField({
field,
mappedSourceField,
mapping,
onRemoveMapping,
onStaticValueChange,
isOver,
}: DroppableTargetFieldProps) {
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
id: field.id,
data: field,
});
const [isEditingStatic, setIsEditingStatic] = useState(false);
const [customValue, setCustomValue] = useState("");
const isActive = isOver || isOverCurrent;
const hasMapping = mappedSourceField || mapping?.staticValue;
// Handle enum field type - show dropdown
if (field.type === "enum" && field.enumValues) {
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
mapping?.staticValue ? "border-green-300 bg-green-50" : "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">(enum)</span>
</div>
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
<SelectTrigger className="h-8 w-full bg-white">
<SelectValue placeholder="Select a value..." />
</SelectTrigger>
<SelectContent>
{field.enumValues.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
// Handle string fields - allow drag & drop OR static value
if (field.type === "string") {
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
</div>
{/* Show mapped source field */}
{mappedSourceField && !mapping?.staticValue && (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
)}
{/* Show static value */}
{mapping?.staticValue && !mappedSourceField && (
<div className="flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= "{mapping.staticValue}"
</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-blue-100">
<XIcon className="h-3 w-3 text-blue-600" />
</button>
</div>
)}
{/* Show input for entering static value when editing */}
{isEditingStatic && !hasMapping && (
<div className="flex items-center gap-1">
<Input
type="text"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={
field.exampleStaticValues ? `e.g., ${field.exampleStaticValues[0]}` : "Enter value..."
}
className="h-7 text-xs"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
setIsEditingStatic(false);
}
if (e.key === "Escape") {
setCustomValue("");
setIsEditingStatic(false);
}
}}
/>
<button
type="button"
onClick={() => {
if (customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
}
setIsEditingStatic(false);
}}
className="rounded p-1 text-slate-500 hover:bg-slate-200">
<ChevronDownIcon className="h-3 w-3" />
</button>
</div>
)}
{/* Show example values as quick select OR drop zone */}
{!hasMapping && !isEditingStatic && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">Drop field or</span>
<button
type="button"
onClick={() => setIsEditingStatic(true)}
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
<PencilIcon className="h-3 w-3" />
set value
</button>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.slice(0, 3).map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{val}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
}
// Helper to get display label for static values
const getStaticValueLabel = (value: string) => {
if (value === "$now") return "Feedback date";
return value;
};
// Default behavior for other field types (timestamp, float64, boolean, jsonb, etc.)
const hasDefaultMapping = mappedSourceField || mapping?.staticValue;
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasDefaultMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{field.name}</span>
{field.required && <span className="text-xs text-red-500">*</span>}
<span className="text-xs text-slate-400">({field.type})</span>
</div>
{/* Show mapped source field */}
{mappedSourceField && !mapping?.staticValue && (
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
)}
{/* Show static value */}
{mapping?.staticValue && !mappedSourceField && (
<div className="mt-1 flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= {getStaticValueLabel(mapping.staticValue)}
</span>
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-blue-100">
<XIcon className="h-3 w-3 text-blue-600" />
</button>
</div>
)}
{/* Show drop zone with preset options */}
{!hasDefaultMapping && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-xs text-slate-400">Drop a field here</span>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{getStaticValueLabel(val)}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,232 @@
"use client";
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
import { CopyIcon, MailIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/modules/ui/components/badge";
import { Switch } from "@/modules/ui/components/switch";
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField, TSourceType } from "./types";
interface MappingUIProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
sourceType: TSourceType;
deriveFromAttachments?: boolean;
onDeriveFromAttachmentsChange?: (value: boolean) => void;
emailInboxId?: string;
}
export function MappingUI({
sourceFields,
mappings,
onMappingsChange,
sourceType,
deriveFromAttachments = false,
onDeriveFromAttachmentsChange,
emailInboxId,
}: MappingUIProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const [emailCopied, setEmailCopied] = useState(false);
// Generate a stable random email ID if not provided
const generatedEmailId = useMemo(() => {
if (emailInboxId) return emailInboxId;
return `fb-${Math.random().toString(36).substring(2, 8)}`;
}, [emailInboxId]);
const inboxEmail = `${generatedEmailId}@inbox.formbricks.com`;
const handleCopyEmail = () => {
navigator.clipboard.writeText(inboxEmail);
setEmailCopied(true);
setTimeout(() => setEmailCopied(false), 2000);
};
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const sourceFieldId = active.id as string;
const targetFieldId = over.id as string;
// Check if this target already has a mapping
const existingMapping = mappings.find((m) => m.targetFieldId === targetFieldId);
if (existingMapping) {
// Remove the existing mapping first
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
} else {
// Remove any existing mapping for this source field
const newMappings = mappings.filter((m) => m.sourceFieldId !== sourceFieldId);
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
}
};
const handleRemoveMapping = (targetFieldId: string) => {
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
};
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
// Remove any existing mapping for this target field
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
// Add new static value mapping
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
};
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
const getMappingForTarget = (targetFieldId: string) => {
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
};
const getMappedSourceField = (targetFieldId: string) => {
const mapping = getMappingForTarget(targetFieldId);
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
};
const isSourceFieldMapped = (sourceFieldId: string) =>
mappings.some((m) => m.sourceFieldId === sourceFieldId);
const activeField = activeId ? getSourceFieldById(activeId) : null;
const getSourceTypeLabel = () => {
switch (sourceType) {
case "webhook":
return "Webhook Payload";
case "email":
return "Email Fields";
case "csv":
return "CSV Columns";
default:
return "Source Fields";
}
};
return (
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
{/* Email inbox address display */}
{sourceType === "email" && (
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
<MailIcon className="h-5 w-5 text-blue-600" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-slate-900">Your feedback inbox</p>
<p className="mt-0.5 text-xs text-slate-500">
Forward emails to this address to capture feedback automatically
</p>
<div className="mt-2 flex items-center gap-2">
<code className="rounded bg-white px-2 py-1 font-mono text-sm text-blue-700">
{inboxEmail}
</code>
<button
type="button"
onClick={handleCopyEmail}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-100">
<CopyIcon className="h-3 w-3" />
{emailCopied ? "Copied!" : "Copy"}
</button>
</div>
</div>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-6">
{/* Source Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">{getSourceTypeLabel()}</h4>
{sourceFields.length === 0 ? (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">
{sourceType === "webhook"
? "Click 'Simulate webhook' to load sample fields"
: sourceType === "email"
? "Click 'Load email fields' to see available fields"
: sourceType === "csv"
? "Click 'Load sample CSV' to see columns"
: "No source fields loaded yet"}
</p>
</div>
) : (
<div className="space-y-2">
{sourceFields.map((field) => (
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
))}
</div>
)}
{/* Email-specific options */}
{sourceType === "email" && onDeriveFromAttachmentsChange && (
<div className="mt-4 flex items-center justify-between rounded-lg border border-slate-200 bg-white p-3">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-900">Derive context from attachments</span>
<Badge text="AI" type="gray" size="tiny" />
</div>
<span className="text-xs text-slate-500">
Extract additional context from email attachments using AI
</span>
</div>
<Switch checked={deriveFromAttachments} onCheckedChange={onDeriveFromAttachmentsChange} />
</div>
)}
</div>
{/* Target Fields Panel */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-slate-700">Hub Feedback Record Fields</h4>
{/* Required Fields */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Required</p>
{requiredFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
{/* Optional Fields */}
<div className="mt-4 space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Optional</p>
{optionalFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
</div>
</div>
<DragOverlay>
{activeField ? (
<div className="border-brand-dark rounded-md border bg-white p-2 text-sm shadow-lg">
<span className="font-medium">{activeField.name}</span>
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { Badge } from "@/modules/ui/components/badge";
import { SOURCE_OPTIONS, TSourceType } from "./types";
interface SourceTypeSelectorProps {
selectedType: TSourceType | null;
onSelectType: (type: TSourceType) => void;
}
export function SourceTypeSelector({ selectedType, onSelectType }: SourceTypeSelectorProps) {
return (
<div className="space-y-3">
<p className="text-sm text-slate-600">Select the type of feedback source you want to connect:</p>
<div className="space-y-2">
{SOURCE_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
selectedType === option.id
? "border-brand-dark bg-slate-50"
: option.disabled
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
</div>
<div
className={`ml-4 h-5 w-5 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-2 w-2 rounded-full bg-white" />
</div>
)}
</div>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,323 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { TConnectorWithMappings, TFormbricksConnector } from "@formbricks/types/connector";
import {
createConnectorAction,
deleteConnectorAction,
getConnectorsWithMappingsAction,
syncFieldMappingsAction,
syncFormbricksMappingsAction,
updateConnectorAction,
} from "@/lib/connector/actions";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import { getSurveysForUnifyAction } from "../actions";
import { CreateSourceModal } from "./create-source-modal";
import { EditSourceModal } from "./edit-source-modal";
import { SourcesTable } from "./sources-table";
import { TSourceConnection, TUnifySurvey } from "./types";
import { elementTypeToHubFieldType } from "./types";
interface SourcesSectionProps {
environmentId: string;
}
// Transform connector from database to TSourceConnection for UI
function connectorToSourceConnection(connector: TConnectorWithMappings): TSourceConnection {
// For webhook (and other field-mapping connectors), include field mappings
const mappings =
connector.type === "webhook" && "fieldMappings" in connector && connector.fieldMappings?.length
? connector.fieldMappings.map((m) => ({
sourceFieldId: m.sourceFieldId,
targetFieldId: m.targetFieldId,
staticValue: m.staticValue ?? undefined,
}))
: [];
return {
id: connector.id,
name: connector.name,
type: connector.type as TSourceConnection["type"],
mappings,
createdAt: connector.createdAt,
updatedAt: connector.updatedAt,
};
}
// Get Formbricks mapping data from a connector
function getFormbricksMappingData(connector: TConnectorWithMappings): {
surveyId: string | null;
elementIds: string[];
} {
if (connector.type !== "formbricks" || !("formbricksMappings" in connector)) {
return { surveyId: null, elementIds: [] };
}
const formbricksConnector = connector as TFormbricksConnector;
const mappings = formbricksConnector.formbricksMappings || [];
if (mappings.length === 0) {
return { surveyId: null, elementIds: [] };
}
// All mappings for a Formbricks connector should be for the same survey (for now)
const surveyId = mappings[0].surveyId;
const elementIds = mappings.map((m) => m.elementId);
return { surveyId, elementIds };
}
export function SourcesSection({ environmentId }: SourcesSectionProps) {
const [sources, setSources] = useState<TSourceConnection[]>([]);
const [connectorsMap, setConnectorsMap] = useState<Map<string, TConnectorWithMappings>>(new Map());
const [surveys, setSurveys] = useState<TUnifySurvey[]>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingSource, setEditingSource] = useState<TSourceConnection | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Fetch surveys and connectors on mount
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
// Fetch surveys and connectors in parallel
const [surveysResult, connectorsResult] = await Promise.all([
getSurveysForUnifyAction({ environmentId }),
getConnectorsWithMappingsAction({ environmentId }),
]);
if (surveysResult?.data) {
setSurveys(surveysResult.data);
}
if (connectorsResult?.data) {
setSources(connectorsResult.data.map(connectorToSourceConnection));
// Store the full connector data for editing
const newConnectorsMap = new Map<string, TConnectorWithMappings>();
connectorsResult.data.forEach((connector) => {
newConnectorsMap.set(connector.id, connector);
});
setConnectorsMap(newConnectorsMap);
}
} catch (error) {
console.error("Failed to fetch data:", error);
toast.error("Failed to load data");
} finally {
setIsLoading(false);
}
}, [environmentId]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleCreateSource = async (
source: TSourceConnection,
selectedSurveyId?: string,
selectedElementIds?: string[]
) => {
try {
// Create the connector in the database
const result = await createConnectorAction({
environmentId,
connectorInput: {
name: source.name,
type: source.type,
},
});
if (!result?.data) {
toast.error("Failed to create connector");
return;
}
const connectorResult = result.data;
if ("error" in connectorResult && connectorResult.error) {
toast.error(connectorResult.error.message || "Failed to create connector");
return;
}
const connector = "data" in connectorResult ? connectorResult.data : connectorResult;
if (!connector || !connector.id) {
toast.error("Failed to create connector - invalid response");
return;
}
// If it's a Formbricks connector, create the mappings
if (
source.type === "formbricks" &&
selectedSurveyId &&
selectedElementIds &&
selectedElementIds.length > 0
) {
// Get the survey to determine element types
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
if (selectedSurvey) {
const mappings = selectedElementIds.map((elementId) => {
const element = selectedSurvey.elements.find((e) => e.id === elementId);
return {
surveyId: selectedSurveyId,
elementId,
hubFieldType: elementTypeToHubFieldType(element?.type || "openText"),
};
});
await syncFormbricksMappingsAction({
connectorId: connector.id,
mappings,
});
}
} else if (source.type !== "formbricks" && source.mappings.length > 0) {
// For other connector types, save field mappings
const fieldMappings = source.mappings.map((m) => ({
sourceFieldId: m.sourceFieldId || "",
targetFieldId: m.targetFieldId,
staticValue: m.staticValue,
}));
await syncFieldMappingsAction({
connectorId: connector.id,
mappings: fieldMappings,
});
}
// Refresh the list
await fetchData();
toast.success("Connector created successfully");
} catch (error) {
console.error("Failed to create connector:", error);
toast.error("Failed to create connector");
}
};
const handleUpdateSource = async (
updatedSource: TSourceConnection,
selectedSurveyId?: string,
selectedElementIds?: string[]
) => {
try {
// Update the connector name
const result = await updateConnectorAction({
connectorId: updatedSource.id,
connectorInput: {
name: updatedSource.name,
},
});
if (!result?.data) {
toast.error("Failed to update connector");
return;
}
// If it's a Formbricks connector, update the mappings
if (
updatedSource.type === "formbricks" &&
selectedSurveyId &&
selectedElementIds &&
selectedElementIds.length > 0
) {
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
if (selectedSurvey) {
const mappings = selectedElementIds.map((elementId) => {
const element = selectedSurvey.elements.find((e) => e.id === elementId);
return {
surveyId: selectedSurveyId,
elementId,
hubFieldType: elementTypeToHubFieldType(element?.type || "openText"),
};
});
await syncFormbricksMappingsAction({
connectorId: updatedSource.id,
mappings,
});
}
} else if (updatedSource.type !== "formbricks" && updatedSource.mappings.length > 0) {
// For other connector types, save field mappings
const fieldMappings = updatedSource.mappings.map((m) => ({
sourceFieldId: m.sourceFieldId || "",
targetFieldId: m.targetFieldId,
staticValue: m.staticValue,
}));
await syncFieldMappingsAction({
connectorId: updatedSource.id,
mappings: fieldMappings,
});
}
// Refresh the list
await fetchData();
toast.success("Connector updated successfully");
} catch (error) {
console.error("Failed to update connector:", error);
toast.error("Failed to update connector");
}
};
const handleDeleteSource = async (sourceId: string) => {
try {
const result = await deleteConnectorAction({
connectorId: sourceId,
});
if (!result?.data) {
toast.error("Failed to delete connector");
return;
}
// Refresh the list
await fetchData();
toast.success("Connector deleted successfully");
} catch (error) {
console.error("Failed to delete connector:", error);
toast.error("Failed to delete connector");
}
};
const handleSourceClick = (source: TSourceConnection) => {
setEditingSource(source);
};
return (
<PageContentWrapper>
<PageHeader
pageTitle="Unify Feedback"
cta={
<CreateSourceModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateSource={handleCreateSource}
surveys={surveys}
/>
}>
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="space-y-6">
<SourcesTable sources={sources} onSourceClick={handleSourceClick} isLoading={isLoading} />
</div>
<EditSourceModal
source={editingSource}
open={editingSource !== null}
onOpenChange={(open) => !open && setEditingSource(null)}
onUpdateSource={handleUpdateSource}
onDeleteSource={handleDeleteSource}
surveys={surveys}
initialSurveyId={
editingSource && connectorsMap.get(editingSource.id)
? getFormbricksMappingData(connectorsMap.get(editingSource.id)!).surveyId
: null
}
initialElementIds={
editingSource && connectorsMap.get(editingSource.id)
? getFormbricksMappingData(connectorsMap.get(editingSource.id)!).elementIds
: []
}
/>
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { FileSpreadsheetIcon, GlobeIcon, MailIcon, MessageSquareIcon, WebhookIcon } from "lucide-react";
import { TSourceType } from "./types";
interface SourcesTableDataRowProps {
id: string;
name: string;
type: TSourceType;
mappingsCount: number;
createdAt: Date;
onClick: () => void;
}
function getSourceIcon(type: TSourceType) {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
case "webhook":
return <WebhookIcon className="h-4 w-4 text-slate-500" />;
case "email":
return <MailIcon className="h-4 w-4 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
case "slack":
return <MessageSquareIcon className="h-4 w-4 text-slate-500" />;
default:
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
}
}
function getSourceTypeLabel(type: TSourceType) {
switch (type) {
case "formbricks":
return "Formbricks";
case "webhook":
return "Webhook";
case "email":
return "Email";
case "csv":
return "CSV";
case "slack":
return "Slack";
default:
return type;
}
}
export function SourcesTableDataRow({
id,
name,
type,
mappingsCount,
createdAt,
onClick,
}: SourcesTableDataRowProps) {
return (
<div
key={id}
role="button"
tabIndex={0}
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClick();
}
}}>
<div className="col-span-1 flex items-center pl-4">
<div className="flex items-center gap-2">
{getSourceIcon(type)}
<span className="hidden text-xs text-slate-500 sm:inline">{getSourceTypeLabel(type)}</span>
</div>
</div>
<div className="col-span-6 flex items-center">
<span className="truncate font-medium text-slate-900">{name}</span>
</div>
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-600 sm:flex">
{mappingsCount} {mappingsCount === 1 ? "field" : "fields"}
</div>
<div className="col-span-3 hidden items-center justify-end pr-4 text-sm text-slate-500 sm:flex">
{formatDistanceToNow(createdAt, { addSuffix: true })}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { SourcesTableDataRow } from "./sources-table-data-row";
import { TSourceConnection } from "./types";
interface SourcesTableProps {
sources: TSourceConnection[];
onSourceClick: (source: TSourceConnection) => void;
isLoading?: boolean;
}
export function SourcesTable({ sources, onSourceClick, isLoading = false }: SourcesTableProps) {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">Type</div>
<div className="col-span-6">Name</div>
<div className="col-span-2 hidden text-center sm:block">Mappings</div>
<div className="col-span-3 hidden pr-6 text-right sm:block">Created</div>
</div>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2Icon className="h-6 w-6 animate-spin text-slate-500" />
</div>
) : sources.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-500">No sources connected yet. Add a source to get started.</p>
</div>
) : (
<div className="divide-y divide-slate-100">
{sources.map((source) => (
<SourcesTableDataRow
key={source.id}
id={source.id}
name={source.name}
type={source.type}
mappingsCount={source.mappings.length}
createdAt={source.createdAt}
onClick={() => onSourceClick(source)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,690 @@
// Source types for the feedback source connections
export type TSourceType = "formbricks" | "webhook" | "email" | "csv" | "slack";
export interface TSourceOption {
id: TSourceType;
name: string;
description: string;
disabled: boolean;
badge?: {
text: string;
type: "success" | "gray" | "warning";
};
}
export const SOURCE_OPTIONS: TSourceOption[] = [
{
id: "formbricks",
name: "Formbricks Surveys",
description: "Connect feedback from your Formbricks surveys",
disabled: false,
},
{
id: "webhook",
name: "Webhook",
description: "Receive feedback via webhook with custom mapping",
disabled: false,
},
{
id: "email",
name: "Email",
description: "Import feedback from email with custom mapping",
disabled: false,
},
{
id: "csv",
name: "CSV Import",
description: "Import feedback from CSV files",
disabled: false,
},
{
id: "slack",
name: "Slack Message",
description: "Connect feedback from Slack channels",
disabled: true,
badge: {
text: "Coming soon",
type: "gray",
},
},
];
// Formbricks Survey types for survey selection (legacy - kept for backwards compatibility)
export interface TFormbricksSurveyQuestion {
id: string;
type: "openText" | "rating" | "nps" | "csat" | "multipleChoice" | "checkbox" | "date";
headline: string;
required: boolean;
}
export interface TFormbricksSurvey {
id: string;
name: string;
status: "draft" | "active" | "paused" | "completed";
responseCount: number;
questions: TFormbricksSurveyQuestion[];
createdAt: Date;
}
// New Unify Survey types that work with real survey data
export interface TUnifySurveyElement {
id: string;
type: string; // Element type from TSurveyElementTypeEnum
headline: string;
required: boolean;
}
export interface TUnifySurvey {
id: string;
name: string;
status: "draft" | "active" | "paused" | "completed";
responseCount: number;
elements: TUnifySurveyElement[];
createdAt: Date;
}
// Mock surveys for POC
export const MOCK_FORMBRICKS_SURVEYS: TFormbricksSurvey[] = [
{
id: "survey_nps_q1",
name: "Q1 2024 NPS Survey",
status: "active",
responseCount: 1247,
createdAt: new Date("2024-01-15"),
questions: [
{ id: "q_nps", type: "nps", headline: "How likely are you to recommend us?", required: true },
{
id: "q_reason",
type: "openText",
headline: "What's the main reason for your score?",
required: false,
},
{
id: "q_improve",
type: "openText",
headline: "What could we do to improve?",
required: false,
},
],
},
{
id: "survey_product_feedback",
name: "Product Feedback Survey",
status: "active",
responseCount: 523,
createdAt: new Date("2024-02-01"),
questions: [
{
id: "q_satisfaction",
type: "rating",
headline: "How satisfied are you with the product?",
required: true,
},
{
id: "q_features",
type: "multipleChoice",
headline: "Which features do you use most?",
required: true,
},
{
id: "q_missing",
type: "openText",
headline: "What features are you missing?",
required: false,
},
{ id: "q_feedback", type: "openText", headline: "Any other feedback?", required: false },
],
},
{
id: "survey_onboarding",
name: "Onboarding Experience",
status: "active",
responseCount: 89,
createdAt: new Date("2024-03-10"),
questions: [
{ id: "q_easy", type: "csat", headline: "How easy was the onboarding process?", required: true },
{
id: "q_time",
type: "multipleChoice",
headline: "How long did onboarding take?",
required: true,
},
{
id: "q_help",
type: "checkbox",
headline: "Which resources did you find helpful?",
required: false,
},
{
id: "q_suggestions",
type: "openText",
headline: "Any suggestions for improvement?",
required: false,
},
],
},
{
id: "survey_support",
name: "Support Satisfaction",
status: "paused",
responseCount: 312,
createdAt: new Date("2024-01-20"),
questions: [
{
id: "q_support_rating",
type: "rating",
headline: "How would you rate your support experience?",
required: true,
},
{
id: "q_resolved",
type: "multipleChoice",
headline: "Was your issue resolved?",
required: true,
},
{ id: "q_comments", type: "openText", headline: "Additional comments", required: false },
],
},
];
// Helper to get question type label (legacy)
export function getQuestionTypeLabel(type: TFormbricksSurveyQuestion["type"]): string {
switch (type) {
case "openText":
return "Open Text";
case "rating":
return "Rating";
case "nps":
return "NPS";
case "csat":
return "CSAT";
case "multipleChoice":
return "Multiple Choice";
case "checkbox":
return "Checkbox";
case "date":
return "Date";
default:
return type;
}
}
// Helper to get element type label (for real survey elements)
export function getElementTypeLabel(type: string): string {
switch (type) {
case "openText":
return "Open Text";
case "rating":
return "Rating";
case "nps":
return "NPS";
case "multipleChoiceSingle":
return "Single Choice";
case "multipleChoiceMulti":
return "Multiple Choice";
case "date":
return "Date";
case "consent":
return "Consent";
case "matrix":
return "Matrix";
case "ranking":
return "Ranking";
case "pictureSelection":
return "Picture Selection";
case "contactInfo":
return "Contact Info";
case "address":
return "Address";
case "fileUpload":
return "File Upload";
case "cal":
return "Calendar";
case "cta":
return "CTA";
default:
return type;
}
}
// Helper to map question type to FeedbackRecord field_type (legacy)
export function questionTypeToFieldType(type: TFormbricksSurveyQuestion["type"]): TFeedbackRecordFieldType {
switch (type) {
case "openText":
return "text";
case "rating":
return "rating";
case "nps":
return "nps";
case "csat":
return "csat";
case "multipleChoice":
case "checkbox":
return "categorical";
case "date":
return "date";
default:
return "text";
}
}
// Helper to map element type to Hub field_type (for real survey elements)
export function elementTypeToHubFieldType(type: string): TFeedbackRecordFieldType {
switch (type) {
case "openText":
return "text";
case "rating":
return "rating";
case "nps":
return "nps";
case "multipleChoiceSingle":
case "multipleChoiceMulti":
return "categorical";
case "date":
return "date";
case "consent":
return "boolean";
case "matrix":
case "ranking":
case "pictureSelection":
return "categorical";
case "contactInfo":
case "address":
case "fileUpload":
case "cal":
return "text";
case "cta":
return "boolean";
default:
return "text";
}
}
// Field mapping types - supports both source field mapping and static values
export interface TFieldMapping {
targetFieldId: string;
// Either map from a source field OR set a static value
sourceFieldId?: string;
staticValue?: string;
}
export interface TSourceConnection {
id: string;
name: string;
type: TSourceType;
mappings: TFieldMapping[];
createdAt: Date;
updatedAt: Date;
}
// FeedbackRecord field types (enum values for field_type)
export type TFeedbackRecordFieldType =
| "text"
| "categorical"
| "nps"
| "csat"
| "ces"
| "rating"
| "number"
| "boolean"
| "date";
// Field types for the Hub schema
export type TTargetFieldType = "string" | "enum" | "timestamp" | "float64" | "boolean" | "jsonb" | "string[]";
export interface TTargetField {
id: string;
name: string;
type: TTargetFieldType;
required: boolean;
description: string;
// For enum fields, the possible values
enumValues?: string[];
// For string fields, example static values that could be set
exampleStaticValues?: string[];
}
export interface TSourceField {
id: string;
name: string;
type: string;
sampleValue?: string;
}
// Enum values for field_type
export const FIELD_TYPE_ENUM_VALUES: TFeedbackRecordFieldType[] = [
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
];
// Target fields based on the FeedbackRecord schema
export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
// Required fields
{
id: "collected_at",
name: "Collected At",
type: "timestamp",
required: true,
description: "When the feedback was originally collected",
exampleStaticValues: ["$now"],
},
{
id: "source_type",
name: "Source Type",
type: "string",
required: true,
description: "Type of source (e.g., survey, review, support)",
exampleStaticValues: ["survey", "review", "support", "email", "qualtrics", "typeform", "intercom"],
},
{
id: "field_id",
name: "Field ID",
type: "string",
required: true,
description: "Unique question/field identifier",
},
{
id: "field_type",
name: "Field Type",
type: "enum",
required: true,
description: "Data type (text, nps, csat, rating, etc.)",
enumValues: FIELD_TYPE_ENUM_VALUES,
},
// Optional fields
{
id: "tenant_id",
name: "Tenant ID",
type: "string",
required: false,
description: "Tenant/organization identifier for multi-tenant deployments",
},
{
id: "response_id",
name: "Response ID",
type: "string",
required: false,
description: "Groups multiple answers from a single submission",
},
{
id: "source_id",
name: "Source ID",
type: "string",
required: false,
description: "Reference to survey/form/ticket/review ID",
},
{
id: "source_name",
name: "Source Name",
type: "string",
required: false,
description: "Human-readable source name for display",
exampleStaticValues: ["Product Feedback", "Customer Support", "NPS Survey", "Qualtrics Import"],
},
{
id: "field_label",
name: "Field Label",
type: "string",
required: false,
description: "Question text or field label for display",
},
{
id: "value_text",
name: "Value (Text)",
type: "string",
required: false,
description: "Text responses (feedback, comments, open-ended answers)",
},
{
id: "value_number",
name: "Value (Number)",
type: "float64",
required: false,
description: "Numeric responses (ratings, scores, NPS, CSAT)",
},
{
id: "value_boolean",
name: "Value (Boolean)",
type: "boolean",
required: false,
description: "Yes/no responses",
},
{
id: "value_date",
name: "Value (Date)",
type: "timestamp",
required: false,
description: "Date/datetime responses",
},
{
id: "metadata",
name: "Metadata",
type: "jsonb",
required: false,
description: "Flexible context (device, location, campaign, custom fields)",
},
{
id: "language",
name: "Language",
type: "string",
required: false,
description: "ISO 639-1 language code (e.g., en, de, fr)",
exampleStaticValues: ["en", "de", "fr", "es", "pt", "ja", "zh"],
},
{
id: "user_identifier",
name: "User Identifier",
type: "string",
required: false,
description: "Anonymous user ID for tracking (hashed, never PII)",
},
];
// Sample webhook payload for testing
export const SAMPLE_WEBHOOK_PAYLOAD = {
id: "resp_12345",
timestamp: "2024-01-15T10:30:00Z",
survey_id: "survey_abc",
survey_name: "Product Feedback Survey",
question_id: "q1",
question_text: "How satisfied are you with our product?",
answer_type: "rating",
answer_value: 4,
user_id: "user_xyz",
metadata: {
device: "mobile",
browser: "Safari",
},
};
// Email source fields (simplified)
export const EMAIL_SOURCE_FIELDS: TSourceField[] = [
{ id: "subject", name: "Subject", type: "string", sampleValue: "Feature Request: Dark Mode" },
{
id: "body",
name: "Body (Text)",
type: "string",
sampleValue: "I would love to see a dark mode option...",
},
];
// CSV sample columns
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
// Helper function to parse payload to source fields
export function parsePayloadToFields(payload: Record<string, unknown>): TSourceField[] {
const fields: TSourceField[] = [];
function extractFields(obj: Record<string, unknown>, prefix = ""): void {
for (const [key, value] of Object.entries(obj)) {
const fieldId = prefix ? `${prefix}.${key}` : key;
const fieldName = prefix ? `${prefix}.${key}` : key;
if (value === null || value === undefined) {
fields.push({
id: fieldId,
name: fieldName,
type: "string",
sampleValue: String(value),
});
} else if (Array.isArray(value)) {
// Handle arrays - expand first few elements with index notation
if (value.length === 0) {
fields.push({
id: fieldId,
name: fieldName,
type: "array",
sampleValue: "[]",
});
} else {
// Expand up to first 3 array elements
const maxItems = Math.min(value.length, 3);
for (let i = 0; i < maxItems; i++) {
const item = value[i];
const itemPrefix = `${fieldId}[${i}]`;
if (item !== null && typeof item === "object" && !Array.isArray(item)) {
// Array of objects - expand the object properties
extractFields(item as Record<string, unknown>, itemPrefix);
} else if (Array.isArray(item)) {
// Nested array
fields.push({
id: itemPrefix,
name: itemPrefix,
type: "array",
sampleValue: `[${item.length} items]`,
});
} else {
// Primitive array element
let type = "string";
if (typeof item === "number") type = "number";
if (typeof item === "boolean") type = "boolean";
fields.push({
id: itemPrefix,
name: itemPrefix,
type,
sampleValue: String(item),
});
}
}
// If there are more items, indicate that
if (value.length > 3) {
fields.push({
id: `${fieldId}[...]`,
name: `${fieldId}[...]`,
type: "info",
sampleValue: `+${value.length - 3} more items`,
});
}
}
} else if (typeof value === "object") {
// Handle nested objects
extractFields(value as Record<string, unknown>, fieldId);
} else {
// Handle primitives
let type = "string";
if (typeof value === "number") type = "number";
if (typeof value === "boolean") type = "boolean";
fields.push({
id: fieldId,
name: fieldName,
type,
sampleValue: String(value),
});
}
}
}
extractFields(payload);
return fields;
}
// Helper function to parse CSV columns to source fields
export function parseCSVColumnsToFields(columns: string): TSourceField[] {
return columns.split(",").map((col) => {
const trimmedCol = col.trim();
return {
id: trimmedCol,
name: trimmedCol,
type: "string",
sampleValue: `Sample ${trimmedCol}`,
};
});
}
// AI suggested mappings for different source types
// Maps source field IDs to target field IDs
export interface TAISuggestedMapping {
// Maps source field ID -> target field ID
fieldMappings: Record<string, string>;
// Static values to set on target fields
staticValues: Record<string, string>;
}
export const AI_SUGGESTED_MAPPINGS: Record<TSourceType, TAISuggestedMapping> = {
webhook: {
fieldMappings: {
timestamp: "collected_at",
survey_id: "source_id",
survey_name: "source_name",
question_id: "field_id",
question_text: "field_label",
answer_value: "value_number",
user_id: "user_identifier",
},
staticValues: {
source_type: "survey",
field_type: "rating",
},
},
email: {
fieldMappings: {
subject: "field_label",
body: "value_text",
},
staticValues: {
collected_at: "$now",
source_type: "email",
field_type: "text",
},
},
csv: {
fieldMappings: {
timestamp: "collected_at",
customer_id: "user_identifier",
rating: "value_number",
feedback_text: "value_text",
category: "field_label",
},
staticValues: {
source_type: "survey",
field_type: "rating",
},
},
formbricks: {
fieldMappings: {},
staticValues: {
source_type: "survey",
},
},
slack: {
fieldMappings: {},
staticValues: {
source_type: "support",
field_type: "text",
},
},
};
// Modal step types
export type TCreateSourceStep = "selectType" | "mapping";

View File

@@ -0,0 +1,10 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { SourcesSection } from "./components/sources-page-client";
export default async function UnifySourcesPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
return <SourcesSection environmentId={params.environmentId} />;
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import toast from "react-hot-toast";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
export type AddKeywordModalLevel = "L1" | "L2" | "L3";
interface AddKeywordModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
level: AddKeywordModalLevel;
parentName?: string;
onConfirm: (name: string) => void;
}
export function AddKeywordModal({
open,
onOpenChange,
level,
parentName,
onConfirm,
}: AddKeywordModalProps) {
const [name, setName] = useState("");
const handleClose = (nextOpen: boolean) => {
if (!nextOpen) setName("");
onOpenChange(nextOpen);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) {
toast.error("Please enter a keyword name.");
return;
}
onConfirm(trimmed);
setName("");
onOpenChange(false);
toast.success("Keyword added (demo).");
};
const title =
level === "L1" ? "Add L1 keyword" : level === "L2" ? "Add L2 keyword" : "Add L3 keyword";
const description =
level === "L1"
? "Add a new top-level keyword."
: parentName
? `Add a new ${level} keyword under "${parentName}".`
: `Add a new ${level} keyword.`;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<DialogBody>
<div className="space-y-2">
<Label htmlFor="keyword-name">Keyword name</Label>
<Input
id="keyword-name"
placeholder="e.g. New category"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full"
/>
</div>
</DialogBody>
<DialogFooter className="m-2">
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button type="submit">Add keyword</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import {
ChevronRightIcon,
LightbulbIcon,
MessageCircleIcon,
TriangleAlertIcon,
WrenchIcon,
} from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/cn";
import { Input } from "@/modules/ui/components/input";
import { H4, Small } from "@/modules/ui/components/typography";
import type { TaxonomyDetail, TaxonomyThemeItem } from "../types";
const THEME_COLORS: Record<string, string> = {
red: "bg-red-400",
orange: "bg-orange-400",
yellow: "bg-amber-400",
green: "bg-emerald-500",
slate: "bg-slate-400",
};;
function getThemeIcon(icon?: TaxonomyThemeItem["icon"]) {
switch (icon) {
case "warning":
return <TriangleAlertIcon className="size-4 text-amber-500" />;
case "wrench":
return <WrenchIcon className="size-4 text-slate-500" />;
case "message-circle":
return <MessageCircleIcon className="size-4 text-slate-500" />;
case "lightbulb":
return <LightbulbIcon className="size-4 text-amber-500" />;
default:
return <MessageCircleIcon className="size-4 text-slate-400" />;
}
}
interface ThemeItemRowProps {
item: TaxonomyThemeItem;
depth?: number;
themeSearch: string;
}
function ThemeItemRow({ item, depth = 0, themeSearch }: ThemeItemRowProps) {
const [expanded, setExpanded] = useState(depth === 0 && (item.children?.length ?? 0) > 0);
const hasChildren = item.children && item.children.length > 0;
const labelLower = item.label.toLowerCase();
const matchesSearch =
!themeSearch.trim() || labelLower.includes(themeSearch.trim().toLowerCase());
const childMatches =
hasChildren &&
item.children!.some((c) =>
c.label.toLowerCase().includes(themeSearch.trim().toLowerCase())
);
const show = matchesSearch || childMatches;
if (!show) return null;
return (
<div className="flex flex-col">
<div
className={cn(
"flex items-center gap-2 py-1.5 text-sm",
depth === 0 ? "font-medium text-slate-800" : "text-slate-600"
)}
style={{ paddingLeft: depth * 16 + 4 }}>
<button
type="button"
onClick={() => hasChildren && setExpanded(!expanded)}
className="flex shrink-0 items-center justify-center text-slate-400 hover:text-slate-600">
{hasChildren ? (
<ChevronRightIcon
className={cn("size-4 transition-transform", expanded && "rotate-90")}
/>
) : (
<span className="w-4" />
)}
</button>
{getThemeIcon(item.icon)}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<Small color="muted" className="shrink-0">
{item.count}
</Small>
</div>
{hasChildren && expanded && (
<div className="border-l border-slate-200 pl-2">
{item.children!.map((child) => (
<ThemeItemRow
key={child.id}
item={child}
depth={depth + 1}
themeSearch={themeSearch}
/>
))}
</div>
)}
</div>
);
}
interface TaxonomyDetailPanelProps {
detail: TaxonomyDetail | null;
}
export function TaxonomyDetailPanel({ detail }: TaxonomyDetailPanelProps) {
const [themeSearch, setThemeSearch] = useState("");
if (!detail) {
return (
<div className="flex flex-1 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<Small color="muted">Select a Level 3 keyword to view details.</Small>
</div>
</div>
);
}
const totalThemes = detail.themes.reduce((s, t) => s + t.count, 0);
return (
<div className="flex flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-col gap-5 overflow-y-auto p-4">
<div className="border-b border-slate-200 pb-4">
<H4 className="mb-1">{detail.keywordName}</H4>
<div className="flex items-center gap-2">
<Small color="muted">{detail.count} responses</Small>
<button
type="button"
className="text-sm font-medium text-slate-600 underline-offset-2 hover:underline">
View all
</button>
</div>
</div>
<div>
<H4 className="mb-1 text-sm">Description</H4>
<Small color="muted" className="leading-relaxed">
{detail.description}
</Small>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<H4 className="text-sm">{detail.themes.length} themes</H4>
<div className="flex h-2 flex-1 max-w-[120px] overflow-hidden rounded-full bg-slate-100">
{detail.themes.map((t) => (
<div
key={t.id}
className={cn(THEME_COLORS[t.color] ?? "bg-slate-400")}
style={{
width: totalThemes ? `${(t.count / totalThemes) * 100}%` : "0%",
}}
title={t.label}
/>
))}
</div>
</div>
<Input
placeholder="Search themes"
value={themeSearch}
onChange={(e) => setThemeSearch(e.target.value)}
className="mb-3 h-9 text-sm"
/>
<div className="space-y-0.5">
{detail.themeItems.map((item) => (
<ThemeItemRow key={item.id} item={item} themeSearch={themeSearch} />
))}
</div>
</div>
</div>
</div>
);
}

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