mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-28 17:40:47 -05:00
Compare commits
4 Commits
referencee
...
feat/attri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdddf034f2 | ||
|
|
5555112e56 | ||
|
|
7c92e2b5bb | ||
|
|
e90bb93dfb |
404
.cursor/plans/date_attribute_type_feature_6f67ae57.plan.md
Normal file
404
.cursor/plans/date_attribute_type_feature_6f67ae57.plan.md
Normal file
@@ -0,0 +1,404 @@
|
||||
---
|
||||
name: Date Attribute Type Feature
|
||||
overview: Add DATE type support to the Formbricks attribute system, enabling time-based segment filters like "Sign Up Date is older than 3 months". This involves schema changes, new operators, UI components, SDK updates, and evaluation logic.
|
||||
todos:
|
||||
- id: schema
|
||||
content: Add ContactAttributeDataType enum and dataType field to ContactAttributeKey in Prisma schema
|
||||
status: completed
|
||||
- id: types
|
||||
content: "Update type definitions: add data type to contact-attribute-key.ts, add date operators to segment.ts"
|
||||
status: completed
|
||||
- id: zod
|
||||
content: Update Zod schemas in packages/database/zod/ to include dataType
|
||||
status: completed
|
||||
- id: detect
|
||||
content: Create auto-detection logic for attribute data types based on value format
|
||||
status: completed
|
||||
- id: attributes
|
||||
content: Update attribute creation/update logic to auto-detect and persist dataType
|
||||
status: completed
|
||||
- id: date-utils
|
||||
content: Create date utility functions for relative time calculations
|
||||
status: completed
|
||||
- id: eval-logic
|
||||
content: Add date filter evaluation logic to segments.ts evaluateSegment function
|
||||
status: completed
|
||||
- id: prisma-query
|
||||
content: Update prisma-query.ts to handle date comparisons in segment filters
|
||||
status: completed
|
||||
- id: ui-operators
|
||||
content: Update segment-filter.tsx to show date-specific operators when attribute is DATE type
|
||||
status: completed
|
||||
- id: ui-value
|
||||
content: Create date-filter-value.tsx component for date filter value input
|
||||
status: completed
|
||||
- id: utils
|
||||
content: Add date operator text/title conversions in utils.ts
|
||||
status: completed
|
||||
- id: sdk
|
||||
content: Update JS SDK to accept Date objects and convert to ISO strings
|
||||
status: completed
|
||||
- id: api
|
||||
content: Update API endpoints to expose dataType in contact attribute key responses
|
||||
status: completed
|
||||
- id: i18n
|
||||
content: Add translation keys for new operators and UI elements
|
||||
status: completed
|
||||
- id: tests
|
||||
content: Add unit tests for date detection, evaluation, and UI components
|
||||
status: completed
|
||||
---
|
||||
|
||||
# Date Attribute Type Feature
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
The attribute system currently stores all values as strings:
|
||||
|
||||
- `ContactAttribute.value` is `String` in Prisma schema (line 73)
|
||||
- `ContactAttributeKey` has no `dataType` field - only `type` (default/custom)
|
||||
- Segment filter operators are string/number-focused with no date awareness
|
||||
- SDK accepts `Record<string, string>` only
|
||||
|
||||
## Architecture Changes
|
||||
|
||||
### 1. Database Schema Updates
|
||||
|
||||
Add `dataType` enum and field to `ContactAttributeKey`:
|
||||
|
||||
```prisma
|
||||
enum ContactAttributeDataType {
|
||||
text
|
||||
number
|
||||
date
|
||||
}
|
||||
|
||||
model ContactAttributeKey {
|
||||
// ... existing fields
|
||||
dataType ContactAttributeDataType @default(text)
|
||||
}
|
||||
```
|
||||
|
||||
Store dates as ISO 8601 strings in `ContactAttribute.value` (no schema change needed for value column).
|
||||
|
||||
### 2. Type Definitions (`packages/types/`)
|
||||
|
||||
**`packages/types/contact-attribute-key.ts`** - Add data type:
|
||||
|
||||
```typescript
|
||||
export const ZContactAttributeDataType = z.enum(["text", "number", "date"]);
|
||||
export type TContactAttributeDataType = z.infer<typeof ZContactAttributeDataType>;
|
||||
```
|
||||
|
||||
**`packages/types/segment.ts`** - Add date operators:
|
||||
|
||||
```typescript
|
||||
export const DATE_OPERATORS = [
|
||||
"isOlderThan", // relative: X days/weeks/months/years ago
|
||||
"isNewerThan", // relative: within last X days/weeks/months/years
|
||||
"isBefore", // absolute: before specific date
|
||||
"isAfter", // absolute: after specific date
|
||||
"isBetween", // absolute: between two dates
|
||||
"isSameDay", // absolute: matches specific date
|
||||
] as const;
|
||||
|
||||
export const TIME_UNITS = ["days", "weeks", "months", "years"] as const;
|
||||
|
||||
export const ZSegmentDateFilter = z.object({
|
||||
id: z.string().cuid2(),
|
||||
root: z.object({
|
||||
type: z.literal("attribute"),
|
||||
contactAttributeKey: z.string(),
|
||||
}),
|
||||
value: z.union([
|
||||
// Relative: { amount: 3, unit: "months" }
|
||||
z.object({ amount: z.number(), unit: z.enum(TIME_UNITS) }),
|
||||
// Absolute: ISO date string or [start, end] for between
|
||||
z.string(),
|
||||
z.tuple([z.string(), z.string()]),
|
||||
]),
|
||||
qualifier: z.object({
|
||||
operator: z.enum(DATE_OPERATORS),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Auto-Detection Logic (`apps/web/modules/ee/contacts/lib/`)
|
||||
|
||||
Create `detect-attribute-type.ts`:
|
||||
|
||||
```typescript
|
||||
export const detectAttributeDataType = (value: string): TContactAttributeDataType => {
|
||||
// Check if valid ISO 8601 date
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime()) && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
return "date";
|
||||
}
|
||||
// Check if numeric
|
||||
if (!isNaN(Number(value)) && value.trim() !== "") {
|
||||
return "number";
|
||||
}
|
||||
return "text";
|
||||
};
|
||||
```
|
||||
|
||||
Update `apps/web/modules/ee/contacts/lib/attributes.ts` to auto-detect and set `dataType` when creating new attribute keys.
|
||||
|
||||
### 4. Segment Filter UI Components
|
||||
|
||||
**New files in `apps/web/modules/ee/contacts/segments/components/`:**
|
||||
|
||||
- `date-filter-value.tsx` - Combined component for date filter value input:
|
||||
- Relative time: number input + unit dropdown (days/weeks/months/years)
|
||||
- Absolute date: date picker component
|
||||
- Between: two date pickers for range
|
||||
|
||||
- Update `segment-filter.tsx`:
|
||||
- Check `contactAttributeKey.dataType` to determine which operators to show
|
||||
- Render appropriate value input based on operator type
|
||||
- Handle date-specific validation
|
||||
|
||||
### 5. Filter Evaluation Logic
|
||||
|
||||
Update `apps/web/modules/ee/contacts/segments/lib/segments.ts`:
|
||||
|
||||
```typescript
|
||||
const evaluateDateFilter = (
|
||||
attributeValue: string,
|
||||
filterValue: TDateFilterValue,
|
||||
operator: TDateOperator
|
||||
): boolean => {
|
||||
const attrDate = new Date(attributeValue);
|
||||
const now = new Date();
|
||||
|
||||
switch (operator) {
|
||||
case "isOlderThan": {
|
||||
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
|
||||
return attrDate < threshold;
|
||||
}
|
||||
case "isNewerThan": {
|
||||
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
|
||||
return attrDate >= threshold;
|
||||
}
|
||||
case "isBefore":
|
||||
return attrDate < new Date(filterValue);
|
||||
case "isAfter":
|
||||
return attrDate > new Date(filterValue);
|
||||
case "isBetween":
|
||||
return attrDate >= new Date(filterValue[0]) && attrDate <= new Date(filterValue[1]);
|
||||
case "isSameDay":
|
||||
return isSameDay(attrDate, new Date(filterValue));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 6. Prisma Query Generation (No Raw SQL)
|
||||
|
||||
Update `apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts`:
|
||||
|
||||
Since dates are stored as ISO 8601 strings, lexicographic string comparison works correctly (e.g., `"2024-01-15" < "2024-02-01"`). Calculate threshold dates in JavaScript and pass as ISO strings:
|
||||
|
||||
```typescript
|
||||
const buildDateAttributeFilterWhereClause = (filter: TSegmentDateFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { operator } = qualifier;
|
||||
const now = new Date();
|
||||
|
||||
let dateCondition: Prisma.StringFilter = {};
|
||||
|
||||
switch (operator) {
|
||||
case "isOlderThan": {
|
||||
const threshold = subtractTimeUnit(now, value.amount, value.unit);
|
||||
dateCondition = { lt: threshold.toISOString() };
|
||||
break;
|
||||
}
|
||||
case "isNewerThan": {
|
||||
const threshold = subtractTimeUnit(now, value.amount, value.unit);
|
||||
dateCondition = { gte: threshold.toISOString() };
|
||||
break;
|
||||
}
|
||||
case "isBefore":
|
||||
dateCondition = { lt: value };
|
||||
break;
|
||||
case "isAfter":
|
||||
dateCondition = { gt: value };
|
||||
break;
|
||||
case "isBetween":
|
||||
dateCondition = { gte: value[0], lte: value[1] };
|
||||
break;
|
||||
case "isSameDay": {
|
||||
const dayStart = startOfDay(new Date(value)).toISOString();
|
||||
const dayEnd = endOfDay(new Date(value)).toISOString();
|
||||
dateCondition = { gte: dayStart, lte: dayEnd };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: root.contactAttributeKey },
|
||||
value: dateCondition,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Backwards Compatibility Concerns
|
||||
|
||||
### 1. API Response Changes (Non-Breaking)
|
||||
|
||||
- **Concern**: Adding `dataType` to `ContactAttributeKey` responses
|
||||
- **Solution**: This is an additive change - existing clients ignore unknown fields
|
||||
- **Action**: No breaking change, just document the new field
|
||||
|
||||
### 2. API Request Changes (Non-Breaking)
|
||||
|
||||
- **Concern**: Existing integrations create attributes without specifying `dataType`
|
||||
- **Solution**: Make `dataType` optional in create/update requests; auto-detect from value if not provided
|
||||
- **Action**: Default to auto-detection, allow explicit override
|
||||
|
||||
### 3. SDK Signature Change (Backwards Compatible)
|
||||
|
||||
- **Concern**: Current signature `Record<string, string>` changing to `Record<string, string | Date>`
|
||||
- **Solution**: TypeScript union types are backwards compatible - existing string values work
|
||||
- **Action**: Existing code continues to work; Date objects are a new optional capability
|
||||
|
||||
### 4. Existing Segment Filters (Critical)
|
||||
|
||||
- **Concern**: Existing filters in database use current operator format
|
||||
- **Solution**:
|
||||
- Keep all existing operators functional
|
||||
- Date operators only appear in UI when attribute has `dataType: "date"`
|
||||
- Filter evaluation checks operator type and routes to appropriate handler
|
||||
- **Action**: Add `isDateOperator()` check in evaluation logic
|
||||
|
||||
### 5. Filter Value Schema Change (Requires Careful Handling)
|
||||
|
||||
- **Concern**: Current `TSegmentFilterValue = string | number`, dates need `{ amount, unit }` for relative
|
||||
- **Solution**: Extend the union type, not replace:
|
||||
```typescript
|
||||
export const ZSegmentFilterValue = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.object({ amount: z.number(), unit: z.enum(TIME_UNITS) }), // NEW
|
||||
z.tuple([z.string(), z.string()]), // NEW: for "between" operator
|
||||
]);
|
||||
```
|
||||
|
||||
- **Action**: Existing filters parse correctly; new format only used for date operators
|
||||
|
||||
### 6. Database Migration (Safe)
|
||||
|
||||
- **Concern**: Adding `dataType` column to existing `ContactAttributeKey` rows
|
||||
- **Solution**:
|
||||
- Add column with `@default(text)`
|
||||
- All existing attributes become `text` type automatically
|
||||
- No data transformation needed
|
||||
- **Action**: Simple additive migration, no downtime
|
||||
|
||||
### 7. Segment Evaluation at Runtime
|
||||
|
||||
- **Concern**: Old segments with text operators should not break
|
||||
- **Solution**:
|
||||
- `evaluateAttributeFilter()` checks if operator is date-specific
|
||||
- If yes, calls `evaluateDateFilter()`
|
||||
- If no, uses existing `compareValues()` logic
|
||||
- **Action**: Add operator type routing in evaluation
|
||||
|
||||
### 8. Client-Side Segment Evaluation (JS SDK)
|
||||
|
||||
- **Concern**: SDK may evaluate segments client-side for performance
|
||||
- **Solution**: Ensure SDK's segment evaluation logic also handles date operators
|
||||
- **Action**: Update `packages/js-core` if client-side evaluation exists
|
||||
|
||||
### Version Matrix
|
||||
|
||||
| Component | Breaking Change | Migration Required |
|
||||
|
||||
|-----------|-----------------|-------------------|
|
||||
|
||||
| Database Schema | No | Yes (additive) |
|
||||
|
||||
| REST API | No | No |
|
||||
|
||||
| JS SDK | No | No (optional upgrade) |
|
||||
|
||||
| Existing Segments | No | No |
|
||||
|
||||
| UI | No | No |
|
||||
|
||||
### 7. SDK Updates (`packages/js-core/`)
|
||||
|
||||
Update `packages/js-core/src/lib/user/attribute.ts`:
|
||||
|
||||
```typescript
|
||||
export const setAttributes = async (
|
||||
attributes: Record<string, string | Date>
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
// Convert Date objects to ISO strings
|
||||
const normalizedAttributes = Object.fromEntries(
|
||||
Object.entries(attributes).map(([key, value]) => [
|
||||
key,
|
||||
value instanceof Date ? value.toISOString() : value,
|
||||
])
|
||||
);
|
||||
// ... rest of implementation
|
||||
};
|
||||
```
|
||||
|
||||
### 8. API Updates
|
||||
|
||||
Update attribute endpoints to include `dataType` in responses:
|
||||
|
||||
- `apps/web/modules/api/v2/management/contact-attribute-keys/`
|
||||
- `apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/`
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|
||||
|------|--------|
|
||||
|
||||
| `packages/database/schema.prisma` | Add `ContactAttributeDataType` enum, add `dataType` field |
|
||||
|
||||
| `packages/types/contact-attribute-key.ts` | Add data type definitions |
|
||||
|
||||
| `packages/types/segment.ts` | Add date operators, time units, date filter schema |
|
||||
|
||||
| `packages/database/zod/contact-attribute-keys.ts` | Add dataType to zod schema |
|
||||
|
||||
| `apps/web/modules/ee/contacts/lib/attributes.ts` | Auto-detect dataType on attribute creation |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/lib/segments.ts` | Add date filter evaluation |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts` | Add date query building |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/lib/utils.ts` | Add date operator text/title conversions |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/components/segment-filter.tsx` | Conditionally render date operators/inputs |
|
||||
|
||||
| `packages/js-core/src/lib/user/attribute.ts` | Accept Date objects |
|
||||
|
||||
## New Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|
||||
|------|---------|
|
||||
|
||||
| `apps/web/modules/ee/contacts/lib/detect-attribute-type.ts` | Auto-detection logic |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/components/date-filter-value.tsx` | Date filter value UI |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/lib/date-utils.ts` | Date comparison utilities |
|
||||
|
||||
## Migration
|
||||
|
||||
Create Prisma migration:
|
||||
|
||||
```bash
|
||||
pnpm db:migrate:dev --name add_contact_attribute_data_type
|
||||
```
|
||||
|
||||
Default existing attributes to `text` dataType (no data migration needed).
|
||||
61
.cursor/rules/build-and-deployment.mdc
Normal file
61
.cursor/rules/build-and-deployment.mdc
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Build & Deployment Best Practices
|
||||
|
||||
## Build Process
|
||||
|
||||
### Running Builds
|
||||
- Use `pnpm build` from project root for full build
|
||||
- Monitor for React hooks warnings and fix them immediately
|
||||
- Ensure all TypeScript errors are resolved before deployment
|
||||
|
||||
### Common Build Issues & Fixes
|
||||
|
||||
#### React Hooks Warnings
|
||||
- Capture ref values in variables within useEffect cleanup
|
||||
- Avoid accessing `.current` directly in cleanup functions
|
||||
- Pattern for fixing ref cleanup warnings:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const currentRef = myRef.current;
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
currentRef.cleanup();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
#### Test Failures During Build
|
||||
- Ensure all test mocks include required constants like `SESSION_MAX_AGE`
|
||||
- Mock Next.js navigation hooks properly: `useParams`, `useRouter`, `useSearchParams`
|
||||
- Remove unused imports and constants from test files
|
||||
- Use literal values instead of imported constants when the constant isn't actually needed
|
||||
|
||||
### Test Execution
|
||||
- Run `pnpm test` to execute all tests
|
||||
- Use `pnpm test -- --run filename.test.tsx` for specific test files
|
||||
- Fix test failures before merging code
|
||||
- Ensure 100% test coverage for new components
|
||||
|
||||
### Performance Monitoring
|
||||
- Monitor build times and optimize if necessary
|
||||
- Watch for memory usage during builds
|
||||
- Use proper caching strategies for faster rebuilds
|
||||
|
||||
### Deployment Checklist
|
||||
1. All tests passing
|
||||
2. Build completes without warnings
|
||||
3. TypeScript compilation successful
|
||||
4. No linter errors
|
||||
5. Database migrations applied (if any)
|
||||
6. Environment variables configured
|
||||
|
||||
### EKS Deployment Considerations
|
||||
- Ensure latest code is deployed to all pods
|
||||
- Monitor AWS RDS Performance Insights for database issues
|
||||
- Verify environment-specific configurations
|
||||
- Check pod health and resource usage
|
||||
415
.cursor/rules/cache-optimization.mdc
Normal file
415
.cursor/rules/cache-optimization.mdc
Normal file
@@ -0,0 +1,415 @@
|
||||
---
|
||||
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)
|
||||
41
.cursor/rules/database-performance.mdc
Normal file
41
.cursor/rules/database-performance.mdc
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Database Performance & Prisma Best Practices
|
||||
|
||||
## Critical Performance Rules
|
||||
|
||||
### Response Count Queries
|
||||
- **NEVER** use `skip`/`offset` with `prisma.response.count()` - this causes expensive subqueries with OFFSET
|
||||
- Always use only `where` clauses for count operations: `prisma.response.count({ where: { ... } })`
|
||||
- For pagination, separate count queries from data queries
|
||||
- Reference: [apps/web/lib/response/service.ts](mdc:apps/web/lib/response/service.ts) line 654-686
|
||||
|
||||
### Prisma Query Optimization
|
||||
- Use proper indexes defined in [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
|
||||
- Leverage existing indexes: `@@index([surveyId, createdAt])`, `@@index([createdAt])`
|
||||
- Use cursor-based pagination for large datasets instead of offset-based
|
||||
- Cache frequently accessed data using React Cache and custom cache tags
|
||||
|
||||
### Date Range Filtering
|
||||
- When filtering by `createdAt`, always use indexed queries
|
||||
- Combine with `surveyId` for optimal performance: `{ surveyId, createdAt: { gte: start, lt: end } }`
|
||||
- Avoid complex WHERE clauses that can't utilize indexes
|
||||
|
||||
### Count vs Data Separation
|
||||
- Always separate count queries from data fetching queries
|
||||
- Use `Promise.all()` to run count and data queries in parallel
|
||||
- Example pattern from [apps/web/modules/api/v2/management/responses/lib/response.ts](mdc:apps/web/modules/api/v2/management/responses/lib/response.ts):
|
||||
```typescript
|
||||
const [responses, totalCount] = await Promise.all([
|
||||
prisma.response.findMany(query),
|
||||
prisma.response.count({ where: whereClause }),
|
||||
]);
|
||||
```
|
||||
|
||||
### Monitoring & Debugging
|
||||
- Monitor AWS RDS Performance Insights for problematic queries
|
||||
- Look for queries with OFFSET in count operations - these indicate performance issues
|
||||
- Use proper error handling with `DatabaseError` for Prisma exceptions
|
||||
105
.cursor/rules/database.mdc
Normal file
105
.cursor/rules/database.mdc
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
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.
|
||||
28
.cursor/rules/documentations.mdc
Normal file
28
.cursor/rules/documentations.mdc
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
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>
|
||||
332
.cursor/rules/formbricks-architecture.mdc
Normal file
332
.cursor/rules/formbricks-architecture.mdc
Normal file
@@ -0,0 +1,332 @@
|
||||
---
|
||||
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
|
||||
232
.cursor/rules/github-actions-security.mdc
Normal file
232
.cursor/rules/github-actions-security.mdc
Normal file
@@ -0,0 +1,232 @@
|
||||
---
|
||||
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)
|
||||
457
.cursor/rules/i18n-management.mdc
Normal file
457
.cursor/rules/i18n-management.mdc
Normal file
@@ -0,0 +1,457 @@
|
||||
---
|
||||
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
|
||||
52
.cursor/rules/react-context-patterns.mdc
Normal file
52
.cursor/rules/react-context-patterns.mdc
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# React Context & Provider Patterns
|
||||
|
||||
## Context Provider Best Practices
|
||||
|
||||
### Provider Implementation
|
||||
- Use TypeScript interfaces for provider props with optional `initialCount` for testing
|
||||
- Implement proper cleanup in `useEffect` to avoid React hooks warnings
|
||||
- Reference: [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx)
|
||||
|
||||
### Cleanup Pattern for Refs
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const currentPendingRequests = pendingRequests.current;
|
||||
const currentAbortController = abortController.current;
|
||||
|
||||
return () => {
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
currentPendingRequests.clear();
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Testing Context Providers
|
||||
- Always wrap components using context in the provider during tests
|
||||
- Use `initialCount` prop for predictable test scenarios
|
||||
- Mock context dependencies like `useParams`, `useResponseFilter`
|
||||
- Example from [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx):
|
||||
|
||||
```typescript
|
||||
render(
|
||||
<ResponseCountProvider survey={dummySurvey} initialCount={5}>
|
||||
<ComponentUnderTest />
|
||||
</ResponseCountProvider>
|
||||
);
|
||||
```
|
||||
|
||||
### Required Mocks for Context Testing
|
||||
- Mock `next/navigation` with `useParams` returning environment and survey IDs
|
||||
- Mock response filter context and actions
|
||||
- Mock API actions that the provider depends on
|
||||
|
||||
### Context Hook Usage
|
||||
- Create custom hooks like `useResponseCountContext()` for consuming context
|
||||
- Provide meaningful error messages when context is used outside provider
|
||||
- Use context for shared state that multiple components need to access
|
||||
179
.cursor/rules/review-and-refine.mdc
Normal file
179
.cursor/rules/review-and-refine.mdc
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
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.**
|
||||
216
.cursor/rules/storybook-component-migration.mdc
Normal file
216
.cursor/rules/storybook-component-migration.mdc
Normal file
@@ -0,0 +1,216 @@
|
||||
---
|
||||
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)
|
||||
177
.cursor/rules/storybook-create-new-story.mdc
Normal file
177
.cursor/rules/storybook-create-new-story.mdc
Normal file
@@ -0,0 +1,177 @@
|
||||
---
|
||||
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.
|
||||
36
.github/workflows/pr-size-check.yml
vendored
36
.github/workflows/pr-size-check.yml
vendored
@@ -111,21 +111,27 @@ jobs:
|
||||
const additions = ${{ steps.check-size.outputs.total_additions }};
|
||||
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
||||
|
||||
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.';
|
||||
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.`;
|
||||
|
||||
// Check if we already commented
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Load environment variables from .env files
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
|
||||
54
AGENTS.md
54
AGENTS.md
@@ -18,65 +18,11 @@ 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.
|
||||
|
||||
@@ -11,24 +11,24 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/survey-ui": "workspace:*"
|
||||
"@formbricks/survey-ui": "workspace:*",
|
||||
"eslint-plugin-react-refresh": "0.4.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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.27.2",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.1.11",
|
||||
"@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",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "10.1.11",
|
||||
"vite": "7.3.1",
|
||||
"@storybook/addon-docs": "10.1.11"
|
||||
"storybook": "10.0.8",
|
||||
"vite": "7.2.4",
|
||||
"@storybook/addon-docs": "10.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
node_modules/
|
||||
.next/
|
||||
public/
|
||||
playwright/
|
||||
dist/
|
||||
coverage/
|
||||
vendor/
|
||||
@@ -104,9 +104,6 @@ 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
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ export const OrganizationBreadcrumb = ({
|
||||
)}
|
||||
{!isLoadingOrganizations && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
|
||||
@@ -234,7 +234,7 @@ export const ProjectBreadcrumb = ({
|
||||
)}
|
||||
{!isLoadingProjects && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const SecurityListTip = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center space-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
|
||||
<ShieldCheckIcon className="h-5 w-5 flex-shrink-0 text-blue-400" />
|
||||
<p className="text-sm">
|
||||
{t("environments.settings.general.security_list_tip")}{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/security#stay-informed-with-formbricks-security-updates"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-blue-700">
|
||||
{t("environments.settings.general.security_list_tip_link")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,6 @@ 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;
|
||||
@@ -49,7 +48,6 @@ 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")}>
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { buildBlock } from "./survey-block-builder";
|
||||
|
||||
const mockT = vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"common.next": "Next",
|
||||
"common.back": "Back",
|
||||
"": "",
|
||||
};
|
||||
return translations[key] || key;
|
||||
}) as unknown as TFunction;
|
||||
|
||||
describe("survey-block-builder", () => {
|
||||
describe("buildBlock", () => {
|
||||
const mockElements = [
|
||||
{
|
||||
id: "element-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: createI18nString("Test Question", []),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
longAnswer: false,
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
];
|
||||
|
||||
test("should use getDefaultButtonLabel when buttonLabel is provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
buttonLabel: "Custom Next",
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.buttonLabel).toEqual({
|
||||
default: "Custom Next",
|
||||
});
|
||||
});
|
||||
|
||||
test("should use createI18nString with empty translation when buttonLabel is not provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.buttonLabel).toEqual({
|
||||
default: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("should use getDefaultBackButtonLabel when backButtonLabel is provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
backButtonLabel: "Custom Back",
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.backButtonLabel).toEqual({
|
||||
default: "Custom Back",
|
||||
});
|
||||
});
|
||||
|
||||
test("should use createI18nString with empty translation when backButtonLabel is not provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.backButtonLabel).toEqual({
|
||||
default: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -302,9 +302,7 @@ export const buildBlock = ({
|
||||
elements,
|
||||
logic,
|
||||
logicFallback,
|
||||
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : createI18nString(t(""), []),
|
||||
backButtonLabel: backButtonLabel
|
||||
? getDefaultBackButtonLabel(backButtonLabel, t)
|
||||
: createI18nString(t(""), []),
|
||||
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : undefined,
|
||||
backButtonLabel: backButtonLabel ? getDefaultBackButtonLabel(backButtonLabel, t) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -61,10 +61,6 @@ checksums:
|
||||
auth/signup/password_validation_uppercase_and_lowercase: ae98b485024dbff1022f6048e22443cd
|
||||
auth/signup/please_verify_captcha: 12938ca7ca13e3f933737dd5436fa1c0
|
||||
auth/signup/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
|
||||
auth/signup/product_updates_description: f20eedb2cf42d2235b1fe0294086695b
|
||||
auth/signup/product_updates_title: 31e099ba18abb0a49f8a75fece1f1791
|
||||
auth/signup/security_updates_description: 4643df07f13cec619e7fd91c8f14d93b
|
||||
auth/signup/security_updates_title: de5127f5847cdd412906607e1402f48d
|
||||
auth/signup/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||
auth/signup/title: 96addc349f834eaa5d14c786d5478b1c
|
||||
auth/signup_without_verification_success/user_successfully_created: ff849ebedc5dacb36493d7894f16edc7
|
||||
@@ -958,8 +954,6 @@ checksums:
|
||||
environments/settings/general/remove_logo: f60f1803e6fc8017b1eae7c30089107f
|
||||
environments/settings/general/replace_logo: e3c8bec7574a670607e88771164e272f
|
||||
environments/settings/general/resend_invitation_email: 6305d1ffa015c377ef59fe9c2661cf02
|
||||
environments/settings/general/security_list_tip: 0bbed89fa5265da7e07767087f87c736
|
||||
environments/settings/general/security_list_tip_link: ccdb1a21610ebf5a626d813b155be4ba
|
||||
environments/settings/general/share_invite_link: b40b7ffbcf02d7464be52fb562df5e3a
|
||||
environments/settings/general/share_this_link_to_let_your_organization_member_join_your_organization: 6eb43d5b1c855572b7ab35f527ba953c
|
||||
environments/settings/general/test_email_sent_successfully: aa68214f5e0707c9615e01343640ab32
|
||||
@@ -1111,9 +1105,6 @@ checksums:
|
||||
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
|
||||
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
|
||||
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
|
||||
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
|
||||
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
|
||||
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
|
||||
environments/surveys/edit/automatically_close_survey_after: 3e1c400a4b226c875dc8337e3b204d85
|
||||
environments/surveys/edit/automatically_close_the_survey_after_a_certain_number_of_responses: 2beee129dca506f041e5d1e6a1688310
|
||||
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
|
||||
@@ -1395,7 +1386,6 @@ checksums:
|
||||
environments/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366
|
||||
environments/surveys/edit/prevent_double_submission: afc502baa2da81d9c9618da1c3b5a57a
|
||||
environments/surveys/edit/prevent_double_submission_description: ef7d2aa22d43bdc6ccebb076c6aa9ce5
|
||||
environments/surveys/edit/progress_saved: d7bfc189571f08bbb4d0240cb9363ffa
|
||||
environments/surveys/edit/protect_survey_with_pin: 16d1925b6a5770f7423772d6d9a8291a
|
||||
environments/surveys/edit/protect_survey_with_pin_description: 0e55d19b6f3578b1024e03606172a5d2
|
||||
environments/surveys/edit/publish: 4aa95ba4793bb293e771bd73b4f87c0f
|
||||
|
||||
@@ -444,11 +444,11 @@ describe("Crypto Utils", () => {
|
||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called with the correct format (object first, message second)
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||
expect(firstArg).toHaveProperty("err");
|
||||
expect(firstArg.err).toHaveProperty("message");
|
||||
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
});
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with corrupted encrypted data", () => {
|
||||
@@ -472,11 +472,11 @@ describe("Crypto Utils", () => {
|
||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||
expect(firstArg).toHaveProperty("err");
|
||||
expect(firstArg.err).toHaveProperty("message");
|
||||
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
});
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with wrong key", () => {
|
||||
@@ -496,11 +496,11 @@ describe("Crypto Utils", () => {
|
||||
expect(() => symmetricDecrypt(payload, wrongKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||
expect(firstArg).toHaveProperty("err");
|
||||
expect(firstArg.err).toHaveProperty("message");
|
||||
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode
|
||||
return language?.default ? "default" : language?.language.code || "default";
|
||||
};
|
||||
|
||||
export const iso639Identifiers = iso639Languages.map((language) => language.code);
|
||||
export const iso639Identifiers = iso639Languages.map((language) => language.alpha2);
|
||||
|
||||
// Helper function to add language keys to a multi-language object (e.g. survey or question)
|
||||
// Iterates over the object recursively and adds empty strings for new language keys
|
||||
|
||||
@@ -308,10 +308,6 @@ describe("Tests for updateSurvey", () => {
|
||||
const updatedSurvey = await updateSurvey(updateSurveyInput);
|
||||
expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
|
||||
});
|
||||
|
||||
// Note: Language handling tests (for languages.length > 0 fix) are covered in
|
||||
// apps/web/modules/survey/editor/lib/survey.test.ts where we have better control
|
||||
// over the test mocks. The key fix ensures languages.length > 0 (not > 1) is used.
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
|
||||
@@ -329,7 +329,7 @@ export const updateSurveyInternal = async (
|
||||
? currentSurvey.languages.map((l) => l.language.id)
|
||||
: [];
|
||||
const updatedLanguageIds =
|
||||
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
||||
languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
||||
const enabledLanguageIds = languages.map((language) => {
|
||||
if (language.enabled) return language.language.id;
|
||||
});
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mix aus Groß- und Kleinbuchstaben",
|
||||
"please_verify_captcha": "Bitte bestätige reCAPTCHA",
|
||||
"privacy_policy": "Datenschutzerklärung",
|
||||
"product_updates_description": "Monatliche Produktneuigkeiten und Feature-Updates, es gilt die Datenschutzerklärung.",
|
||||
"product_updates_title": "Produkt-Updates",
|
||||
"security_updates_description": "Nur sicherheitsrelevante Informationen, es gilt die Datenschutzerklärung.",
|
||||
"security_updates_title": "Sicherheits-Updates",
|
||||
"terms_of_service": "Nutzungsbedingungen",
|
||||
"title": "Erstelle dein Formbricks-Konto"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "Logo entfernen",
|
||||
"replace_logo": "Logo ersetzen",
|
||||
"resend_invitation_email": "Einladungsemail erneut senden",
|
||||
"security_list_tip": "Haben Sie sich für unsere Sicherheitsliste angemeldet? Bleiben Sie informiert, um Ihre Instanz sicher zu halten!",
|
||||
"security_list_tip_link": "Hier registrieren.",
|
||||
"share_invite_link": "Einladungslink teilen",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Teile diesen Link, damit dein Organisationsmitglied deiner Organisation beitreten kann:",
|
||||
"test_email_sent_successfully": "Test-E-Mail erfolgreich gesendet",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "Zuweisen =",
|
||||
"audience": "Publikum",
|
||||
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
|
||||
"auto_save_disabled": "Automatisches Speichern deaktiviert",
|
||||
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
|
||||
"auto_save_on": "Automatisches Speichern an",
|
||||
"automatically_close_survey_after": "Umfrage automatisch schließen nach",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "Bitte angeben",
|
||||
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
|
||||
"prevent_double_submission_description": "Nur eine Antwort pro E-Mail-Adresse zulassen (beta)",
|
||||
"progress_saved": "Fortschritt gespeichert",
|
||||
"protect_survey_with_pin": "Umfrage mit einer PIN schützen",
|
||||
"protect_survey_with_pin_description": "Nur Benutzer, die die PIN haben, können auf die Umfrage zugreifen.",
|
||||
"publish": "Veröffentlichen",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mix of uppercase and lowercase",
|
||||
"please_verify_captcha": "Please verify reCAPTCHA",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"product_updates_description": "Monthly product news and feature updates, Privacy Policy applies.",
|
||||
"product_updates_title": "Product updates",
|
||||
"security_updates_description": "Security relevant information only, Privacy Policy applies.",
|
||||
"security_updates_title": "Security updates",
|
||||
"terms_of_service": "Terms of Service",
|
||||
"title": "Create your Formbricks account"
|
||||
},
|
||||
@@ -188,8 +184,10 @@
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Dark overlay",
|
||||
"date": "Date",
|
||||
"days": "days",
|
||||
"default": "Default",
|
||||
"delete": "Delete",
|
||||
"delete_selected": "{count, plural, one {Delete # item} other {Delete # items}}",
|
||||
"description": "Description",
|
||||
"dev_env": "Dev Environment",
|
||||
"development": "Development",
|
||||
@@ -210,6 +208,7 @@
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
"enter_value": "Enter value",
|
||||
"enterprise_license": "Enterprise License",
|
||||
"environment": "Environment",
|
||||
"environment_not_found": "Environment not found",
|
||||
@@ -277,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Don't worry – your surveys look great on every device and screen size!",
|
||||
"mobile_overlay_title": "Oops, tiny screen detected!",
|
||||
"months": "months",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
@@ -392,6 +392,7 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Step by step manual",
|
||||
"storage_not_configured": "File storage not set up, uploads will likely fail",
|
||||
"string": "Text",
|
||||
"styling": "Styling",
|
||||
"submit": "Submit",
|
||||
"summary": "Summary",
|
||||
@@ -435,6 +436,7 @@
|
||||
"user": "User",
|
||||
"user_id": "User ID",
|
||||
"user_not_found": "User not found",
|
||||
"value": "Value",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variable IDs",
|
||||
"variables": "Variables",
|
||||
@@ -447,6 +449,7 @@
|
||||
"website_and_app_connection": "Website & App Connection",
|
||||
"website_app_survey": "Website & App Survey",
|
||||
"website_survey": "Website Survey",
|
||||
"weeks": "weeks",
|
||||
"welcome_card": "Welcome card",
|
||||
"workspace_configuration": "Workspace Configuration",
|
||||
"workspace_created_successfully": "Workspace created successfully",
|
||||
@@ -457,6 +460,7 @@
|
||||
"workspace_not_found": "Workspace not found",
|
||||
"workspace_permission_not_found": "Workspace permission not found",
|
||||
"workspaces": "Workspaces",
|
||||
"years": "years",
|
||||
"you": "You",
|
||||
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
|
||||
@@ -613,38 +617,55 @@
|
||||
},
|
||||
"contacts": {
|
||||
"add_attribute": "Add Attribute",
|
||||
"attribute_added_successfully": "Attribute added successfully",
|
||||
"attribute_created_successfully": "Attribute created successfully",
|
||||
"attribute_deleted_successfully": "Attribute deleted successfully",
|
||||
"attribute_description": "Description",
|
||||
"attribute_description_placeholder": "Short description",
|
||||
"attribute_key": "Key",
|
||||
"attribute_key_cannot_be_changed": "Key cannot be changed after creation",
|
||||
"attribute_key_created_successfully": "Attribute key created successfully",
|
||||
"attribute_key_description": "Unique identifier (e.g., signUpDate, planType)",
|
||||
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
|
||||
"attribute_key_placeholder": "e.g. date_of_birth",
|
||||
"attribute_key_required": "Key is required",
|
||||
"attribute_key_safe_identifier_required": "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
|
||||
"attribute_keys_deleted_successfully": "{count, plural, one {Attribute key deleted successfully} other {# attribute keys deleted successfully}}",
|
||||
"attribute_label": "Label",
|
||||
"attribute_label_placeholder": "e.g. Date of Birth",
|
||||
"attribute_name_description": "Human-readable display name",
|
||||
"attribute_updated_successfully": "Attribute updated successfully",
|
||||
"attribute_value": "Value",
|
||||
"attribute_value_placeholder": "Attribute Value",
|
||||
"attributes_updated_successfully": "Attributes updated successfully",
|
||||
"confirm_delete_attribute": "Are you sure you want to delete the {attributeName} attribute? This cannot be undone.",
|
||||
"contact_deleted_successfully": "Contact deleted successfully",
|
||||
"contact_not_found": "No such contact found",
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||
"create_attribute": "Create attribute",
|
||||
"create_attribute_key": "Create Attribute Key",
|
||||
"create_key": "Create Key",
|
||||
"create_new_attribute": "Create new attribute",
|
||||
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
|
||||
"data_type": "Data Type",
|
||||
"data_type_cannot_be_changed": "Data type cannot be changed after creation",
|
||||
"data_type_description": "Choose how this attribute should be stored and filtered",
|
||||
"delete_attribute_confirmation": "{value, plural, one {This will delete the selected attribute. Any contact data associated with this attribute will be lost.} other {This will delete the selected attributes. Any contact data associated with these attributes will be lost.}}",
|
||||
"delete_attribute_keys_warning_detailed": "{count, plural, one {Deleting this attribute key will permanently remove all attribute values across all contacts in this environment. Any segments or filters using this attribute will stop working. This action cannot be undone.} other {Deleting these # attribute keys will permanently remove all attribute values across all contacts in this environment. Any segments or filters using these attributes will stop working. This action cannot be undone.}}",
|
||||
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts' data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
|
||||
"edit_attribute": "Edit attribute",
|
||||
"edit_attribute_description": "Update the label and description for this attribute.",
|
||||
"edit_attribute_values": "Edit attributes",
|
||||
"edit_attribute_values_description": "Change the values for specific attributes for this contact.",
|
||||
"edit_attributes": "Edit Attributes",
|
||||
"edit_attributes_success": "Contact attributes updated successfully",
|
||||
"generate_personal_link": "Generate Personal Link",
|
||||
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
|
||||
"invalid_date_value": "Invalid date value",
|
||||
"invalid_email_value": "Invalid email address",
|
||||
"no_custom_attributes_yet": "No custom attribute keys yet. Create one to get started.",
|
||||
"no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.",
|
||||
"no_published_surveys": "No published surveys",
|
||||
"no_responses_found": "No responses found",
|
||||
@@ -653,10 +674,12 @@
|
||||
"personal_link_generated_but_clipboard_failed": "Personal link generated but failed to copy to clipboard: {url}",
|
||||
"personal_survey_link": "Personal Survey Link",
|
||||
"please_select_a_survey": "Please select a survey",
|
||||
"please_select_attribute_and_value": "Please select an attribute and enter a value",
|
||||
"search_attribute_keys": "Search attribute keys...",
|
||||
"search_contact": "Search contact",
|
||||
"select_a_survey": "Select a survey",
|
||||
"select_attribute": "Select Attribute",
|
||||
"selected_attribute_keys": "{count, plural, one {# attribute key} other {# attribute keys}}",
|
||||
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
|
||||
"unlock_contacts_title": "Unlock contacts with a higher plan",
|
||||
"upload_contacts_modal_attributes_description": "Map the columns in your CSV to the attributes in Formbricks.",
|
||||
@@ -868,6 +891,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "User targeting is currently only available when",
|
||||
"value_cannot_be_empty": "Value cannot be empty.",
|
||||
"value_must_be_a_number": "Value must be a number.",
|
||||
"value_must_be_positive": "Value must be a positive number.",
|
||||
"view_filters": "View filters",
|
||||
"where": "Where",
|
||||
"with_the_formbricks_sdk": "with the Formbricks SDK"
|
||||
@@ -1019,8 +1043,6 @@
|
||||
"remove_logo": "Remove logo",
|
||||
"replace_logo": "Replace logo",
|
||||
"resend_invitation_email": "Resend Invitation Email",
|
||||
"security_list_tip": "Are you signed up for our Security List? Stay informed to keep your instance secure!",
|
||||
"security_list_tip_link": "Sign up here.",
|
||||
"share_invite_link": "Share Invite Link",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Share this link to let your organization member join your organization:",
|
||||
"test_email_sent_successfully": "Test email sent successfully",
|
||||
@@ -1182,9 +1204,6 @@
|
||||
"assign": "Assign =",
|
||||
"audience": "Audience",
|
||||
"auto_close_on_inactivity": "Auto close on inactivity",
|
||||
"auto_save_disabled": "Auto-save disabled",
|
||||
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
|
||||
"auto_save_on": "Auto-save on",
|
||||
"automatically_close_survey_after": "Automatically close survey after",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Automatically close the survey after a certain number of responses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
|
||||
@@ -1466,7 +1485,6 @@
|
||||
"please_specify": "Please specify",
|
||||
"prevent_double_submission": "Prevent double submission",
|
||||
"prevent_double_submission_description": "Only allow 1 response per email address",
|
||||
"progress_saved": "Progress saved",
|
||||
"protect_survey_with_pin": "Protect survey with a PIN",
|
||||
"protect_survey_with_pin_description": "Only users who have the PIN can access the survey.",
|
||||
"publish": "Publish",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mezcla de mayúsculas y minúsculas",
|
||||
"please_verify_captcha": "Por favor, verifica el reCAPTCHA",
|
||||
"privacy_policy": "Política de privacidad",
|
||||
"product_updates_description": "Noticias mensuales del producto y actualizaciones de funciones, se aplica la política de privacidad.",
|
||||
"product_updates_title": "Actualizaciones del producto",
|
||||
"security_updates_description": "Solo información relevante sobre seguridad, se aplica la política de privacidad.",
|
||||
"security_updates_title": "Actualizaciones de seguridad",
|
||||
"terms_of_service": "Términos de servicio",
|
||||
"title": "Crea tu cuenta de Formbricks"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "Eliminar logotipo",
|
||||
"replace_logo": "Reemplazar logotipo",
|
||||
"resend_invitation_email": "Reenviar correo electrónico de invitación",
|
||||
"security_list_tip": "¿Estás suscrito a nuestra lista de seguridad? ¡Mantente informado para mantener tu instancia segura!",
|
||||
"security_list_tip_link": "Regístrate aquí.",
|
||||
"share_invite_link": "Compartir enlace de invitación",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Comparte este enlace para permitir que los miembros de tu organización se unan a tu organización:",
|
||||
"test_email_sent_successfully": "Correo electrónico de prueba enviado correctamente",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "Asignar =",
|
||||
"audience": "Audiencia",
|
||||
"auto_close_on_inactivity": "Cierre automático por inactividad",
|
||||
"auto_save_disabled": "Guardado automático desactivado",
|
||||
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
|
||||
"auto_save_on": "Guardado automático activado",
|
||||
"automatically_close_survey_after": "Cerrar automáticamente la encuesta después de",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "Por favor, especifica",
|
||||
"prevent_double_submission": "Evitar envío duplicado",
|
||||
"prevent_double_submission_description": "Permitir solo 1 respuesta por dirección de correo electrónico",
|
||||
"progress_saved": "Progreso guardado",
|
||||
"protect_survey_with_pin": "Proteger encuesta con un PIN",
|
||||
"protect_survey_with_pin_description": "Solo los usuarios que tengan el PIN pueden acceder a la encuesta.",
|
||||
"publish": "Publicar",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mélange de majuscules et de minuscules",
|
||||
"please_verify_captcha": "Veuillez vérifier reCAPTCHA",
|
||||
"privacy_policy": "Politique de confidentialité",
|
||||
"product_updates_description": "Actualités mensuelles du produit et mises à jour des fonctionnalités, la politique de confidentialité s'applique.",
|
||||
"product_updates_title": "Mises à jour du produit",
|
||||
"security_updates_description": "Informations relatives à la sécurité uniquement, la politique de confidentialité s'applique.",
|
||||
"security_updates_title": "Mises à jour de sécurité",
|
||||
"terms_of_service": "Conditions d'utilisation",
|
||||
"title": "Créez votre compte Formbricks"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "Supprimer le logo",
|
||||
"replace_logo": "Remplacer le logo",
|
||||
"resend_invitation_email": "Renvoyer l'e-mail d'invitation",
|
||||
"security_list_tip": "Êtes-vous inscrit à notre liste de sécurité ? Restez informé pour maintenir votre instance sécurisée !",
|
||||
"security_list_tip_link": "Inscrivez-vous ici.",
|
||||
"share_invite_link": "Partager le lien d'invitation",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Partagez ce lien pour permettre à un membre de votre organisation de rejoindre votre organisation :",
|
||||
"test_email_sent_successfully": "E-mail de test envoyé avec succès",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "Attribuer =",
|
||||
"audience": "Public",
|
||||
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
|
||||
"auto_save_disabled": "Sauvegarde automatique désactivée",
|
||||
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
|
||||
"auto_save_on": "Sauvegarde automatique activée",
|
||||
"automatically_close_survey_after": "Fermer automatiquement l'enquête après",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "Veuillez préciser",
|
||||
"prevent_double_submission": "Empêcher la double soumission",
|
||||
"prevent_double_submission_description": "Autoriser uniquement 1 réponse par adresse e-mail",
|
||||
"progress_saved": "Progression enregistrée",
|
||||
"protect_survey_with_pin": "Protéger l'enquête par un code PIN",
|
||||
"protect_survey_with_pin_description": "Seules les personnes ayant le code PIN peuvent accéder à l'enquête.",
|
||||
"publish": "Publier",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "大文字と小文字を混ぜる",
|
||||
"please_verify_captcha": "reCAPTCHAを認証してください",
|
||||
"privacy_policy": "プライバシーポリシー",
|
||||
"product_updates_description": "毎月の製品ニュースと機能アップデート、プライバシーポリシーが適用されます。",
|
||||
"product_updates_title": "製品アップデート",
|
||||
"security_updates_description": "セキュリティ関連情報のみ、プライバシーポリシーが適用されます。",
|
||||
"security_updates_title": "セキュリティアップデート",
|
||||
"terms_of_service": "利用規約",
|
||||
"title": "Formbricksアカウントを作成"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "ロゴを削除",
|
||||
"replace_logo": "ロゴを交換",
|
||||
"resend_invitation_email": "招待メールを再送信",
|
||||
"security_list_tip": "セキュリティリストに登録していますか?インスタンスを安全に保つために最新情報を入手しましょう!",
|
||||
"security_list_tip_link": "こちらからサインアップしてください。",
|
||||
"share_invite_link": "招待リンクを共有",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "このリンクを共有して、組織メンバーを招待できます:",
|
||||
"test_email_sent_successfully": "テストメールを正常に送信しました",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "割り当て =",
|
||||
"audience": "オーディエンス",
|
||||
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
|
||||
"auto_save_disabled": "自動保存が無効",
|
||||
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
|
||||
"auto_save_on": "自動保存オン",
|
||||
"automatically_close_survey_after": "フォームを自動的に閉じる",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "具体的に指定してください",
|
||||
"prevent_double_submission": "二重送信を防ぐ",
|
||||
"prevent_double_submission_description": "メールアドレスごとに1つの回答のみを許可する",
|
||||
"progress_saved": "進捗を保存しました",
|
||||
"protect_survey_with_pin": "PINでフォームを保護",
|
||||
"protect_survey_with_pin_description": "PINを持つユーザーのみがフォームにアクセスできます。",
|
||||
"publish": "公開",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mix van hoofdletters en kleine letters",
|
||||
"please_verify_captcha": "Controleer reCAPTCHA",
|
||||
"privacy_policy": "Privacybeleid",
|
||||
"product_updates_description": "Maandelijks productnieuws en feature-updates, privacybeleid is van toepassing.",
|
||||
"product_updates_title": "Product-updates",
|
||||
"security_updates_description": "Alleen beveiligingsrelevante informatie, privacybeleid is van toepassing.",
|
||||
"security_updates_title": "Beveiligingsupdates",
|
||||
"terms_of_service": "Servicevoorwaarden",
|
||||
"title": "Maak uw Formbricks-account aan"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "Logo verwijderen",
|
||||
"replace_logo": "Logo vervangen",
|
||||
"resend_invitation_email": "Uitnodigings-e-mail opnieuw verzenden",
|
||||
"security_list_tip": "Ben je aangemeld voor onze beveiligingslijst? Blijf op de hoogte om je instantie veilig te houden!",
|
||||
"security_list_tip_link": "Meld je hier aan.",
|
||||
"share_invite_link": "Deel de uitnodigingslink",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Deel deze link om uw organisatielid lid te laten worden van uw organisatie:",
|
||||
"test_email_sent_successfully": "Test-e-mail succesvol verzonden",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "Toewijzen =",
|
||||
"audience": "Publiek",
|
||||
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
|
||||
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
|
||||
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
|
||||
"auto_save_on": "Automatisch opslaan aan",
|
||||
"automatically_close_survey_after": "Sluit de enquête daarna automatisch af",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "Gelieve te specificeren",
|
||||
"prevent_double_submission": "Voorkom dubbele indiening",
|
||||
"prevent_double_submission_description": "Er is slechts 1 reactie per e-mailadres toegestaan",
|
||||
"progress_saved": "Voortgang opgeslagen",
|
||||
"protect_survey_with_pin": "Beveilig onderzoek met een pincode",
|
||||
"protect_survey_with_pin_description": "Alleen gebruikers die de pincode hebben, hebben toegang tot de enquête.",
|
||||
"publish": "Publiceren",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "mistura de maiúsculas e minúsculas",
|
||||
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"product_updates_description": "Novidades mensais do produto e atualizações de recursos, a Política de Privacidade se aplica.",
|
||||
"product_updates_title": "Atualizações do produto",
|
||||
"security_updates_description": "Apenas informações relevantes sobre segurança, a Política de Privacidade se aplica.",
|
||||
"security_updates_title": "Atualizações de segurança",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"title": "Crie sua conta no Formbricks"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "Remover logo",
|
||||
"replace_logo": "Substituir logo",
|
||||
"resend_invitation_email": "Reenviar E-mail de Convite",
|
||||
"security_list_tip": "Você está inscrito na nossa Lista de Segurança? Mantenha-se informado para manter sua instância segura!",
|
||||
"security_list_tip_link": "Cadastre-se aqui.",
|
||||
"share_invite_link": "Compartilhar Link de Convite",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Compartilhe esse link para que o membro da sua organização possa entrar na sua organização:",
|
||||
"test_email_sent_successfully": "E-mail de teste enviado com sucesso",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "atribuir =",
|
||||
"audience": "Público",
|
||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||
"auto_save_disabled": "Salvamento automático desativado",
|
||||
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
|
||||
"auto_save_on": "Salvamento automático ativado",
|
||||
"automatically_close_survey_after": "Fechar pesquisa automaticamente após",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "Por favor, especifique",
|
||||
"prevent_double_submission": "Evitar envio duplicado",
|
||||
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
|
||||
"progress_saved": "Progresso salvo",
|
||||
"protect_survey_with_pin": "Proteger pesquisa com um PIN",
|
||||
"protect_survey_with_pin_description": "Somente usuários que têm o PIN podem acessar a pesquisa.",
|
||||
"publish": "Publicar",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mistura de maiúsculas e minúsculas",
|
||||
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"product_updates_description": "Notícias mensais sobre o produto e atualizações de funcionalidades, aplica-se a Política de Privacidade.",
|
||||
"product_updates_title": "Atualizações do produto",
|
||||
"security_updates_description": "Apenas informações relevantes sobre segurança, aplica-se a Política de Privacidade.",
|
||||
"security_updates_title": "Atualizações de segurança",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"title": "Crie a sua conta Formbricks"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "Remover logótipo",
|
||||
"replace_logo": "Substituir logotipo",
|
||||
"resend_invitation_email": "Reenviar Email de Convite",
|
||||
"security_list_tip": "Está inscrito na nossa Lista de Segurança? Mantenha-se informado para manter a sua instância segura!",
|
||||
"security_list_tip_link": "Inscreva-se aqui.",
|
||||
"share_invite_link": "Partilhar Link de Convite",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Partilhe este link para permitir que o membro da sua organização se junte à sua organização:",
|
||||
"test_email_sent_successfully": "Email de teste enviado com sucesso",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "Atribuir =",
|
||||
"audience": "Público",
|
||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||
"auto_save_disabled": "Guardar automático desativado",
|
||||
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
|
||||
"auto_save_on": "Guardar automático ativado",
|
||||
"automatically_close_survey_after": "Fechar automaticamente o inquérito após",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "Por favor, especifique",
|
||||
"prevent_double_submission": "Impedir submissão dupla",
|
||||
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
|
||||
"progress_saved": "Progresso guardado",
|
||||
"protect_survey_with_pin": "Proteger inquérito com um PIN",
|
||||
"protect_survey_with_pin_description": "Apenas utilizadores com o PIN podem aceder ao inquérito.",
|
||||
"publish": "Publicar",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
|
||||
"please_verify_captcha": "Vă rugăm să verificați CAPTCHA",
|
||||
"privacy_policy": "Politica de confidențialitate",
|
||||
"product_updates_description": "Noutăți lunare despre produse și actualizări de funcționalități; se aplică Politica de confidențialitate.",
|
||||
"product_updates_title": "Actualizări de produs",
|
||||
"security_updates_description": "Doar informații relevante pentru securitate; se aplică Politica de confidențialitate.",
|
||||
"security_updates_title": "Actualizări de securitate",
|
||||
"terms_of_service": "Termeni de utilizare a serviciului",
|
||||
"title": "Creați-vă contul Formbricks"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "Înlătură siglă",
|
||||
"replace_logo": "Înlocuiește sigla",
|
||||
"resend_invitation_email": "Retrimite emailul de invitație",
|
||||
"security_list_tip": "Ești abonat la lista noastră de securitate? Rămâi informat pentru a-ți menține instanța în siguranță!",
|
||||
"security_list_tip_link": "Înscrie-te aici.",
|
||||
"share_invite_link": "Distribuie link-ul de invitație",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Distribuie acest link pentru a permite membrului organizației să se alăture organizației tale:",
|
||||
"test_email_sent_successfully": "Email de test trimis cu succes",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "Atribuire =",
|
||||
"audience": "Public",
|
||||
"auto_close_on_inactivity": "Închidere automată la inactivitate",
|
||||
"auto_save_disabled": "Salvare automată dezactivată",
|
||||
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
|
||||
"auto_save_on": "Salvare automată activată",
|
||||
"automatically_close_survey_after": "Închideți automat sondajul după",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "Vă rugăm să specificați",
|
||||
"prevent_double_submission": "Prevenire trimitere dublă",
|
||||
"prevent_double_submission_description": "Permite doar 1 răspuns per adresă de email.",
|
||||
"progress_saved": "Progres salvat",
|
||||
"protect_survey_with_pin": "Protejați sondajul cu un PIN",
|
||||
"protect_survey_with_pin_description": "Doar utilizatorii care cunosc PIN-ul pot accesa sondajul.",
|
||||
"publish": "Publică",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "Сочетание заглавных и строчных букв",
|
||||
"please_verify_captcha": "Пожалуйста, подтвердите reCAPTCHA",
|
||||
"privacy_policy": "Политика конфиденциальности",
|
||||
"product_updates_description": "Ежемесячные новости о продукте и обновления функций. Применяется Политика конфиденциальности.",
|
||||
"product_updates_title": "Обновления продукта",
|
||||
"security_updates_description": "Только важная информация по безопасности. Применяется Политика конфиденциальности.",
|
||||
"security_updates_title": "Обновления безопасности",
|
||||
"terms_of_service": "Условия использования",
|
||||
"title": "Создайте аккаунт Formbricks"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "Удалить логотип",
|
||||
"replace_logo": "Заменить логотип",
|
||||
"resend_invitation_email": "Отправить приглашение повторно",
|
||||
"security_list_tip": "Вы подписаны на нашу рассылку по безопасности? Будьте в курсе, чтобы обезопасить свой экземпляр!",
|
||||
"security_list_tip_link": "Зарегистрируйтесь здесь.",
|
||||
"share_invite_link": "Поделиться ссылкой-приглашением",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Поделитесь этой ссылкой, чтобы участник вашей организации мог присоединиться к ней:",
|
||||
"test_email_sent_successfully": "Тестовое письмо успешно отправлено",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "Назначить =",
|
||||
"audience": "Аудитория",
|
||||
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
|
||||
"auto_save_disabled": "Автосохранение отключено",
|
||||
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
|
||||
"auto_save_on": "Автосохранение включено",
|
||||
"automatically_close_survey_after": "Автоматически закрыть опрос через",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "Пожалуйста, уточните",
|
||||
"prevent_double_submission": "Предотвратить повторную отправку",
|
||||
"prevent_double_submission_description": "Разрешить только 1 ответ на один адрес электронной почты",
|
||||
"progress_saved": "Прогресс сохранён",
|
||||
"protect_survey_with_pin": "Защитить опрос с помощью PIN-кода",
|
||||
"protect_survey_with_pin_description": "Только пользователи, у которых есть PIN-код, могут получить доступ к опросу.",
|
||||
"publish": "Опубликовать",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "Blandning av stora och små bokstäver",
|
||||
"please_verify_captcha": "Vänligen verifiera reCAPTCHA",
|
||||
"privacy_policy": "Integritetspolicy",
|
||||
"product_updates_description": "Månatliga produktnyheter och funktionsuppdateringar. Integritetspolicyn gäller.",
|
||||
"product_updates_title": "Produktuppdateringar",
|
||||
"security_updates_description": "Endast säkerhetsrelaterad information. Integritetspolicyn gäller.",
|
||||
"security_updates_title": "Säkerhetsuppdateringar",
|
||||
"terms_of_service": "Användarvillkor",
|
||||
"title": "Skapa ditt Formbricks-konto"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "Ta bort logotyp",
|
||||
"replace_logo": "Ersätt logotyp",
|
||||
"resend_invitation_email": "Skicka inbjudningsmejl igen",
|
||||
"security_list_tip": "Är du med på vår säkerhetslista? Håll dig informerad för att skydda din instans!",
|
||||
"security_list_tip_link": "Registrera dig här.",
|
||||
"share_invite_link": "Dela inbjudningslänk",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Dela denna länk för att låta din organisationsmedlem gå med i din organisation:",
|
||||
"test_email_sent_successfully": "Test-e-post skickat",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "Tilldela =",
|
||||
"audience": "Målgrupp",
|
||||
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
|
||||
"auto_save_disabled": "Automatisk sparning inaktiverad",
|
||||
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
|
||||
"auto_save_on": "Automatisk sparning på",
|
||||
"automatically_close_survey_after": "Stäng enkäten automatiskt efter",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "Vänligen specificera",
|
||||
"prevent_double_submission": "Förhindra dubbelinskickning",
|
||||
"prevent_double_submission_description": "Tillåt endast 1 svar per e-postadress",
|
||||
"progress_saved": "Framsteg sparade",
|
||||
"protect_survey_with_pin": "Skydda enkäten med en PIN",
|
||||
"protect_survey_with_pin_description": "Endast användare som har PIN-koden kan komma åt enkäten.",
|
||||
"publish": "Publicera",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "大小写混合",
|
||||
"please_verify_captcha": "请 验证 reCAPTCHA",
|
||||
"privacy_policy": "隐私政策",
|
||||
"product_updates_description": "每月产品新闻和功能更新,适用隐私政策。",
|
||||
"product_updates_title": "产品更新",
|
||||
"security_updates_description": "仅限安全相关信息,适用隐私政策。",
|
||||
"security_updates_title": "安全更新",
|
||||
"terms_of_service": "服务条款",
|
||||
"title": "创建你的 Formbricks 账户"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "移除 logo",
|
||||
"replace_logo": "替换 logo",
|
||||
"resend_invitation_email": "重新发送邀请邮件",
|
||||
"security_list_tip": "您已订阅我们的安全列表了吗?保持关注,保障您的实例安全!",
|
||||
"security_list_tip_link": "点击此处注册。",
|
||||
"share_invite_link": "分享邀请链接",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "分享 这个 链接 以 让 你的 组织 成员 加入 你的 组织:",
|
||||
"test_email_sent_successfully": "测试 邮件 发送 成功",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "指派 =",
|
||||
"audience": "受众",
|
||||
"auto_close_on_inactivity": "自动关闭 在 无活动时",
|
||||
"auto_save_disabled": "自动保存已禁用",
|
||||
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
|
||||
"auto_save_on": "自动保存已启用",
|
||||
"automatically_close_survey_after": "自动 关闭 调查 后",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "请 指定",
|
||||
"prevent_double_submission": "防止 重复 提交",
|
||||
"prevent_double_submission_description": "只允许每个 email 地址提供 1 个回复",
|
||||
"progress_saved": "进度已保存",
|
||||
"protect_survey_with_pin": "使用 PIN 保护 调查",
|
||||
"protect_survey_with_pin_description": "只有 拥有 PIN 的 用户 可以 访问 调查。",
|
||||
"publish": "发布",
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
"password_validation_uppercase_and_lowercase": "混合使用大小寫字母",
|
||||
"please_verify_captcha": "請驗證 reCAPTCHA",
|
||||
"privacy_policy": "隱私權政策",
|
||||
"product_updates_description": "每月產品新聞與功能更新,適用隱私權政策。",
|
||||
"product_updates_title": "產品更新",
|
||||
"security_updates_description": "僅限安全相關資訊,適用隱私權政策。",
|
||||
"security_updates_title": "安全更新",
|
||||
"terms_of_service": "服務條款",
|
||||
"title": "建立您的 Formbricks 帳戶"
|
||||
},
|
||||
@@ -1019,8 +1015,6 @@
|
||||
"remove_logo": "移除標誌",
|
||||
"replace_logo": "取代標誌",
|
||||
"resend_invitation_email": "重新發送邀請電子郵件",
|
||||
"security_list_tip": "您已訂閱我們的安全名單了嗎?保持關注,確保您的實例安全!",
|
||||
"security_list_tip_link": "請在此註冊。",
|
||||
"share_invite_link": "分享邀請連結",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "分享此連結以讓您的組織成員加入您的組織:",
|
||||
"test_email_sent_successfully": "測試電子郵件已成功發送",
|
||||
@@ -1182,9 +1176,6 @@
|
||||
"assign": "等於 =",
|
||||
"audience": "受眾",
|
||||
"auto_close_on_inactivity": "非活動時自動關閉",
|
||||
"auto_save_disabled": "自動儲存已停用",
|
||||
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
|
||||
"auto_save_on": "自動儲存已啟用",
|
||||
"automatically_close_survey_after": "在指定時間自動關閉問卷",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
|
||||
@@ -1466,7 +1457,6 @@
|
||||
"please_specify": "請指定",
|
||||
"prevent_double_submission": "防止重複提交",
|
||||
"prevent_double_submission_description": "每個電子郵件地址僅允許 1 個回應",
|
||||
"progress_saved": "進度已儲存",
|
||||
"protect_survey_with_pin": "使用 PIN 碼保護問卷",
|
||||
"protect_survey_with_pin_description": "只有擁有 PIN 碼的使用者才能存取問卷。",
|
||||
"publish": "發布",
|
||||
|
||||
@@ -56,7 +56,7 @@ const handleDomainAwareRouting = (request: NextRequest): Response | null => {
|
||||
}
|
||||
};
|
||||
|
||||
export const proxy = async (originalRequest: NextRequest) => {
|
||||
export const middleware = async (originalRequest: NextRequest) => {
|
||||
// Handle domain-aware routing first
|
||||
const domainResponse = handleDomainAwareRouting(originalRequest);
|
||||
if (domainResponse) return domainResponse;
|
||||
@@ -1,15 +1,11 @@
|
||||
import { Languages } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface LanguageDropdownProps {
|
||||
survey: TSurvey;
|
||||
@@ -18,31 +14,38 @@ interface LanguageDropdownProps {
|
||||
}
|
||||
|
||||
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
|
||||
const [showLanguageSelect, setShowLanguageSelect] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
|
||||
|
||||
if (enabledLanguages.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
useClickOutside(containerRef, () => setShowLanguageSelect(false));
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" title="Select Language" aria-label="Select Language">
|
||||
enabledLanguages.length > 1 && (
|
||||
<div className="relative" ref={containerRef}>
|
||||
{showLanguageSelect && (
|
||||
<div className="absolute top-12 z-30 max-h-64 max-w-48 overflow-auto rounded-lg border bg-slate-900 p-1 text-sm text-white">
|
||||
{enabledLanguages.map((surveyLanguage) => (
|
||||
<button
|
||||
key={surveyLanguage.language.code}
|
||||
className="w-full truncate rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
|
||||
onClick={() => {
|
||||
setLanguage(surveyLanguage.language.code);
|
||||
setShowLanguageSelect(false);
|
||||
}}>
|
||||
{getLanguageLabel(surveyLanguage.language.code, locale)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
title="Select Language"
|
||||
aria-label="Select Language"
|
||||
onClick={() => setShowLanguageSelect(!showLanguageSelect)}>
|
||||
<Languages className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="max-h-64 max-w-48 overflow-auto bg-slate-900 p-1 text-sm text-white"
|
||||
align="start">
|
||||
{enabledLanguages.map((surveyLanguage) => (
|
||||
<DropdownMenuItem
|
||||
key={surveyLanguage.language.code}
|
||||
className="w-full truncate rounded-md p-2 text-start text-white hover:cursor-pointer hover:bg-slate-700 focus:bg-slate-700"
|
||||
onSelect={() => setLanguage(surveyLanguage.language.code)}>
|
||||
{getLanguageLabel(surveyLanguage.language.code, locale)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export const getContactAttributeKeys = reactCache(
|
||||
export const createContactAttributeKey = async (
|
||||
contactAttributeKey: TContactAttributeKeyInput
|
||||
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
|
||||
const { environmentId, name, description, key } = contactAttributeKey;
|
||||
const { environmentId, name, description, key, dataType } = contactAttributeKey;
|
||||
|
||||
try {
|
||||
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
|
||||
@@ -49,6 +49,8 @@ export const createContactAttributeKey = async (
|
||||
name,
|
||||
description,
|
||||
key,
|
||||
// If dataType is provided, use it; otherwise Prisma will use the default (text)
|
||||
...(dataType && { dataType }),
|
||||
};
|
||||
|
||||
const createdContactAttributeKey = await prisma.contactAttributeKey.create({
|
||||
|
||||
@@ -28,8 +28,12 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
|
||||
key: true,
|
||||
name: true,
|
||||
description: true,
|
||||
dataType: true,
|
||||
environmentId: true,
|
||||
})
|
||||
.extend({
|
||||
dataType: ZContactAttributeKey.shape.dataType.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Enforce safe identifier format for key
|
||||
if (!isSafeIdentifier(data.key)) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCommonFilterQuery } from "./utils";
|
||||
|
||||
describe("buildCommonFilterQuery", () => {
|
||||
// Test for line 32: spread existing date filter when adding startDate
|
||||
test("should preserve existing date filter when adding startDate", () => {
|
||||
it("should preserve existing date filter when adding startDate", () => {
|
||||
const query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
createdAt: {
|
||||
@@ -23,7 +23,7 @@ describe("buildCommonFilterQuery", () => {
|
||||
});
|
||||
|
||||
// Test for line 45: spread existing date filter when adding endDate
|
||||
test("should preserve existing date filter when adding endDate", () => {
|
||||
it("should preserve existing date filter when adding endDate", () => {
|
||||
const query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
createdAt: {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { subscribeUserToMailingList } from "@/modules/ee/mailing/lib/mailing-subscription";
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
|
||||
|
||||
const ZCreatedUser = ZUser.pick({
|
||||
@@ -45,9 +44,6 @@ const ZCreateUserAction = z.object({
|
||||
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
|
||||
"CAPTCHA verification required"
|
||||
),
|
||||
isFormbricksCloud: z.boolean(),
|
||||
subscribeToSecurityUpdates: z.boolean().optional(),
|
||||
subscribeToProductUpdates: z.boolean().optional(),
|
||||
});
|
||||
|
||||
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
|
||||
@@ -195,13 +191,6 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
|
||||
parsedInput.inviteToken,
|
||||
parsedInput.emailVerificationDisabled
|
||||
);
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: user.email,
|
||||
isFormbricksCloud: parsedInput.isFormbricksCloud,
|
||||
subscribeToSecurityUpdates: parsedInput.subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
|
||||
});
|
||||
}
|
||||
|
||||
if (user) {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { createUserAction } from "@/modules/auth/signup/actions";
|
||||
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
|
||||
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
@@ -49,7 +48,6 @@ interface SignupFormProps {
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
turnstileSiteKey?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
@@ -71,7 +69,6 @@ export const SignupForm = ({
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
turnstileSiteKey,
|
||||
isFormbricksCloud,
|
||||
}: SignupFormProps) => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -79,8 +76,6 @@ export const SignupForm = ({
|
||||
const inviteToken = searchParams?.get("inviteToken");
|
||||
const router = useRouter();
|
||||
const [turnstileToken, setTurnstileToken] = useState<string>();
|
||||
const [subscribeToSecurityUpdates, setSubscribeToSecurityUpdates] = useState(false);
|
||||
const [subscribeToProductUpdates, setSubscribeToProductUpdates] = useState(false);
|
||||
|
||||
const turnstile = useTurnstile();
|
||||
|
||||
@@ -115,9 +110,6 @@ export const SignupForm = ({
|
||||
inviteToken: inviteToken ?? "",
|
||||
emailVerificationDisabled,
|
||||
turnstileToken,
|
||||
isFormbricksCloud,
|
||||
subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates,
|
||||
});
|
||||
|
||||
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
||||
@@ -247,43 +239,6 @@ export const SignupForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLogin &&
|
||||
(isFormbricksCloud ? (
|
||||
<label
|
||||
htmlFor="product-updates"
|
||||
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
|
||||
<Checkbox
|
||||
id="product-updates"
|
||||
checked={subscribeToProductUpdates}
|
||||
onCheckedChange={(checked) => setSubscribeToProductUpdates(checked === true)}
|
||||
className="mt-0.5 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t("auth.signup.product_updates_title")}
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">{t("auth.signup.product_updates_description")}</p>
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="security-updates"
|
||||
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
|
||||
<Checkbox
|
||||
id="security-updates"
|
||||
checked={subscribeToSecurityUpdates}
|
||||
onCheckedChange={(checked) => setSubscribeToSecurityUpdates(checked === true)}
|
||||
className="mt-0.5 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t("auth.signup.security_updates_title")}
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">{t("auth.signup.security_updates_description")}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{showLogin && (
|
||||
<Button
|
||||
data-testid="signup-submit"
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_TURNSTILE_CONFIGURED,
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
@@ -77,7 +76,6 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
|
||||
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
|
||||
const ZGeneratePersonalSurveyLinkAction = z.object({
|
||||
contactId: ZId,
|
||||
@@ -58,3 +63,105 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient
|
||||
surveyUrl: result.data,
|
||||
};
|
||||
});
|
||||
|
||||
const ZUpdateContactAttributesAction = z.object({
|
||||
contactId: ZId,
|
||||
attributes: ZContactAttributes,
|
||||
});
|
||||
|
||||
export const updateContactAttributesAction = authenticatedActionClient
|
||||
.schema(ZUpdateContactAttributesAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contact = await getContact(parsedInput.contactId);
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
|
||||
}
|
||||
|
||||
// Get userId from contact attributes
|
||||
const userIdAttribute = await prisma.contactAttribute.findFirst({
|
||||
where: {
|
||||
contactId: parsedInput.contactId,
|
||||
attributeKey: { key: "userId" },
|
||||
},
|
||||
select: { value: true },
|
||||
});
|
||||
|
||||
if (!userIdAttribute) {
|
||||
throw new InvalidInputError("Contact does not have a userId attribute");
|
||||
}
|
||||
|
||||
const result = await updateAttributes(
|
||||
parsedInput.contactId,
|
||||
userIdAttribute.value,
|
||||
contact.environmentId,
|
||||
parsedInput.attributes
|
||||
);
|
||||
|
||||
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const ZDeleteContactAttributeAction = z.object({
|
||||
contactId: ZId,
|
||||
attributeKey: z.string(),
|
||||
});
|
||||
|
||||
export const deleteContactAttributeAction = authenticatedActionClient
|
||||
.schema(ZDeleteContactAttributeAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contact = await getContact(parsedInput.contactId);
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
|
||||
}
|
||||
|
||||
// Delete the attribute
|
||||
await prisma.contactAttribute.deleteMany({
|
||||
where: {
|
||||
contactId: parsedInput.contactId,
|
||||
attributeKey: { key: parsedInput.attributeKey },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import {
|
||||
getContactAttributes,
|
||||
getContactAttributesWithMetadata,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
|
||||
const t = await getTranslate();
|
||||
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
|
||||
const [contact, attributes, attributesWithMetadata] = await Promise.all([
|
||||
getContact(contactId),
|
||||
getContactAttributes(contactId),
|
||||
getContactAttributesWithMetadata(contactId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(t("environments.contacts.contact_not_found"));
|
||||
@@ -53,13 +62,18 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd>
|
||||
</div>
|
||||
|
||||
{Object.entries(attributes)
|
||||
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
|
||||
.map(([key, attributeData]) => {
|
||||
{attributesWithMetadata
|
||||
.filter((attr) => attr.key !== "email" && attr.key !== "userId" && attr.key !== "language")
|
||||
.map((attr) => {
|
||||
return (
|
||||
<div key={key}>
|
||||
<dt className="text-sm font-medium text-slate-500">{key}</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
|
||||
<div key={attr.key}>
|
||||
<dt className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||
<span>{attr.name || attr.key}</span>
|
||||
<Badge text={attr.dataType} type="gray" size="tiny" />
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">
|
||||
{formatAttributeValue(attr.value, attr.dataType)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,23 +5,31 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteContactAction } from "@/modules/ee/contacts/actions";
|
||||
import { EditContactAttributesModal } from "@/modules/ee/contacts/components/edit-contact-attributes-modal";
|
||||
import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { EditAttributesModal } from "./edit-attributes-modal";
|
||||
import { GeneratePersonalLinkModal } from "./generate-personal-link-modal";
|
||||
|
||||
interface AttributeWithMetadata {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
|
||||
interface ContactControlBarProps {
|
||||
environmentId: string;
|
||||
contactId: string;
|
||||
isReadOnly: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
publishedLinkSurveys: PublishedLinkSurvey[];
|
||||
currentAttributes: TContactAttributes;
|
||||
allAttributeKeys: TContactAttributeKey[];
|
||||
currentAttributes: AttributeWithMetadata[];
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
@@ -31,6 +39,7 @@ export const ContactControlBar = ({
|
||||
isReadOnly,
|
||||
isQuotasAllowed,
|
||||
publishedLinkSurveys,
|
||||
allAttributeKeys,
|
||||
currentAttributes,
|
||||
attributeKeys,
|
||||
}: ContactControlBarProps) => {
|
||||
@@ -63,7 +72,7 @@ export const ContactControlBar = ({
|
||||
const iconActions = [
|
||||
{
|
||||
icon: PencilIcon,
|
||||
tooltip: t("environments.contacts.edit_attribute_values"),
|
||||
tooltip: t("environments.contacts.edit_attributes"),
|
||||
onClick: () => {
|
||||
setIsEditAttributesModalOpen(true);
|
||||
},
|
||||
@@ -104,6 +113,13 @@ export const ContactControlBar = ({
|
||||
: t("environments.contacts.delete_contact_confirmation")
|
||||
}
|
||||
/>
|
||||
<EditAttributesModal
|
||||
open={isEditAttributesModalOpen}
|
||||
setOpen={setIsEditAttributesModalOpen}
|
||||
contactId={contactId}
|
||||
attributes={currentAttributes}
|
||||
allAttributeKeys={allAttributeKeys}
|
||||
/>
|
||||
<GeneratePersonalLinkModal
|
||||
open={isGenerateLinkModalOpen}
|
||||
setOpen={setIsGenerateLinkModalOpen}
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
deleteContactAttributeAction,
|
||||
updateContactAttributesAction,
|
||||
} from "@/modules/ee/contacts/[contactId]/actions";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface AttributeWithMetadata {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
|
||||
interface EditAttributesModalProps {
|
||||
contactId: string;
|
||||
attributes: AttributeWithMetadata[];
|
||||
allAttributeKeys: TContactAttributeKey[];
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditAttributesModal({
|
||||
contactId,
|
||||
attributes,
|
||||
allAttributeKeys,
|
||||
open,
|
||||
setOpen,
|
||||
}: EditAttributesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deletedKeys, setDeletedKeys] = useState<Set<string>>(new Set());
|
||||
const [deletingKeys, setDeletingKeys] = useState<Set<string>>(new Set());
|
||||
const [selectedNewAttributeKey, setSelectedNewAttributeKey] = useState<string>("");
|
||||
const [newAttributeValue, setNewAttributeValue] = useState<string>("");
|
||||
|
||||
// Reset deleted keys when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDeletedKeys(new Set());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Filter out protected attributes and locally deleted ones
|
||||
const editableAttributes = useMemo(() => {
|
||||
return attributes.filter(
|
||||
(attr) => attr.key !== "contactId" && attr.key !== "userId" && !deletedKeys.has(attr.key)
|
||||
);
|
||||
}, [attributes, deletedKeys]);
|
||||
|
||||
// Get available attribute keys that are not yet assigned to this contact (including deleted ones)
|
||||
const availableAttributeKeys = useMemo(() => {
|
||||
const currentKeys = new Set(editableAttributes.map((attr) => attr.key));
|
||||
return allAttributeKeys.filter((key) => !currentKeys.has(key.key) && key.key !== "userId");
|
||||
}, [editableAttributes, allAttributeKeys]);
|
||||
|
||||
const selectedAttributeKey = useMemo(() => {
|
||||
return allAttributeKeys.find((key) => key.key === selectedNewAttributeKey);
|
||||
}, [selectedNewAttributeKey, allAttributeKeys]);
|
||||
|
||||
// Create schema dynamically based on current editable attributes
|
||||
const attributeSchema = useMemo(() => {
|
||||
return z.object(
|
||||
editableAttributes.reduce(
|
||||
(acc, attr) => {
|
||||
// Add specific validation for known attributes
|
||||
if (attr.key === "email") {
|
||||
acc[attr.key] = z.string().email({ message: "Invalid email address" });
|
||||
} else if (attr.key === "language") {
|
||||
acc[attr.key] = z.string().min(2, { message: "Language code must be at least 2 characters" });
|
||||
} else {
|
||||
// Generic string validation for other attributes
|
||||
acc[attr.key] = z.string();
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, z.ZodString | z.ZodEffects<z.ZodString>>
|
||||
)
|
||||
);
|
||||
}, [editableAttributes]);
|
||||
|
||||
type TAttributeForm = z.infer<typeof attributeSchema>;
|
||||
|
||||
const form = useForm<TAttributeForm>({
|
||||
resolver: zodResolver(attributeSchema),
|
||||
defaultValues: editableAttributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
});
|
||||
|
||||
// Update form when editable attributes change
|
||||
useEffect(() => {
|
||||
const newDefaults = editableAttributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
form.reset(newDefaults);
|
||||
}, [editableAttributes, form]);
|
||||
|
||||
const onSubmit = async (data: TAttributeForm) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await updateContactAttributesAction({
|
||||
contactId,
|
||||
attributes: data,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.contacts.attributes_updated_successfully"));
|
||||
router.refresh();
|
||||
setOpen(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAttribute = async (attributeKey: string) => {
|
||||
// Confirm deletion for important attributes
|
||||
if (attributeKey === "email" || attributeKey === "language") {
|
||||
const confirmed = globalThis.confirm(
|
||||
t("environments.contacts.confirm_delete_attribute", {
|
||||
attributeName: attributeKey,
|
||||
})
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setDeletingKeys((prev) => new Set(prev).add(attributeKey));
|
||||
try {
|
||||
const result = await deleteContactAttributeAction({
|
||||
contactId,
|
||||
attributeKey,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.contacts.attribute_deleted_successfully"));
|
||||
// Mark as deleted locally and remove from form
|
||||
setDeletedKeys((prev) => new Set(prev).add(attributeKey));
|
||||
form.unregister(attributeKey);
|
||||
router.refresh();
|
||||
// Keep modal open so user can see the attribute is now available to add
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setDeletingKeys((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(attributeKey);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAttribute = async () => {
|
||||
if (!selectedNewAttributeKey || !newAttributeValue) {
|
||||
toast.error(t("environments.contacts.please_select_attribute_and_value"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate based on data type
|
||||
const selectedKey = selectedAttributeKey;
|
||||
if (selectedKey?.dataType === "date") {
|
||||
const date = new Date(newAttributeValue);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
toast.error(t("environments.contacts.invalid_date_value"));
|
||||
return;
|
||||
}
|
||||
} else if (selectedKey?.key === "email") {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(newAttributeValue)) {
|
||||
toast.error(t("environments.contacts.invalid_email_value"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateContactAttributesAction({
|
||||
contactId,
|
||||
attributes: {
|
||||
[selectedNewAttributeKey]: newAttributeValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.contacts.attribute_added_successfully"));
|
||||
// Add to form dynamically
|
||||
form.setValue(selectedNewAttributeKey, newAttributeValue);
|
||||
// Remove from deleted keys if it was previously deleted
|
||||
setDeletedKeys((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(selectedNewAttributeKey);
|
||||
return newSet;
|
||||
});
|
||||
setSelectedNewAttributeKey("");
|
||||
setNewAttributeValue("");
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.contacts.edit_attributes")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="max-h-[60vh] overflow-y-auto pb-4 pr-6">
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{editableAttributes.map((attr) => (
|
||||
<motion.div
|
||||
key={attr.key}
|
||||
layout
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={attr.key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{attr.name || attr.key}</span>
|
||||
<Badge text={attr.dataType} type="gray" size="tiny" />
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
{attr.dataType === "date" ? (
|
||||
<Input
|
||||
type="date"
|
||||
{...field}
|
||||
value={field.value ? field.value.split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value
|
||||
? new Date(e.target.value).toISOString()
|
||||
: "";
|
||||
field.onChange(dateValue);
|
||||
}}
|
||||
/>
|
||||
) : attr.dataType === "number" ? (
|
||||
<Input type="number" {...field} />
|
||||
) : (
|
||||
<Input type="text" {...field} />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteAttribute(attr.key)}
|
||||
disabled={deletingKeys.has(attr.key)}
|
||||
loading={deletingKeys.has(attr.key)}
|
||||
title={t("common.delete")}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Add New Attribute Section */}
|
||||
{availableAttributeKeys.length > 0 && (
|
||||
<>
|
||||
<hr className="my-6" />
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.add_attribute")}
|
||||
</h3>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-slate-600">
|
||||
{t("environments.contacts.select_attribute")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedNewAttributeKey}
|
||||
onValueChange={(value) => {
|
||||
setSelectedNewAttributeKey(value);
|
||||
setNewAttributeValue("");
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("environments.contacts.select_attribute")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableAttributeKeys.map((key) => (
|
||||
<SelectItem key={key.id} value={key.key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge text={key.dataType} type="gray" size="tiny" />
|
||||
<span>{key.name || key.key}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedNewAttributeKey && (
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-slate-600">{t("common.value")}</label>
|
||||
{selectedAttributeKey?.dataType === "date" ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={newAttributeValue ? newAttributeValue.split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value
|
||||
? new Date(e.target.value).toISOString()
|
||||
: "";
|
||||
setNewAttributeValue(dateValue);
|
||||
}}
|
||||
/>
|
||||
) : selectedAttributeKey?.dataType === "number" ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={newAttributeValue}
|
||||
onChange={(e) => setNewAttributeValue(e.target.value)}
|
||||
placeholder={t("common.enter_value")}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={newAttributeValue}
|
||||
onChange={(e) => setNewAttributeValue(e.target.value)}
|
||||
placeholder={t("common.enter_value")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddAttribute}
|
||||
disabled={!selectedNewAttributeKey || !newAttributeValue}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || !form.formState.isDirty}
|
||||
loading={isSubmitting}>
|
||||
{t("common.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
|
||||
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import {
|
||||
getContactAttributes,
|
||||
getContactAttributesWithMetadata,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
|
||||
@@ -22,14 +25,21 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [environmentTags, contact, contactAttributes, publishedLinkSurveys, contactAttributeKeys] =
|
||||
await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getContactAttributes(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
]);
|
||||
const [
|
||||
environmentTags,
|
||||
contact,
|
||||
contactAttributes,
|
||||
publishedLinkSurveys,
|
||||
attributesWithMetadata,
|
||||
allAttributeKeys,
|
||||
] = await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getContactAttributes(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
getContactAttributesWithMetadata(params.contactId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(t("environments.contacts.contact_not_found"));
|
||||
@@ -45,8 +55,9 @@ export const SingleContactPage = async (props: {
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
publishedLinkSurveys={publishedLinkSurveys}
|
||||
currentAttributes={contactAttributes}
|
||||
attributeKeys={contactAttributeKeys}
|
||||
currentAttributes={attributesWithMetadata}
|
||||
allAttributeKeys={allAttributeKeys}
|
||||
attributeKeys={allAttributeKeys}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -162,6 +162,8 @@ export const updateUser = async (
|
||||
// Single comprehensive query - gets contact + user state data
|
||||
let contactData = await getContactWithFullData(environmentId, userId);
|
||||
|
||||
console.log("contactData", contactData);
|
||||
|
||||
// Create contact if doesn't exist
|
||||
if (!contactData) {
|
||||
contactData = await createContact(environmentId, userId);
|
||||
|
||||
@@ -65,6 +65,7 @@ export const updateContactAttributeKey = async (
|
||||
description: data.description,
|
||||
name: data.name,
|
||||
key: data.key,
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
export const ZContactAttributeKeyCreateInput = z.object({
|
||||
key: z.string(),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(["custom"]),
|
||||
dataType: ZContactAttributeDataType.optional(),
|
||||
environmentId: z.string(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
@@ -13,6 +15,7 @@ export const ZContactAttributeKeyUpdateInput = z.object({
|
||||
description: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
dataType: ZContactAttributeDataType.optional(),
|
||||
});
|
||||
|
||||
export type TContactAttributeKeyUpdateInput = z.infer<typeof ZContactAttributeKeyUpdateInput>;
|
||||
|
||||
@@ -47,6 +47,7 @@ export const createContactAttributeKey = async (
|
||||
name: data.name ?? data.key,
|
||||
type: data.type,
|
||||
description: data.description ?? "",
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
|
||||
@@ -2,8 +2,11 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
|
||||
import { TContactBulkUploadContact } from "@/modules/ee/contacts/types/contact";
|
||||
|
||||
export const upsertBulkContacts = async (
|
||||
@@ -88,6 +91,72 @@ export const upsertBulkContacts = async (
|
||||
}),
|
||||
]);
|
||||
|
||||
// Type Detection Phase: Analyze attribute values to detect data types
|
||||
// For each attribute key, collect all non-empty values and detect type from first value
|
||||
const attributeValuesByKey = new Map<string, string[]>();
|
||||
|
||||
contacts.forEach((contact) => {
|
||||
contact.attributes.forEach((attr) => {
|
||||
if (!attributeValuesByKey.has(attr.attributeKey.key)) {
|
||||
attributeValuesByKey.set(attr.attributeKey.key, []);
|
||||
}
|
||||
if (attr.value.trim() !== "") {
|
||||
attributeValuesByKey.get(attr.attributeKey.key)!.push(attr.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Build a map of attribute keys to their detected/existing data types
|
||||
const attributeTypeMap = new Map<string, TContactAttributeDataType>();
|
||||
|
||||
for (const [key, values] of attributeValuesByKey) {
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === key);
|
||||
|
||||
if (existingKey) {
|
||||
// Use existing dataType for existing keys
|
||||
attributeTypeMap.set(key, existingKey.dataType);
|
||||
} else {
|
||||
// Detect type from first non-empty value for new keys
|
||||
const firstValue = values.find((v) => v !== "");
|
||||
if (firstValue) {
|
||||
const detectedType = detectAttributeDataType(firstValue);
|
||||
attributeTypeMap.set(key, detectedType);
|
||||
} else {
|
||||
attributeTypeMap.set(key, "string"); // default for empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all values can be converted to their detected/expected type
|
||||
// If validation fails for any value, we fallback to treating that attribute as string type
|
||||
const typeValidationErrors: string[] = [];
|
||||
|
||||
for (const [key, dataType] of attributeTypeMap) {
|
||||
const values = attributeValuesByKey.get(key) || [];
|
||||
|
||||
// Skip validation for string type (always valid)
|
||||
if (dataType === "string") continue;
|
||||
|
||||
for (const value of values) {
|
||||
try {
|
||||
// Test if we can convert the value to the expected type
|
||||
prepareAttributeColumnsForStorage(value, dataType);
|
||||
} catch {
|
||||
// If any value fails conversion, downgrade this attribute to string type for compatibility
|
||||
attributeTypeMap.set(key, "string");
|
||||
typeValidationErrors.push(
|
||||
`Attribute "${key}" has mixed or invalid values for type "${dataType}", treating as string type`
|
||||
);
|
||||
break; // No need to check remaining values for this key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log validation warnings if any
|
||||
if (typeValidationErrors.length > 0) {
|
||||
logger.warn({ errors: typeValidationErrors }, "Type validation warnings during bulk upload");
|
||||
}
|
||||
|
||||
// Build a map from email to contact id (if the email attribute exists)
|
||||
const contactMap = new Map<
|
||||
string,
|
||||
@@ -239,28 +308,35 @@ export const upsertBulkContacts = async (
|
||||
|
||||
for (const contact of filteredContacts) {
|
||||
for (const attr of contact.attributes) {
|
||||
if (!attributeKeyMap[attr.attributeKey.key]) {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
} else {
|
||||
if (attributeKeyMap[attr.attributeKey.key]) {
|
||||
// Check if the name has changed for existing attribute keys
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
|
||||
if (existingKey && existingKey.name !== attr.attributeKey.name) {
|
||||
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
} else {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle both missing keys and name updates in a single batch operation
|
||||
const keysToUpsert = new Map<string, { key: string; name: string }>();
|
||||
const keysToUpsert = new Map<
|
||||
string,
|
||||
{ key: string; name: string; dataType: TContactAttributeDataType }
|
||||
>();
|
||||
|
||||
// Collect all keys that need to be created or updated
|
||||
for (const [key, value] of missingKeysMap) {
|
||||
keysToUpsert.set(key, value);
|
||||
const dataType = attributeTypeMap.get(key) ?? "string";
|
||||
keysToUpsert.set(key, { ...value, dataType });
|
||||
}
|
||||
|
||||
for (const [key, value] of attributeKeyNameUpdates) {
|
||||
keysToUpsert.set(key, value);
|
||||
// For name updates, preserve existing dataType
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === key);
|
||||
const dataType = existingKey?.dataType ?? "string";
|
||||
keysToUpsert.set(key, { ...value, dataType });
|
||||
}
|
||||
|
||||
if (keysToUpsert.size > 0) {
|
||||
@@ -272,12 +348,13 @@ export const upsertBulkContacts = async (
|
||||
|
||||
// Use raw query to perform upsert
|
||||
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
|
||||
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "created_at", "updated_at")
|
||||
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "dataType", "created_at", "updated_at")
|
||||
SELECT
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.key)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.name)}]`}),
|
||||
${environmentId},
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.dataType)}]`}::text[]::"ContactAttributeDataType"[]),
|
||||
NOW(),
|
||||
NOW()
|
||||
ON CONFLICT ("key", "environmentId")
|
||||
@@ -308,25 +385,39 @@ export const upsertBulkContacts = async (
|
||||
|
||||
// Prepare attributes for both new and existing contacts
|
||||
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
contact.attributes.map((attr) => {
|
||||
const dataType = attributeTypeMap.get(attr.attributeKey.key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(attr.value, dataType);
|
||||
|
||||
return {
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
contact.attributes.map((attr) => {
|
||||
const dataType = attributeTypeMap.get(attr.attributeKey.key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(attr.value, dataType);
|
||||
|
||||
return {
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
|
||||
@@ -341,7 +432,7 @@ export const upsertBulkContacts = async (
|
||||
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "ContactAttribute" (
|
||||
"id", "created_at", "updated_at", "contactId", "value", "attributeKeyId"
|
||||
"id", "created_at", "updated_at", "contactId", "value", "valueNumber", "valueDate", "attributeKeyId"
|
||||
)
|
||||
SELECT
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.id)}]`}),
|
||||
@@ -349,9 +440,13 @@ export const upsertBulkContacts = async (
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.updatedAt)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.contactId)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.value)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.valueNumber)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.valueDate)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.attributeKeyId)}]`})
|
||||
ON CONFLICT ("contactId", "attributeKeyId") DO UPDATE SET
|
||||
"value" = EXCLUDED."value",
|
||||
"valueNumber" = EXCLUDED."valueNumber",
|
||||
"valueDate" = EXCLUDED."valueDate",
|
||||
"updated_at" = EXCLUDED."updated_at"
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -24,6 +25,7 @@ const ZCreateContactAttributeKeyAction = z.object({
|
||||
}),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
dataType: ZContactAttributeDataType.optional(),
|
||||
});
|
||||
|
||||
type TCreateContactAttributeKeyActionInput = z.infer<typeof ZCreateContactAttributeKeyAction>;
|
||||
@@ -66,6 +68,7 @@ export const createContactAttributeKeyAction = authenticatedActionClient
|
||||
key: parsedInput.key,
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
dataType: parsedInput.dataType,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.newObject = contactAttributeKey;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { SearchBar } from "@/modules/ui/components/search-bar";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { deleteAttributeKeyAction } from "../actions";
|
||||
import { generateAttributeKeysTableColumns } from "./attribute-keys-table-columns";
|
||||
|
||||
interface AttributeKeysManagerProps {
|
||||
environmentId: string;
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function AttributeKeysManager({
|
||||
environmentId,
|
||||
attributeKeys,
|
||||
isReadOnly,
|
||||
}: AttributeKeysManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeletingKeys, setIsDeletingKeys] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
// Filter to only show custom attribute keys
|
||||
const customAttributeKeys = useMemo(() => {
|
||||
return attributeKeys.filter((key) => key.type === "custom");
|
||||
}, [attributeKeys]);
|
||||
|
||||
// Filter by search
|
||||
const filteredAttributeKeys = useMemo(() => {
|
||||
if (!searchValue) return customAttributeKeys;
|
||||
|
||||
return customAttributeKeys.filter((key) => {
|
||||
const searchLower = searchValue.toLowerCase();
|
||||
return (
|
||||
key.key.toLowerCase().includes(searchLower) ||
|
||||
key.name?.toLowerCase().includes(searchLower) ||
|
||||
key.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
}, [customAttributeKeys, searchValue]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return generateAttributeKeysTableColumns(isReadOnly);
|
||||
}, [isReadOnly]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredAttributeKeys,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
},
|
||||
enableRowSelection: !isReadOnly,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const selectedAttributeKeyIds = selectedRows.map((row) => row.original.id);
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedAttributeKeyIds.length === 0) return;
|
||||
|
||||
setIsDeletingKeys(true);
|
||||
try {
|
||||
const deletePromises = selectedAttributeKeyIds.map((id) =>
|
||||
deleteAttributeKeyAction({ environmentId, attributeKeyId: id })
|
||||
);
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
toast.success(
|
||||
t("environments.contacts.attribute_keys_deleted_successfully", {
|
||||
count: selectedAttributeKeyIds.length,
|
||||
})
|
||||
);
|
||||
setRowSelection({});
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeletingKeys(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<SearchBar
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
placeholder={t("environments.contacts.search_attribute_keys")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toolbar with bulk actions */}
|
||||
{!isReadOnly && selectedRows.length > 0 && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||
<p className="text-sm text-slate-700">
|
||||
{t("environments.contacts.selected_attribute_keys", { count: selectedRows.length })}
|
||||
</p>
|
||||
<Button variant="destructive" size="sm" onClick={() => setDeleteDialogOpen(true)}>
|
||||
{t("common.delete_selected", { count: selectedRows.length })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="rounded-t-lg">
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const isFirstHeader = index === 0;
|
||||
const isLastHeader = index === headerGroup.headers.length - 1;
|
||||
// Skip rendering checkbox in the header for selection column
|
||||
if (header.id === "select") {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="h-10 w-12 rounded-tl-lg border-b border-slate-200 bg-white px-4 font-semibold"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`h-10 border-b border-slate-200 bg-white px-4 font-semibold ${
|
||||
isFirstHeader ? "rounded-tl-lg" : isLastHeader ? "rounded-tr-lg" : ""
|
||||
}`}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row, index) => {
|
||||
const isLastRow = index === table.getRowModel().rows.length - 1;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={`hover:bg-white ${isLastRow ? "rounded-b-lg" : ""}`}>
|
||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||
const isFirstCell = cellIndex === 0;
|
||||
const isLastCell = cellIndex === row.getVisibleCells().length - 1;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`py-2 ${
|
||||
isLastRow
|
||||
? isFirstCell
|
||||
? "rounded-bl-lg"
|
||||
: isLastCell
|
||||
? "rounded-br-lg"
|
||||
: ""
|
||||
: ""
|
||||
}`}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<p className="text-slate-400">{t("environments.contacts.no_custom_attributes_yet")}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat={
|
||||
selectedRows.length === 1
|
||||
? selectedRows[0].original.name || selectedRows[0].original.key
|
||||
: t("environments.contacts.selected_attribute_keys", { count: selectedRows.length })
|
||||
}
|
||||
onDelete={handleBulkDelete}
|
||||
isDeleting={isDeletingKeys}
|
||||
text={t("environments.contacts.delete_attribute_keys_warning_detailed", {
|
||||
count: selectedRows.length,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { Calendar1Icon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
const getIconForDataType = (dataType: TContactAttributeDataType) => {
|
||||
switch (dataType) {
|
||||
case "date":
|
||||
return <Calendar1Icon className="h-4 w-4 text-slate-600" />;
|
||||
case "number":
|
||||
return <HashIcon className="h-4 w-4 text-slate-600" />;
|
||||
case "string":
|
||||
default:
|
||||
return <TagIcon className="h-4 w-4 text-slate-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
export const generateAttributeKeysTableColumns = (isReadOnly: boolean): ColumnDef<TContactAttributeKey>[] => {
|
||||
const nameColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.name || row.original.key;
|
||||
return <span className="font-medium text-slate-900">{name}</span>;
|
||||
},
|
||||
};
|
||||
|
||||
const keyColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "key",
|
||||
accessorKey: "key",
|
||||
header: "Key",
|
||||
cell: ({ row }) => {
|
||||
return <IdBadge id={row.original.key} />;
|
||||
},
|
||||
};
|
||||
|
||||
const dataTypeColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "dataType",
|
||||
accessorKey: "dataType",
|
||||
header: "Data Type",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{getIconForDataType(row.original.dataType)}
|
||||
<Badge text={row.original.dataType} type="gray" size="tiny" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const descriptionColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => {
|
||||
return <span className="text-sm text-slate-600">{row.original.description || "—"}</span>;
|
||||
},
|
||||
};
|
||||
|
||||
const createdAtColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const updatedAtColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">
|
||||
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const baseColumns = [
|
||||
nameColumn,
|
||||
keyColumn,
|
||||
dataTypeColumn,
|
||||
descriptionColumn,
|
||||
createdAtColumn,
|
||||
updatedAtColumn,
|
||||
];
|
||||
|
||||
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { Calendar1Icon, HashIcon, PlusIcon, TagIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -18,6 +19,13 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { createContactAttributeKeyAction } from "../actions";
|
||||
|
||||
interface CreateAttributeModalProps {
|
||||
@@ -33,6 +41,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
key: "",
|
||||
name: "",
|
||||
description: "",
|
||||
dataType: "string" as TContactAttributeDataType,
|
||||
});
|
||||
const [keyError, setKeyError] = useState<string>("");
|
||||
|
||||
@@ -41,6 +50,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
key: "",
|
||||
name: "",
|
||||
description: "",
|
||||
dataType: "string",
|
||||
});
|
||||
setKeyError("");
|
||||
setOpen(false);
|
||||
@@ -92,6 +102,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
key: formData.key,
|
||||
name: formData.name || formData.key,
|
||||
description: formData.description || undefined,
|
||||
dataType: formData.dataType,
|
||||
});
|
||||
|
||||
if (!createContactAttributeKeyResponse?.data) {
|
||||
@@ -166,6 +177,42 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.data_type")}
|
||||
</label>
|
||||
<Select
|
||||
value={formData.dataType}
|
||||
onValueChange={(value: TContactAttributeDataType) =>
|
||||
setFormData((prev) => ({ ...prev, dataType: value }))
|
||||
}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<span>{t("common.string")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="number">
|
||||
<div className="flex items-center gap-2">
|
||||
<HashIcon className="h-4 w-4" />
|
||||
<span>{t("common.number")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="date">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
<span>{t("common.date")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500">{t("environments.contacts.data_type_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_description")} ({t("common.optional")})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar1Icon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +21,18 @@ import {
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { updateContactAttributeKeyAction } from "../actions";
|
||||
|
||||
const getDataTypeIcon = (dataType: string) => {
|
||||
switch (dataType) {
|
||||
case "date":
|
||||
return <Calendar1Icon className="h-4 w-4" />;
|
||||
case "number":
|
||||
return <HashIcon className="h-4 w-4" />;
|
||||
case "string":
|
||||
default:
|
||||
return <TagIcon className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
interface EditAttributeModalProps {
|
||||
attribute: TContactAttributeKey;
|
||||
open: boolean;
|
||||
@@ -86,6 +100,19 @@ export function EditAttributeModal({ attribute, open, setOpen }: Readonly<EditAt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.data_type")}
|
||||
</label>
|
||||
<div className="flex h-10 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3">
|
||||
{getDataTypeIcon(attribute.dataType)}
|
||||
<Badge text={t(`common.${attribute.dataType}`)} type="gray" size="tiny" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("environments.contacts.data_type_cannot_be_changed")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_label")}
|
||||
|
||||
@@ -131,6 +131,7 @@ export const ContactDataView = ({
|
||||
key: attr.key,
|
||||
name: attr.name,
|
||||
value: contact.attributes[attr.key] ?? "",
|
||||
dataType: attr.dataType,
|
||||
})),
|
||||
}));
|
||||
}, [contacts, environmentAttributes]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
@@ -27,7 +28,7 @@ export const generateContactTableColumns = (
|
||||
header: "User ID",
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.userId;
|
||||
return <IdBadge id={userId} showCopyIconOnHover={true} />;
|
||||
return <IdBadge id={userId} />;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,7 +72,9 @@ export const generateContactTableColumns = (
|
||||
header: attr.name ?? attr.key,
|
||||
cell: ({ row }) => {
|
||||
const attribute = row.original.attributes.find((a) => a.key === attr.key);
|
||||
return <HighlightedText value={attribute?.value} searchValue={searchValue} />;
|
||||
if (!attribute) return null;
|
||||
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType);
|
||||
return <HighlightedText value={formattedValue} searchValue={searchValue} />;
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
@@ -294,9 +294,9 @@ export const ContactsTable = ({
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{t("common.no_results")}
|
||||
<p className="text-slate-400">{t("common.no_results")}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -7,8 +7,7 @@ import { useEffect, useMemo, useRef } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -33,11 +32,18 @@ import { InputCombobox, TComboboxOption } from "@/modules/ui/components/input-co
|
||||
import { updateContactAttributesAction } from "../actions";
|
||||
import { TEditContactAttributesForm, ZEditContactAttributesForm } from "../types/contact";
|
||||
|
||||
interface AttributeWithMetadata {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
|
||||
interface EditContactAttributesModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactId: string;
|
||||
currentAttributes: TContactAttributes;
|
||||
currentAttributes: AttributeWithMetadata[];
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
@@ -53,9 +59,9 @@ export const EditContactAttributesModal = ({
|
||||
// Convert current attributes to form format
|
||||
const defaultValues: TEditContactAttributesForm = useMemo(
|
||||
() => ({
|
||||
attributes: Object.entries(currentAttributes).map(([key, value]) => ({
|
||||
key,
|
||||
value: value ?? "",
|
||||
attributes: currentAttributes.map((attr) => ({
|
||||
key: attr.key,
|
||||
value: attr.value ?? "",
|
||||
})),
|
||||
}),
|
||||
[currentAttributes]
|
||||
|
||||
151
apps/web/modules/ee/contacts/lib/attribute-storage.ts
Normal file
151
apps/web/modules/ee/contacts/lib/attribute-storage.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { detectAttributeDataType } from "./detect-attribute-type";
|
||||
|
||||
/**
|
||||
* Storage columns for a contact attribute value
|
||||
*/
|
||||
export type TAttributeStorageColumns = {
|
||||
value: string;
|
||||
valueNumber: number | null;
|
||||
valueDate: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares an attribute value for storage by routing to the appropriate column(s).
|
||||
* Used when creating a new attribute - detects type and prepares all columns.
|
||||
*
|
||||
* @param value - The raw value to store (string, number, or Date)
|
||||
* @returns Object with dataType and column values for storage
|
||||
*/
|
||||
export const prepareNewAttributeForStorage = (
|
||||
value: string | number | Date
|
||||
): {
|
||||
dataType: TContactAttributeDataType;
|
||||
columns: TAttributeStorageColumns;
|
||||
} => {
|
||||
const dataType = detectAttributeDataType(value);
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
|
||||
return { dataType, columns };
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares attribute column values based on the data type.
|
||||
* Used when updating an existing attribute with a known data type.
|
||||
*
|
||||
* @param value - The raw value to store (string, number, or Date)
|
||||
* @param dataType - The data type of the attribute key
|
||||
* @returns Object with column values for storage
|
||||
*/
|
||||
export const prepareAttributeColumnsForStorage = (
|
||||
value: string | number | Date,
|
||||
dataType: TContactAttributeDataType
|
||||
): TAttributeStorageColumns => {
|
||||
switch (dataType) {
|
||||
case "string": {
|
||||
// String type - only use value column
|
||||
let stringValue: string;
|
||||
|
||||
if (value instanceof Date) {
|
||||
stringValue = value.toISOString();
|
||||
} else if (typeof value === "number") {
|
||||
stringValue = String(value);
|
||||
} else {
|
||||
stringValue = value;
|
||||
}
|
||||
|
||||
return {
|
||||
value: stringValue,
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// Number type - use both value (for backwards compat) and valueNumber columns
|
||||
let numericValue: number;
|
||||
|
||||
if (typeof value === "number") {
|
||||
numericValue = value;
|
||||
} else if (typeof value === "string") {
|
||||
numericValue = Number(value.trim());
|
||||
} else {
|
||||
// Date - shouldn't happen if validation passed, but handle gracefully
|
||||
numericValue = value.getTime();
|
||||
}
|
||||
|
||||
return {
|
||||
value: String(numericValue),
|
||||
valueNumber: numericValue,
|
||||
valueDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
case "date": {
|
||||
// Date type - use both value (for backwards compat) and valueDate columns
|
||||
let dateValue: Date;
|
||||
|
||||
if (value instanceof Date) {
|
||||
dateValue = value;
|
||||
} else if (typeof value === "string") {
|
||||
dateValue = new Date(value);
|
||||
} else {
|
||||
// Number - treat as timestamp
|
||||
dateValue = new Date(value);
|
||||
}
|
||||
|
||||
return {
|
||||
value: dateValue.toISOString(),
|
||||
valueNumber: null,
|
||||
valueDate: dateValue,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
// Unknown type - treat as string
|
||||
return {
|
||||
value: String(value),
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads an attribute value from the appropriate column based on data type.
|
||||
*
|
||||
* @param attribute - The attribute with all column values
|
||||
* @param dataType - The data type of the attribute key
|
||||
* @returns The value from the appropriate column
|
||||
*/
|
||||
export const readAttributeValue = (
|
||||
attribute: {
|
||||
value: string;
|
||||
valueNumber: number | null;
|
||||
valueDate: Date | null;
|
||||
},
|
||||
dataType: TContactAttributeDataType
|
||||
): string => {
|
||||
// For now, always return from value column for backwards compatibility
|
||||
// The typed columns are primarily for query performance
|
||||
switch (dataType) {
|
||||
case "number":
|
||||
// Return from valueNumber if available, otherwise fallback to value
|
||||
if (attribute.valueNumber === null) {
|
||||
return attribute.value;
|
||||
}
|
||||
return String(attribute.valueNumber);
|
||||
|
||||
case "date":
|
||||
// Return from valueDate if available, otherwise fallback to value
|
||||
if (attribute.valueDate === null) {
|
||||
return attribute.value;
|
||||
}
|
||||
return attribute.valueDate.toISOString();
|
||||
|
||||
case "string":
|
||||
default:
|
||||
return attribute.value;
|
||||
}
|
||||
};
|
||||
@@ -4,12 +4,14 @@ import { TContactAttributes, ZContactAttributes } from "@formbricks/types/contac
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { prepareNewAttributeForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import {
|
||||
getContactAttributes,
|
||||
hasEmailAttribute,
|
||||
hasUserIdAttribute,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { validateAndParseAttributeValue } from "@/modules/ee/contacts/lib/validate-attribute-type";
|
||||
|
||||
// Default/system attributes that should not be deleted even if missing from payload
|
||||
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
|
||||
@@ -168,22 +170,42 @@ export const updateAttributes = async (
|
||||
// Create lookup map for attribute keys
|
||||
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
|
||||
|
||||
// Separate existing and new attributes in a single pass
|
||||
const { existingAttributes, newAttributes } = Object.entries(contactAttributes).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const attributeKey = contactAttributeKeyMap.get(key);
|
||||
if (attributeKey) {
|
||||
acc.existingAttributes.push({ key, value, attributeKeyId: attributeKey.id });
|
||||
// Separate existing and new attributes, validating types for existing attributes
|
||||
const existingAttributes: {
|
||||
key: string;
|
||||
attributeKeyId: string;
|
||||
columns: { value: string; valueNumber: number | null; valueDate: Date | null };
|
||||
}[] = [];
|
||||
const newAttributes: { key: string; value: string }[] = [];
|
||||
const typeValidationErrors: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(contactAttributes)) {
|
||||
const attributeKey = contactAttributeKeyMap.get(key);
|
||||
|
||||
if (attributeKey) {
|
||||
// Existing attribute - validate type and prepare columns
|
||||
const validationResult = validateAndParseAttributeValue(value, attributeKey.dataType, key);
|
||||
|
||||
if (validationResult.valid) {
|
||||
existingAttributes.push({
|
||||
key,
|
||||
attributeKeyId: attributeKey.id,
|
||||
columns: validationResult.parsedValue,
|
||||
});
|
||||
} else {
|
||||
acc.newAttributes.push({ key, value });
|
||||
// Type mismatch - add to errors
|
||||
typeValidationErrors.push(validationResult.error);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ existingAttributes: [], newAttributes: [] } as {
|
||||
existingAttributes: { key: string; value: string; attributeKeyId: string }[];
|
||||
newAttributes: { key: string; value: string }[];
|
||||
} else {
|
||||
// New attribute - will detect type on creation
|
||||
newAttributes.push({ key, value });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Add type validation errors to messages
|
||||
if (typeValidationErrors.length > 0) {
|
||||
messages.push(...typeValidationErrors);
|
||||
}
|
||||
|
||||
if (emailExists) {
|
||||
messages.push("The email already exists for this environment and was not updated.");
|
||||
@@ -193,10 +215,10 @@ export const updateAttributes = async (
|
||||
messages.push("The userId already exists for this environment and was not updated.");
|
||||
}
|
||||
|
||||
// Update all existing attributes
|
||||
// Update all existing attributes with typed column values
|
||||
if (existingAttributes.length > 0) {
|
||||
await prisma.$transaction(
|
||||
existingAttributes.map(({ attributeKeyId, value }) =>
|
||||
existingAttributes.map(({ attributeKeyId, columns }) =>
|
||||
prisma.contactAttribute.upsert({
|
||||
where: {
|
||||
contactId_attributeKeyId: {
|
||||
@@ -204,11 +226,17 @@ export const updateAttributes = async (
|
||||
attributeKeyId,
|
||||
},
|
||||
},
|
||||
update: { value },
|
||||
update: {
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
create: {
|
||||
contactId,
|
||||
attributeKeyId,
|
||||
value,
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -227,18 +255,25 @@ export const updateAttributes = async (
|
||||
} else {
|
||||
// Create new attributes since we're under the limit
|
||||
await prisma.$transaction(
|
||||
newAttributes.map(({ key, value }) =>
|
||||
prisma.contactAttributeKey.create({
|
||||
newAttributes.map(({ key, value }) => {
|
||||
const { dataType, columns } = prepareNewAttributeForStorage(value);
|
||||
return prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
key,
|
||||
type: "custom",
|
||||
dataType,
|
||||
environment: { connect: { id: environmentId } },
|
||||
attributes: {
|
||||
create: { contactId, value },
|
||||
create: {
|
||||
contactId,
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getContactAttributeKeys = reactCache(
|
||||
@@ -28,6 +28,7 @@ export const createContactAttributeKey = async (data: {
|
||||
key: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
dataType?: TContactAttributeDataType;
|
||||
}): Promise<TContactAttributeKey> => {
|
||||
try {
|
||||
const contactAttributeKey = await prisma.contactAttributeKey.create({
|
||||
@@ -37,6 +38,7 @@ export const createContactAttributeKey = async (data: {
|
||||
description: data.description ?? null,
|
||||
environmentId: data.environmentId,
|
||||
type: "custom",
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@ import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
const selectContactAttribute = {
|
||||
value: true,
|
||||
valueNumber: true,
|
||||
valueDate: true,
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
name: true,
|
||||
dataType: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ContactAttributeSelect;
|
||||
@@ -41,6 +44,34 @@ export const getContactAttributes = reactCache(async (contactId: string) => {
|
||||
}
|
||||
});
|
||||
|
||||
export const getContactAttributesWithMetadata = reactCache(async (contactId: string) => {
|
||||
validateInputs([contactId, ZId]);
|
||||
|
||||
try {
|
||||
const prismaAttributes = await prisma.contactAttribute.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
select: selectContactAttribute,
|
||||
});
|
||||
|
||||
return prismaAttributes.map((attr) => ({
|
||||
key: attr.attributeKey.key,
|
||||
name: attr.attributeKey.name,
|
||||
value: attr.value,
|
||||
valueNumber: attr.valueNumber,
|
||||
valueDate: attr.valueDate,
|
||||
dataType: attr.attributeKey.dataType,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const hasEmailAttribute = reactCache(
|
||||
async (email: string, environmentId: string, contactId: string): Promise<boolean> => {
|
||||
validateInputs([email, ZUserEmail], [environmentId, ZId], [contactId, ZId]);
|
||||
|
||||
@@ -4,10 +4,13 @@ import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
|
||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import {
|
||||
@@ -210,14 +213,25 @@ const contactAttributesInclude = {
|
||||
},
|
||||
} satisfies Prisma.ContactInclude;
|
||||
|
||||
// Helper to create attribute objects for Prisma create operations
|
||||
const createAttributeConnections = (record: Record<string, string>, environmentId: string) =>
|
||||
Object.entries(record).map(([key, value]) => ({
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key, environmentId } },
|
||||
},
|
||||
value,
|
||||
}));
|
||||
// Helper to create attribute objects for Prisma create operations with typed columns
|
||||
const createAttributeConnections = (
|
||||
record: Record<string, string>,
|
||||
environmentId: string,
|
||||
attributeTypeMap: Map<string, TContactAttributeDataType>
|
||||
) =>
|
||||
Object.entries(record).map(([key, value]) => {
|
||||
const dataType = attributeTypeMap.get(key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
|
||||
return {
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key, environmentId } },
|
||||
},
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
};
|
||||
});
|
||||
|
||||
// Helper to handle userId conflicts when updating/overwriting contacts
|
||||
const resolveUserIdConflict = (
|
||||
@@ -327,7 +341,7 @@ export const createContactsFromCSV = async (
|
||||
// Fetch existing attribute keys and cache them
|
||||
const existingAttributeKeys = await prisma.contactAttributeKey.findMany({
|
||||
where: { environmentId },
|
||||
select: { key: true, id: true },
|
||||
select: { key: true, id: true, dataType: true },
|
||||
});
|
||||
|
||||
const attributeKeyMap = new Map<string, string>();
|
||||
@@ -345,6 +359,71 @@ export const createContactsFromCSV = async (
|
||||
Object.keys(record).forEach((key) => csvKeys.add(key));
|
||||
});
|
||||
|
||||
// Type Detection Phase: Detect data types for new attribute keys
|
||||
const attributeValuesByKey = new Map<string, string[]>();
|
||||
|
||||
csvData.forEach((record) => {
|
||||
Object.entries(record).forEach(([key, value]) => {
|
||||
if (!attributeValuesByKey.has(key)) {
|
||||
attributeValuesByKey.set(key, []);
|
||||
}
|
||||
if (value && value.trim() !== "") {
|
||||
attributeValuesByKey.get(key)!.push(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Build a map of attribute keys to their detected/existing data types
|
||||
const attributeTypeMap = new Map<string, TContactAttributeDataType>();
|
||||
|
||||
for (const [key, values] of attributeValuesByKey) {
|
||||
// Use case-insensitive lookup for existing keys
|
||||
const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase());
|
||||
const existingKey = actualKey ? existingAttributeKeys.find((ak) => ak.key === actualKey) : null;
|
||||
|
||||
if (existingKey) {
|
||||
// Use existing dataType for existing keys
|
||||
attributeTypeMap.set(key, existingKey.dataType);
|
||||
} else {
|
||||
// Detect type from first non-empty value for new keys
|
||||
const firstValue = values.find((v) => v !== "");
|
||||
if (firstValue) {
|
||||
const detectedType = detectAttributeDataType(firstValue);
|
||||
attributeTypeMap.set(key, detectedType);
|
||||
} else {
|
||||
attributeTypeMap.set(key, "string"); // default for empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all values can be converted to their detected type
|
||||
// If validation fails, fallback to string type for compatibility
|
||||
const typeValidationErrors: string[] = [];
|
||||
|
||||
for (const [key, dataType] of attributeTypeMap) {
|
||||
const values = attributeValuesByKey.get(key) || [];
|
||||
|
||||
// Skip validation for string type (always valid)
|
||||
if (dataType === "string") continue;
|
||||
|
||||
for (const value of values) {
|
||||
try {
|
||||
prepareAttributeColumnsForStorage(value, dataType);
|
||||
} catch {
|
||||
// If any value fails conversion, downgrade to string type
|
||||
attributeTypeMap.set(key, "string");
|
||||
typeValidationErrors.push(
|
||||
`Attribute "${key}" has mixed or invalid values for type "${dataType}", treating as string type`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeValidationErrors.length > 0) {
|
||||
logger.warn({ errors: typeValidationErrors }, "Type validation warnings during CSV upload");
|
||||
}
|
||||
|
||||
// Identify missing attribute keys (case-insensitive check)
|
||||
const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
|
||||
|
||||
@@ -363,6 +442,7 @@ export const createContactsFromCSV = async (
|
||||
data: Array.from(uniqueMissingKeys.values()).map((key) => ({
|
||||
key,
|
||||
name: key,
|
||||
dataType: attributeTypeMap.get(key) ?? "string",
|
||||
environmentId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
@@ -374,7 +454,7 @@ export const createContactsFromCSV = async (
|
||||
key: { in: Array.from(uniqueMissingKeys.values()) },
|
||||
environmentId,
|
||||
},
|
||||
select: { key: true, id: true },
|
||||
select: { key: true, id: true, dataType: true },
|
||||
});
|
||||
|
||||
newAttributeKeys.forEach((attrKey) => {
|
||||
@@ -414,19 +494,30 @@ export const createContactsFromCSV = async (
|
||||
case "update": {
|
||||
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
|
||||
|
||||
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => ({
|
||||
where: {
|
||||
contactId_attributeKeyId: {
|
||||
contactId: existingContact.id,
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => {
|
||||
const dataType = attributeTypeMap.get(key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
|
||||
return {
|
||||
where: {
|
||||
contactId_attributeKeyId: {
|
||||
contactId: existingContact.id,
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
},
|
||||
},
|
||||
},
|
||||
update: { value },
|
||||
create: {
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
value,
|
||||
},
|
||||
}));
|
||||
update: {
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
create: {
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Update contact with upserted attributes
|
||||
return prisma.contact.update({
|
||||
@@ -453,7 +544,7 @@ export const createContactsFromCSV = async (
|
||||
where: { id: existingContact.id },
|
||||
data: {
|
||||
attributes: {
|
||||
create: createAttributeConnections(recordToProcess, environmentId),
|
||||
create: createAttributeConnections(recordToProcess, environmentId, attributeTypeMap),
|
||||
},
|
||||
},
|
||||
include: contactAttributesInclude,
|
||||
@@ -466,7 +557,7 @@ export const createContactsFromCSV = async (
|
||||
data: {
|
||||
environmentId,
|
||||
attributes: {
|
||||
create: createAttributeConnections(mappedRecord, environmentId),
|
||||
create: createAttributeConnections(mappedRecord, environmentId, attributeTypeMap),
|
||||
},
|
||||
},
|
||||
include: contactAttributesInclude,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { detectAttributeDataType } from "./detect-attribute-type";
|
||||
|
||||
describe("detectAttributeDataType", () => {
|
||||
describe("Date object input", () => {
|
||||
test("detects Date objects as date type", () => {
|
||||
expect(detectAttributeDataType(new Date())).toBe("date");
|
||||
expect(detectAttributeDataType(new Date("2024-01-15"))).toBe("date");
|
||||
expect(detectAttributeDataType(new Date("2024-01-15T10:30:00Z"))).toBe("date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("number input", () => {
|
||||
test("detects numbers as number type", () => {
|
||||
expect(detectAttributeDataType(42)).toBe("number");
|
||||
expect(detectAttributeDataType(3.14)).toBe("number");
|
||||
expect(detectAttributeDataType(-10)).toBe("number");
|
||||
expect(detectAttributeDataType(0)).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("string input", () => {
|
||||
test("detects ISO 8601 date strings", () => {
|
||||
expect(detectAttributeDataType("2024-01-15")).toBe("date");
|
||||
expect(detectAttributeDataType("2024-01-15T10:30:00Z")).toBe("date");
|
||||
expect(detectAttributeDataType("2024-01-15T10:30:00.000Z")).toBe("date");
|
||||
expect(detectAttributeDataType("2023-12-31")).toBe("date");
|
||||
});
|
||||
|
||||
test("detects numeric string values", () => {
|
||||
expect(detectAttributeDataType("42")).toBe("number");
|
||||
expect(detectAttributeDataType("3.14")).toBe("number");
|
||||
expect(detectAttributeDataType("-10")).toBe("number");
|
||||
expect(detectAttributeDataType("0")).toBe("number");
|
||||
expect(detectAttributeDataType(" 123 ")).toBe("number");
|
||||
});
|
||||
|
||||
test("detects string values", () => {
|
||||
expect(detectAttributeDataType("hello")).toBe("string");
|
||||
expect(detectAttributeDataType("john@example.com")).toBe("string");
|
||||
expect(detectAttributeDataType("123abc")).toBe("string");
|
||||
expect(detectAttributeDataType("")).toBe("string");
|
||||
});
|
||||
|
||||
test("handles invalid date strings as string", () => {
|
||||
expect(detectAttributeDataType("2024-13-01")).toBe("string"); // Invalid month
|
||||
expect(detectAttributeDataType("not-a-date")).toBe("string");
|
||||
expect(detectAttributeDataType("2024/01/15")).toBe("string"); // Wrong format
|
||||
});
|
||||
|
||||
test("handles edge cases", () => {
|
||||
expect(detectAttributeDataType(" ")).toBe("string"); // Whitespace only
|
||||
expect(detectAttributeDataType("NaN")).toBe("string");
|
||||
expect(detectAttributeDataType("Infinity")).toBe("number"); // Technically a number
|
||||
});
|
||||
});
|
||||
});
|
||||
91
apps/web/modules/ee/contacts/lib/detect-attribute-type.ts
Normal file
91
apps/web/modules/ee/contacts/lib/detect-attribute-type.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
/**
|
||||
* Parses a date string in DD-MM-YYYY or MM-DD-YYYY format.
|
||||
* Uses heuristics to disambiguate between formats.
|
||||
*/
|
||||
const parseDateFromParts = (part1: number, part2: number, part3: number): Date | null => {
|
||||
// Heuristic: If first part > 12, it's likely DD-MM-YYYY
|
||||
if (part1 > 12) {
|
||||
return new Date(part3, part2 - 1, part1);
|
||||
}
|
||||
|
||||
// If second part > 12, it's definitely MM-DD-YYYY
|
||||
if (part2 > 12) {
|
||||
return new Date(part3, part1 - 1, part2);
|
||||
}
|
||||
|
||||
// Ambiguous - use additional heuristics
|
||||
if (part1 > 31 || part3 < 100) {
|
||||
// Likely YYYY-MM-DD format
|
||||
return new Date(part1, part2 - 1, part3);
|
||||
}
|
||||
|
||||
// Default to American format MM-DD-YYYY
|
||||
return new Date(part3, part1 - 1, part2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to parse a string as a date in various formats.
|
||||
*/
|
||||
const tryParseDate = (stringValue: string): Date | null => {
|
||||
// Try ISO format first (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss)
|
||||
if (/^\d{4}[-/]\d{2}[-/]\d{2}/.test(stringValue)) {
|
||||
return new Date(stringValue);
|
||||
}
|
||||
|
||||
// For DD-MM-YYYY or MM-DD-YYYY formats, parse manually
|
||||
const parts = stringValue.split(/[-/]/);
|
||||
if (parts.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [part1, part2, part3] = parts.map((p) => Number.parseInt(p, 10));
|
||||
return parseDateFromParts(part1, part2, part3);
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects the data type of an attribute value based on its format.
|
||||
* Used for first-time attribute creation to infer the dataType.
|
||||
*
|
||||
* Supported date formats:
|
||||
* - ISO 8601: YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss.sssZ
|
||||
* - European: DD-MM-YYYY or DD/MM/YYYY
|
||||
* - American: MM-DD-YYYY or MM/DD/YYYY
|
||||
*
|
||||
* @param value - The attribute value to detect the type of (string, number, or Date)
|
||||
* @returns The detected data type (string, number, or date)
|
||||
*/
|
||||
export const detectAttributeDataType = (value: string | number | Date): TContactAttributeDataType => {
|
||||
// Handle Date objects directly
|
||||
if (value instanceof Date) {
|
||||
return "date";
|
||||
}
|
||||
|
||||
// Handle numbers directly
|
||||
if (typeof value === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
// For string values, try to detect the actual type
|
||||
const stringValue = value.trim();
|
||||
|
||||
// Check if it matches common date formats
|
||||
const datePattern = /^(\d{4}[-/]\d{2}[-/]\d{2}|\d{2}[-/]\d{2}[-/]\d{4})/;
|
||||
if (datePattern.test(stringValue)) {
|
||||
const parsedDate = tryParseDate(stringValue);
|
||||
|
||||
// Verify it's a valid date
|
||||
if (parsedDate && !Number.isNaN(parsedDate.getTime())) {
|
||||
return "date";
|
||||
}
|
||||
}
|
||||
|
||||
// Check if numeric (integer or decimal)
|
||||
if (stringValue !== "" && !Number.isNaN(Number(stringValue))) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
// Default to string for everything else
|
||||
return "string";
|
||||
};
|
||||
46
apps/web/modules/ee/contacts/lib/format-attribute-value.ts
Normal file
46
apps/web/modules/ee/contacts/lib/format-attribute-value.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { format } from "date-fns";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
/**
|
||||
* Formats an attribute value for display based on its data type.
|
||||
*
|
||||
* @param value - The raw attribute value (string representation from DB)
|
||||
* @param dataType - The data type of the attribute
|
||||
* @returns Formatted string for display
|
||||
*/
|
||||
export const formatAttributeValue = (
|
||||
value: string | number | Date | null | undefined,
|
||||
dataType: TContactAttributeDataType
|
||||
): string => {
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
|
||||
switch (dataType) {
|
||||
case "date": {
|
||||
try {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
// Format as "Jan 15, 2024" for better readability
|
||||
return format(date, "MMM d, yyyy");
|
||||
} catch {
|
||||
// If date parsing fails, return the raw value
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// Format numbers with proper localization
|
||||
const num = typeof value === "number" ? value : Number.parseFloat(String(value));
|
||||
if (Number.isNaN(num)) {
|
||||
return String(value);
|
||||
}
|
||||
// Use toLocaleString for proper formatting with commas
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
case "string":
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
154
apps/web/modules/ee/contacts/lib/validate-attribute-type.test.ts
Normal file
154
apps/web/modules/ee/contacts/lib/validate-attribute-type.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { validateAndParseAttributeValue } from "./validate-attribute-type";
|
||||
|
||||
describe("validateAndParseAttributeValue", () => {
|
||||
describe("string type", () => {
|
||||
test("accepts any string value", () => {
|
||||
const result = validateAndParseAttributeValue("hello", "string", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.value).toBe("hello");
|
||||
expect(result.parsedValue.valueNumber).toBeNull();
|
||||
expect(result.parsedValue.valueDate).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("converts numbers to string", () => {
|
||||
const result = validateAndParseAttributeValue(42, "string", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.value).toBe("42");
|
||||
expect(result.parsedValue.valueNumber).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("converts Date to ISO string", () => {
|
||||
const date = new Date("2024-01-15T10:30:00.000Z");
|
||||
const result = validateAndParseAttributeValue(date, "string", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.value).toBe("2024-01-15T10:30:00.000Z");
|
||||
expect(result.parsedValue.valueDate).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("number type", () => {
|
||||
test("accepts number values", () => {
|
||||
const result = validateAndParseAttributeValue(42, "number", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.value).toBe("42");
|
||||
expect(result.parsedValue.valueNumber).toBe(42);
|
||||
expect(result.parsedValue.valueDate).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts numeric string values", () => {
|
||||
const result = validateAndParseAttributeValue("3.14", "number", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.valueNumber).toBe(3.14);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts numeric strings with whitespace", () => {
|
||||
const result = validateAndParseAttributeValue(" 123 ", "number", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.valueNumber).toBe(123);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects non-numeric strings", () => {
|
||||
const result = validateAndParseAttributeValue("hello", "number", "testKey");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("testKey");
|
||||
expect(result.error).toContain("expects a number");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects Date values", () => {
|
||||
const date = new Date();
|
||||
const result = validateAndParseAttributeValue(date, "number", "testKey");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("expects a number");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("date type", () => {
|
||||
test("accepts Date objects", () => {
|
||||
const date = new Date("2024-01-15T10:30:00.000Z");
|
||||
const result = validateAndParseAttributeValue(date, "date", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.value).toBe("2024-01-15T10:30:00.000Z");
|
||||
expect(result.parsedValue.valueNumber).toBeNull();
|
||||
expect(result.parsedValue.valueDate).toEqual(date);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts ISO date strings", () => {
|
||||
const result = validateAndParseAttributeValue("2024-01-15T10:30:00.000Z", "date", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.valueDate).toEqual(new Date("2024-01-15T10:30:00.000Z"));
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts date-only strings", () => {
|
||||
const result = validateAndParseAttributeValue("2024-01-15", "date", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.valueDate).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid date strings", () => {
|
||||
const result = validateAndParseAttributeValue("not-a-date", "date", "purchaseDate");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("purchaseDate");
|
||||
expect(result.error).toContain("expects a date");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects number values", () => {
|
||||
const result = validateAndParseAttributeValue(42, "date", "testKey");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("expects a date");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid Date objects", () => {
|
||||
const invalidDate = new Date("invalid");
|
||||
const result = validateAndParseAttributeValue(invalidDate, "date", "testKey");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("Invalid Date");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("error messages", () => {
|
||||
test("includes attribute key in error message", () => {
|
||||
const result = validateAndParseAttributeValue("hello", "number", "purchaseAmount");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("purchaseAmount");
|
||||
}
|
||||
});
|
||||
|
||||
test("includes received value type in error message", () => {
|
||||
const result = validateAndParseAttributeValue("hello", "number", "testKey");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("hello");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
146
apps/web/modules/ee/contacts/lib/validate-attribute-type.ts
Normal file
146
apps/web/modules/ee/contacts/lib/validate-attribute-type.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
/**
|
||||
* Result of attribute value validation
|
||||
*/
|
||||
export type TAttributeValidationResult =
|
||||
| {
|
||||
valid: true;
|
||||
parsedValue: {
|
||||
value: string;
|
||||
valueNumber: number | null;
|
||||
valueDate: Date | null;
|
||||
};
|
||||
}
|
||||
| {
|
||||
valid: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string value is a valid ISO 8601 date
|
||||
*/
|
||||
const isValidISODate = (value: string): boolean => {
|
||||
if (!/^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return !Number.isNaN(date.getTime());
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string value is a valid number
|
||||
*/
|
||||
const isValidNumber = (value: string): boolean => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed !== "" && !Number.isNaN(Number(trimmed));
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that a value matches the expected data type and parses it for storage.
|
||||
* Used for subsequent writes to an existing attribute key.
|
||||
*
|
||||
* @param value - The value to validate (string, number, or Date)
|
||||
* @param expectedDataType - The expected data type of the attribute key
|
||||
* @param attributeKey - The attribute key name (for error messages)
|
||||
* @returns Validation result with parsed values for storage or error message
|
||||
*/
|
||||
export const validateAndParseAttributeValue = (
|
||||
value: string | number | Date,
|
||||
expectedDataType: TContactAttributeDataType,
|
||||
attributeKey: string
|
||||
): TAttributeValidationResult => {
|
||||
switch (expectedDataType) {
|
||||
case "string": {
|
||||
// String type accepts any value - convert to string
|
||||
let stringValue: string;
|
||||
|
||||
if (value instanceof Date) {
|
||||
stringValue = value.toISOString();
|
||||
} else if (typeof value === "number") {
|
||||
stringValue = String(value);
|
||||
} else {
|
||||
stringValue = value;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
parsedValue: {
|
||||
value: stringValue,
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// Number type expects a numeric value
|
||||
let numericValue: number;
|
||||
|
||||
if (typeof value === "number") {
|
||||
numericValue = value;
|
||||
} else if (typeof value === "string" && isValidNumber(value)) {
|
||||
numericValue = Number(value.trim());
|
||||
} else {
|
||||
const receivedType = value instanceof Date ? "Date" : typeof value;
|
||||
return {
|
||||
valid: false,
|
||||
error: `Attribute '${attributeKey}' expects a number. Received: ${receivedType} value '${String(value)}'`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
parsedValue: {
|
||||
value: String(numericValue), // Keep string column for backwards compatibility
|
||||
valueNumber: numericValue,
|
||||
valueDate: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "date": {
|
||||
// Date type expects a Date object or valid ISO date string
|
||||
let dateValue: Date;
|
||||
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Attribute '${attributeKey}' expects a valid date. Received: Invalid Date`,
|
||||
};
|
||||
}
|
||||
dateValue = value;
|
||||
} else if (typeof value === "string" && isValidISODate(value)) {
|
||||
dateValue = new Date(value);
|
||||
} else {
|
||||
const receivedType = typeof value;
|
||||
return {
|
||||
valid: false,
|
||||
error: `Attribute '${attributeKey}' expects a date (ISO 8601 string or Date object). Received: ${receivedType} value '${String(value)}'`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
parsedValue: {
|
||||
value: dateValue.toISOString(), // Keep string column for backwards compatibility
|
||||
valueNumber: null,
|
||||
valueDate: dateValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
// Unknown type - treat as string
|
||||
return {
|
||||
valid: true,
|
||||
parsedValue: {
|
||||
value: String(value),
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { FingerprintIcon, MonitorSmartphoneIcon, TagIcon, Users2Icon } from "lucide-react";
|
||||
import {
|
||||
Calendar1Icon,
|
||||
FingerprintIcon,
|
||||
HashIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
TagIcon,
|
||||
Users2Icon,
|
||||
} from "lucide-react";
|
||||
import React, { type JSX, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type {
|
||||
TBaseFilter,
|
||||
TSegment,
|
||||
@@ -33,6 +40,7 @@ export const handleAddFilter = ({
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey,
|
||||
attributeDataType,
|
||||
deviceType,
|
||||
segmentId,
|
||||
}: {
|
||||
@@ -40,12 +48,22 @@ export const handleAddFilter = ({
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
segmentId?: string;
|
||||
deviceType?: string;
|
||||
}): void => {
|
||||
if (type === "attribute") {
|
||||
if (!contactAttributeKey) return;
|
||||
|
||||
// Set default operator and value based on attribute data type
|
||||
let defaultOperator: "equals" | "isOlderThan" = "equals";
|
||||
let defaultValue: string | { amount: number; unit: "days" } = "";
|
||||
|
||||
if (attributeDataType === "date") {
|
||||
defaultOperator = "isOlderThan";
|
||||
defaultValue = { amount: 1, unit: "days" };
|
||||
}
|
||||
|
||||
const newFilterResource: TSegmentAttributeFilter = {
|
||||
id: createId(),
|
||||
root: {
|
||||
@@ -53,9 +71,9 @@ export const handleAddFilter = ({
|
||||
contactAttributeKey,
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
operator: defaultOperator,
|
||||
},
|
||||
value: "",
|
||||
value: defaultValue,
|
||||
};
|
||||
const newFilter: TBaseFilter = {
|
||||
id: createId(),
|
||||
@@ -235,33 +253,46 @@ export function AddFilterModal({
|
||||
|
||||
{allFiltersFiltered.map((filters, index) => (
|
||||
<div key={index}>
|
||||
{filters.attributes.map((attributeKey) => (
|
||||
<FilterButton
|
||||
key={attributeKey.id}
|
||||
data-testid={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={<TagIcon className="h-4 w-4" />}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
{filters.attributes.map((attributeKey) => {
|
||||
const icon =
|
||||
attributeKey.dataType === "date" ? (
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
) : attributeKey.dataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4" />
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterButton
|
||||
key={attributeKey.id}
|
||||
data-testid={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={icon}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
attributeDataType: attributeKey.dataType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
attributeDataType: attributeKey.dataType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{filters.contactAttributeFiltered.map((personAttribute) => (
|
||||
<FilterButton
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FingerprintIcon, TagIcon } from "lucide-react";
|
||||
import { Calendar1Icon, FingerprintIcon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type { TBaseFilter } from "@formbricks/types/segment";
|
||||
import FilterButton from "./filter-button";
|
||||
|
||||
@@ -13,6 +13,7 @@ interface AttributeTabContentProps {
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ function FilterButtonWithHandler({
|
||||
setOpen,
|
||||
handleAddFilter,
|
||||
contactAttributeKey,
|
||||
attributeDataType,
|
||||
}: {
|
||||
dataTestId: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -38,8 +40,10 @@ function FilterButtonWithHandler({
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
}) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
}) {
|
||||
return (
|
||||
<FilterButton
|
||||
@@ -51,7 +55,7 @@ function FilterButtonWithHandler({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
...(type === "attribute" ? { contactAttributeKey } : {}),
|
||||
...(type === "attribute" ? { contactAttributeKey, attributeDataType } : {}),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
@@ -61,7 +65,7 @@ function FilterButtonWithHandler({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
...(type === "attribute" ? { contactAttributeKey } : {}),
|
||||
...(type === "attribute" ? { contactAttributeKey, attributeDataType } : {}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -104,19 +108,31 @@ function AttributeTabContent({
|
||||
<p>{t("environments.segments.no_attributes_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{contactAttributeKeys.map((attributeKey) => (
|
||||
<FilterButtonWithHandler
|
||||
key={attributeKey.id}
|
||||
dataTestId={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={<TagIcon className="h-4 w-4" />}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
type="attribute"
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
contactAttributeKey={attributeKey.key}
|
||||
/>
|
||||
))}
|
||||
{contactAttributeKeys.map((attributeKey) => {
|
||||
const icon =
|
||||
attributeKey.dataType === "date" ? (
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
) : attributeKey.dataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4" />
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterButtonWithHandler
|
||||
key={attributeKey.id}
|
||||
dataTestId={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={icon}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
type="attribute"
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
contactAttributeKey={attributeKey.key}
|
||||
attributeDataType={attributeKey.dataType}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TDateOperator, TSegmentFilterValue, TTimeUnit } from "@formbricks/types/segment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface DateFilterValueProps {
|
||||
operator: TDateOperator;
|
||||
value: TSegmentFilterValue;
|
||||
onChange: (value: TSegmentFilterValue) => void;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export function DateFilterValue({ operator, value, onChange, viewOnly }: DateFilterValueProps) {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Relative time operators: isOlderThan, isNewerThan
|
||||
if (operator === "isOlderThan" || operator === "isNewerThan") {
|
||||
const relativeValue =
|
||||
typeof value === "object" && "amount" in value && "unit" in value
|
||||
? value
|
||||
: { amount: 1, unit: "days" as TTimeUnit };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className={cn("h-9 w-20 bg-white", error && "border border-red-500 focus:border-red-500")}
|
||||
disabled={viewOnly}
|
||||
value={relativeValue.amount}
|
||||
onChange={(e) => {
|
||||
const amount = Number.parseInt(e.target.value, 10);
|
||||
if (Number.isNaN(amount) || amount < 1) {
|
||||
setError(t("environments.segments.value_must_be_positive"));
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onChange({ amount, unit: relativeValue.unit });
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
disabled={viewOnly}
|
||||
value={relativeValue.unit}
|
||||
onValueChange={(unit: TTimeUnit) => {
|
||||
onChange({ amount: relativeValue.amount, unit });
|
||||
}}>
|
||||
<SelectTrigger className="flex w-auto items-center justify-center bg-white" hideArrow>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="days">{t("common.days")}</SelectItem>
|
||||
<SelectItem value="weeks">{t("common.weeks")}</SelectItem>
|
||||
<SelectItem value="months">{t("common.months")}</SelectItem>
|
||||
<SelectItem value="years">{t("common.years")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Between operator: needs two date inputs
|
||||
if (operator === "isBetween") {
|
||||
const betweenValue = Array.isArray(value) && value.length === 2 ? value : ["", ""];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-auto bg-white"
|
||||
disabled={viewOnly}
|
||||
value={betweenValue[0] ? betweenValue[0].split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
|
||||
onChange([dateValue, betweenValue[1]]);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-slate-600">{t("common.and")}</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-auto bg-white"
|
||||
disabled={viewOnly}
|
||||
value={betweenValue[1] ? betweenValue[1].split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
|
||||
onChange([betweenValue[0], dateValue]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Absolute date operators: isBefore, isAfter, isSameDay
|
||||
// Use a single date picker
|
||||
const dateValue = typeof value === "string" ? value : "";
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-auto bg-white"
|
||||
disabled={viewOnly}
|
||||
value={dateValue ? dateValue.split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
|
||||
onChange(dateValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { UsersIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -20,7 +20,7 @@ interface EditSegmentModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
currentSegment: TSegment;
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
|
||||
@@ -8,16 +8,14 @@ import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface SegmentActivityTabProps {
|
||||
environmentId: string;
|
||||
currentSegment: TSegment & {
|
||||
activeSurveys: string[];
|
||||
inactiveSurveys: string[];
|
||||
};
|
||||
currentSegment: TSegment;
|
||||
}
|
||||
|
||||
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const activeSurveys = currentSegment?.activeSurveys;
|
||||
const inactiveSurveys = currentSegment?.inactiveSurveys;
|
||||
|
||||
const activeSurveys: string[] = [];
|
||||
const inactiveSurveys: string[] = [];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 pb-2">
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
Calendar1Icon,
|
||||
FingerprintIcon,
|
||||
HashIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
MoreVertical,
|
||||
TagIcon,
|
||||
@@ -14,26 +16,27 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type {
|
||||
TArithmeticOperator,
|
||||
TAttributeOperator,
|
||||
TBaseFilter,
|
||||
TDeviceOperator,
|
||||
TSegment,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentConnector,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentFilterValue,
|
||||
TSegmentOperator,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import {
|
||||
ARITHMETIC_OPERATORS,
|
||||
ATTRIBUTE_OPERATORS,
|
||||
DATE_OPERATORS,
|
||||
DEVICE_OPERATORS,
|
||||
NUMBER_TYPE_OPERATORS,
|
||||
PERSON_OPERATORS,
|
||||
STRING_TYPE_OPERATORS,
|
||||
type TArithmeticOperator,
|
||||
type TAttributeOperator,
|
||||
type TBaseFilter,
|
||||
type TDeviceOperator,
|
||||
type TSegment,
|
||||
type TSegmentAttributeFilter,
|
||||
type TSegmentConnector,
|
||||
type TSegmentDeviceFilter,
|
||||
type TSegmentFilter,
|
||||
type TSegmentFilterValue,
|
||||
type TSegmentOperator,
|
||||
type TSegmentPersonFilter,
|
||||
type TSegmentSegmentFilter,
|
||||
isDateOperator,
|
||||
} from "@formbricks/types/segment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
@@ -64,6 +67,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { AddFilterModal } from "./add-filter-modal";
|
||||
import { DateFilterValue } from "./date-filter-value";
|
||||
|
||||
interface TSegmentFilterProps {
|
||||
connector: TSegmentConnector;
|
||||
@@ -204,7 +208,6 @@ type TAttributeSegmentFilterProps = TSegmentFilterProps & {
|
||||
resource: TSegmentAttributeFilter;
|
||||
updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void;
|
||||
};
|
||||
|
||||
function AttributeSegmentFilter({
|
||||
connector,
|
||||
resource,
|
||||
@@ -239,17 +242,32 @@ function AttributeSegmentFilter({
|
||||
}
|
||||
}, [resource.qualifier, resource.value, t]);
|
||||
|
||||
const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
|
||||
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
|
||||
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
|
||||
// Default to 'string' if dataType is undefined (for backwards compatibility)
|
||||
const attributeDataType = attributeKey?.dataType ?? "string";
|
||||
const isDateAttribute = attributeDataType === "date";
|
||||
|
||||
// Show operators based on attribute data type
|
||||
const getOperatorsForDataType = () => {
|
||||
switch (attributeDataType) {
|
||||
case "date":
|
||||
return DATE_OPERATORS;
|
||||
case "number":
|
||||
return NUMBER_TYPE_OPERATORS;
|
||||
case "string":
|
||||
default:
|
||||
return STRING_TYPE_OPERATORS;
|
||||
}
|
||||
};
|
||||
const availableOperators = getOperatorsForDataType();
|
||||
const operatorArr = availableOperators.map((operator) => {
|
||||
return {
|
||||
id: operator,
|
||||
name: convertOperatorToText(operator),
|
||||
};
|
||||
});
|
||||
|
||||
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
|
||||
|
||||
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
|
||||
|
||||
const updateOperatorInLocalSurvey = (filterId: string, newOperator: TAttributeOperator) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
@@ -263,6 +281,15 @@ function AttributeSegmentFilter({
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
updateContactAttributeKeyInFilter(updatedSegment.filters, filterId, newAttributeClassName);
|
||||
|
||||
// When changing attribute, reset operator to appropriate default for the new attribute type
|
||||
const newAttributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === newAttributeClassName);
|
||||
const newAttributeDataType = newAttributeKey?.dataType ?? "string";
|
||||
const defaultOperator = newAttributeDataType === "date" ? "isOlderThan" : "equals";
|
||||
const defaultValue = newAttributeDataType === "date" ? { amount: 1, unit: "days" as const } : "";
|
||||
|
||||
updateOperatorInFilter(updatedSegment.filters, filterId, defaultOperator as any);
|
||||
updateFilterValue(updatedSegment.filters, filterId, defaultValue as any);
|
||||
}
|
||||
|
||||
setSegment(updatedSegment);
|
||||
@@ -315,11 +342,17 @@ function AttributeSegmentFilter({
|
||||
}}
|
||||
value={attrKeyValue}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="h-4 w-4 text-sm" />
|
||||
{isDateAttribute ? (
|
||||
<Calendar1Icon className="h-4 w-4 text-sm" />
|
||||
) : attributeDataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4 text-sm" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4 text-sm" />
|
||||
)}
|
||||
<p>{attrKeyValue}</p>
|
||||
</div>
|
||||
</SelectValue>
|
||||
@@ -328,7 +361,16 @@ function AttributeSegmentFilter({
|
||||
<SelectContent>
|
||||
{contactAttributeKeys.map((attrClass) => (
|
||||
<SelectItem key={attrClass.id} value={attrClass.key}>
|
||||
{attrClass.name ?? attrClass.key}
|
||||
<div className="flex items-center gap-2">
|
||||
{attrClass.dataType === "date" ? (
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
) : attrClass.dataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span>{attrClass.name ?? attrClass.key}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -356,23 +398,39 @@ function AttributeSegmentFilter({
|
||||
</Select>
|
||||
|
||||
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
className={cn("h-9 w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
|
||||
disabled={viewOnly}
|
||||
onChange={(e) => {
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
value={resource.value}
|
||||
/>
|
||||
<>
|
||||
{isDateAttribute && isDateOperator(resource.qualifier.operator) ? (
|
||||
<DateFilterValue
|
||||
operator={resource.qualifier.operator}
|
||||
value={resource.value}
|
||||
onChange={(newValue) => {
|
||||
updateValueInLocalSurvey(resource.id, newValue);
|
||||
}}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
className={cn(
|
||||
"h-9 w-auto bg-white",
|
||||
valueError && "border border-red-500 focus:border-red-500"
|
||||
)}
|
||||
disabled={viewOnly}
|
||||
onChange={(e) => {
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
value={resource.value as string | number}
|
||||
/>
|
||||
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
|
||||
{valueError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
|
||||
{valueError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SegmentFilterItemContextMenu
|
||||
@@ -497,7 +555,7 @@ function PersonSegmentFilter({
|
||||
}}
|
||||
value={personIdentifier}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-1 lowercase">
|
||||
@@ -544,7 +602,7 @@ function PersonSegmentFilter({
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
value={resource.value}
|
||||
value={resource.value as string | number}
|
||||
/>
|
||||
|
||||
{valueError ? (
|
||||
@@ -648,7 +706,7 @@ function SegmentSegmentFilter({
|
||||
}}
|
||||
value={currentSegment?.id}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
hideArrow>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users2Icon className="h-4 w-4 text-sm" />
|
||||
@@ -660,7 +718,9 @@ function SegmentSegmentFilter({
|
||||
{segments
|
||||
.filter((segment) => !segment.isPrivate)
|
||||
.map((segment) => (
|
||||
<SelectItem value={segment.id}>{segment.title}</SelectItem>
|
||||
<SelectItem key={segment.id} value={segment.id}>
|
||||
{segment.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -803,7 +863,7 @@ export function SegmentFilter({
|
||||
}: TSegmentFilterProps) {
|
||||
const { t } = useTranslation();
|
||||
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
|
||||
const updateFilterValueInSegment = (filterId: string, newValue: string | number) => {
|
||||
const updateFilterValueInSegment = (filterId: string, newValue: TSegmentFilterValue) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
updateFilterValue(updatedSegment.filters, filterId, newValue);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type { TBaseFilter, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import type { TBaseFilter, TSegment } from "@formbricks/types/segment";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
@@ -21,7 +21,7 @@ import { SegmentEditor } from "./segment-editor";
|
||||
interface TSegmentSettingsTabProps {
|
||||
environmentId: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
initialSegment: TSegmentWithSurveyNames;
|
||||
initialSegment: TSegment;
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isReadOnly: boolean;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
|
||||
export const generateSegmentTableColumns = (): ColumnDef<TSegment>[] => {
|
||||
const titleColumn: ColumnDef<TSegment> = {
|
||||
id: "title",
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-slate-100">
|
||||
<UsersIcon className="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="font-medium text-slate-900">{row.original.title}</div>
|
||||
{row.original.description && (
|
||||
<div className="text-xs text-slate-500">{row.original.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const updatedAtColumn: ColumnDef<TSegment> = {
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">
|
||||
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const createdAtColumn: ColumnDef<TSegment> = {
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [titleColumn, updatedAtColumn, createdAtColumn];
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { EditSegmentModal } from "./edit-segment-modal";
|
||||
import { generateSegmentTableColumns } from "./segment-table-columns";
|
||||
|
||||
interface SegmentTableUpdatedProps {
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function SegmentTableUpdated({
|
||||
segments,
|
||||
contactAttributeKeys,
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
}: SegmentTableUpdatedProps) {
|
||||
const { t } = useTranslation();
|
||||
const [editingSegment, setEditingSegment] = useState<TSegment | null>(null);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return generateSegmentTableColumns();
|
||||
}, []);
|
||||
|
||||
const table = useReactTable({
|
||||
data: segments,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="rounded-t-lg">
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const isFirstHeader = index === 0;
|
||||
const isLastHeader = index === headerGroup.headers.length - 1;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`h-10 border-b border-slate-200 bg-white px-4 font-semibold ${
|
||||
isFirstHeader ? "rounded-tl-lg" : isLastHeader ? "rounded-tr-lg" : ""
|
||||
}`}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: typeof header.column.columnDef.header === "function"
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row, rowIndex) => {
|
||||
const isLastRow = rowIndex === table.getRowModel().rows.length - 1;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => setEditingSegment(row.original)}
|
||||
className={`cursor-pointer hover:bg-slate-50 ${isLastRow ? "rounded-b-lg" : ""}`}>
|
||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||
const isFirstCell = cellIndex === 0;
|
||||
const isLastCell = cellIndex === row.getVisibleCells().length - 1;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
isLastRow
|
||||
? isFirstCell
|
||||
? "rounded-bl-lg"
|
||||
: isLastCell
|
||||
? "rounded-br-lg"
|
||||
: ""
|
||||
: ""
|
||||
}>
|
||||
{typeof cell.column.columnDef.cell === "function"
|
||||
? cell.column.columnDef.cell(cell.getContext())
|
||||
: cell.column.columnDef.cell}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<p className="text-slate-400">{t("environments.segments.create_your_first_segment")}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Edit Segment Modal */}
|
||||
{editingSegment && (
|
||||
<EditSegmentModal
|
||||
environmentId={editingSegment.environmentId}
|
||||
open={!!editingSegment}
|
||||
setOpen={(open) => !open && setEditingSegment(null)}
|
||||
currentSegment={editingSegment}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { SegmentTableDataRowContainer } from "./segment-table-data-row-container";
|
||||
|
||||
type TSegmentTableProps = {
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
isReadOnly: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTable = async ({
|
||||
segments,
|
||||
contactAttributeKeys,
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
}: TSegmentTableProps) => {
|
||||
const t = await getTranslate();
|
||||
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-4 pl-6">{t("common.title")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.surveys")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.updated")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created")}</div>
|
||||
</div>
|
||||
{segments.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{t("environments.segments.create_your_first_segment")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{segments.map((segment) => (
|
||||
<SegmentTableDataRowContainer
|
||||
key={segment.id}
|
||||
currentSegment={segment}
|
||||
segments={segments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
114
apps/web/modules/ee/contacts/segments/lib/date-utils.test.ts
Normal file
114
apps/web/modules/ee/contacts/segments/lib/date-utils.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { addTimeUnit, endOfDay, isSameDay, startOfDay, subtractTimeUnit } from "./date-utils";
|
||||
|
||||
describe("date-utils", () => {
|
||||
describe("subtractTimeUnit", () => {
|
||||
test("subtracts days correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = subtractTimeUnit(date, 5, "days");
|
||||
expect(result.getDate()).toBe(10);
|
||||
expect(result.getMonth()).toBe(0); // January
|
||||
});
|
||||
|
||||
test("subtracts weeks correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = subtractTimeUnit(date, 2, "weeks");
|
||||
expect(result.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
test("subtracts months correctly", () => {
|
||||
const date = new Date("2024-03-15T12:00:00Z");
|
||||
const result = subtractTimeUnit(date, 2, "months");
|
||||
expect(result.getMonth()).toBe(0); // January
|
||||
});
|
||||
|
||||
test("subtracts years correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = subtractTimeUnit(date, 1, "years");
|
||||
expect(result.getFullYear()).toBe(2023);
|
||||
});
|
||||
|
||||
test("does not modify original date", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const original = date.getTime();
|
||||
subtractTimeUnit(date, 5, "days");
|
||||
expect(date.getTime()).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addTimeUnit", () => {
|
||||
test("adds days correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = addTimeUnit(date, 5, "days");
|
||||
expect(result.getDate()).toBe(20);
|
||||
});
|
||||
|
||||
test("adds weeks correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = addTimeUnit(date, 2, "weeks");
|
||||
expect(result.getDate()).toBe(29);
|
||||
});
|
||||
|
||||
test("adds months correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = addTimeUnit(date, 2, "months");
|
||||
expect(result.getMonth()).toBe(2); // March
|
||||
});
|
||||
|
||||
test("adds years correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = addTimeUnit(date, 1, "years");
|
||||
expect(result.getFullYear()).toBe(2025);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startOfDay", () => {
|
||||
test("sets time to 00:00:00.000", () => {
|
||||
const date = new Date("2024-01-15T14:30:45.123Z");
|
||||
const result = startOfDay(date);
|
||||
expect(result.getHours()).toBe(0);
|
||||
expect(result.getMinutes()).toBe(0);
|
||||
expect(result.getSeconds()).toBe(0);
|
||||
expect(result.getMilliseconds()).toBe(0);
|
||||
expect(result.getDate()).toBe(date.getDate());
|
||||
});
|
||||
});
|
||||
|
||||
describe("endOfDay", () => {
|
||||
test("sets time to 23:59:59.999", () => {
|
||||
const date = new Date("2024-01-15T14:30:45.123Z");
|
||||
const result = endOfDay(date);
|
||||
expect(result.getHours()).toBe(23);
|
||||
expect(result.getMinutes()).toBe(59);
|
||||
expect(result.getSeconds()).toBe(59);
|
||||
expect(result.getMilliseconds()).toBe(999);
|
||||
expect(result.getDate()).toBe(date.getDate());
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameDay", () => {
|
||||
test("returns true for dates on the same day", () => {
|
||||
const date1 = new Date("2024-01-15T10:00:00Z");
|
||||
const date2 = new Date("2024-01-15T22:00:00Z");
|
||||
expect(isSameDay(date1, date2)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for dates on different days", () => {
|
||||
const date1 = new Date("2024-01-15T23:59:59Z");
|
||||
const date2 = new Date("2024-01-16T00:00:01Z");
|
||||
expect(isSameDay(date1, date2)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for dates in different months", () => {
|
||||
const date1 = new Date("2024-01-31T12:00:00Z");
|
||||
const date2 = new Date("2024-02-01T12:00:00Z");
|
||||
expect(isSameDay(date1, date2)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for dates in different years", () => {
|
||||
const date1 = new Date("2023-12-31T12:00:00Z");
|
||||
const date2 = new Date("2024-01-01T12:00:00Z");
|
||||
expect(isSameDay(date1, date2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
apps/web/modules/ee/contacts/segments/lib/date-utils.ts
Normal file
93
apps/web/modules/ee/contacts/segments/lib/date-utils.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { TTimeUnit } from "@formbricks/types/segment";
|
||||
|
||||
/**
|
||||
* Subtracts a time unit from a date
|
||||
* @param date - The date to subtract from
|
||||
* @param amount - The amount of time units to subtract
|
||||
* @param unit - The time unit (days, weeks, months, years)
|
||||
* @returns A new Date object with the time subtracted
|
||||
*/
|
||||
export const subtractTimeUnit = (date: Date, amount: number, unit: TTimeUnit): Date => {
|
||||
const result = new Date(date);
|
||||
|
||||
switch (unit) {
|
||||
case "days":
|
||||
result.setDate(result.getDate() - amount);
|
||||
break;
|
||||
case "weeks":
|
||||
result.setDate(result.getDate() - amount * 7);
|
||||
break;
|
||||
case "months":
|
||||
result.setMonth(result.getMonth() - amount);
|
||||
break;
|
||||
case "years":
|
||||
result.setFullYear(result.getFullYear() - amount);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a time unit to a date
|
||||
* @param date - The date to add to
|
||||
* @param amount - The amount of time units to add
|
||||
* @param unit - The time unit (days, weeks, months, years)
|
||||
* @returns A new Date object with the time added
|
||||
*/
|
||||
export const addTimeUnit = (date: Date, amount: number, unit: TTimeUnit): Date => {
|
||||
const result = new Date(date);
|
||||
|
||||
switch (unit) {
|
||||
case "days":
|
||||
result.setDate(result.getDate() + amount);
|
||||
break;
|
||||
case "weeks":
|
||||
result.setDate(result.getDate() + amount * 7);
|
||||
break;
|
||||
case "months":
|
||||
result.setMonth(result.getMonth() + amount);
|
||||
break;
|
||||
case "years":
|
||||
result.setFullYear(result.getFullYear() + amount);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the start of a day (00:00:00.000)
|
||||
* @param date - The date to get the start of
|
||||
* @returns A new Date object at the start of the day
|
||||
*/
|
||||
export const startOfDay = (date: Date): Date => {
|
||||
const result = new Date(date);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the end of a day (23:59:59.999)
|
||||
* @param date - The date to get the end of
|
||||
* @returns A new Date object at the end of the day
|
||||
*/
|
||||
export const endOfDay = (date: Date): Date => {
|
||||
const result = new Date(date);
|
||||
result.setHours(23, 59, 59, 999);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if two dates are on the same day (ignoring time)
|
||||
* @param date1 - The first date
|
||||
* @param date2 - The second date
|
||||
* @returns True if the dates are on the same day
|
||||
*/
|
||||
export const isSameDay = (date1: Date, date2: Date): boolean => {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,9 @@ import { cache as reactCache } from "react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import {
|
||||
DATE_OPERATORS,
|
||||
TBaseFilters,
|
||||
TDateOperator,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { endOfDay, startOfDay, subtractTimeUnit } from "../date-utils";
|
||||
import { getSegment } from "../segments";
|
||||
|
||||
// Type for the result of the segment filter to prisma query generation
|
||||
@@ -18,6 +21,108 @@ export type SegmentFilterQueryResult = {
|
||||
whereClause: Prisma.ContactWhereInput;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause for date attribute filters
|
||||
* Uses the native valueDate column for performant DateTime comparisons
|
||||
*/
|
||||
const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { contactAttributeKey } = root;
|
||||
const { operator } = qualifier as { operator: TDateOperator };
|
||||
const now = new Date();
|
||||
|
||||
let dateCondition: Prisma.DateTimeNullableFilter = {};
|
||||
|
||||
switch (operator) {
|
||||
case "isOlderThan": {
|
||||
// value should be { amount, unit }
|
||||
if (typeof value === "object" && "amount" in value && "unit" in value) {
|
||||
const threshold = subtractTimeUnit(now, value.amount, value.unit);
|
||||
dateCondition = { lt: threshold };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "isNewerThan": {
|
||||
// value should be { amount, unit }
|
||||
if (typeof value === "object" && "amount" in value && "unit" in value) {
|
||||
const threshold = subtractTimeUnit(now, value.amount, value.unit);
|
||||
dateCondition = { gte: threshold };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "isBefore":
|
||||
if (typeof value === "string") {
|
||||
dateCondition = { lt: new Date(value) };
|
||||
}
|
||||
break;
|
||||
case "isAfter":
|
||||
if (typeof value === "string") {
|
||||
dateCondition = { gt: new Date(value) };
|
||||
}
|
||||
break;
|
||||
case "isBetween":
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
dateCondition = { gte: new Date(value[0]), lte: new Date(value[1]) };
|
||||
}
|
||||
break;
|
||||
case "isSameDay": {
|
||||
if (typeof value === "string") {
|
||||
const dayStart = startOfDay(new Date(value));
|
||||
const dayEnd = endOfDay(new Date(value));
|
||||
dateCondition = { gte: dayStart, lte: dayEnd };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: contactAttributeKey },
|
||||
valueDate: dateCondition,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause for number attribute filters
|
||||
* Uses the native valueNumber column for performant numeric comparisons
|
||||
*/
|
||||
const buildNumberAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { contactAttributeKey } = root;
|
||||
const { operator } = qualifier;
|
||||
|
||||
const numericValue = typeof value === "number" ? value : Number(value);
|
||||
|
||||
let numberCondition: Prisma.FloatNullableFilter = {};
|
||||
|
||||
switch (operator) {
|
||||
case "greaterThan":
|
||||
numberCondition = { gt: numericValue };
|
||||
break;
|
||||
case "greaterEqual":
|
||||
numberCondition = { gte: numericValue };
|
||||
break;
|
||||
case "lessThan":
|
||||
numberCondition = { lt: numericValue };
|
||||
break;
|
||||
case "lessEqual":
|
||||
numberCondition = { lte: numericValue };
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: contactAttributeKey },
|
||||
valueNumber: numberCondition,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a segment attribute filter
|
||||
*/
|
||||
@@ -60,6 +165,11 @@ const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prism
|
||||
},
|
||||
} satisfies Prisma.ContactWhereInput;
|
||||
|
||||
// Handle date operators
|
||||
if (DATE_OPERATORS.includes(operator as TDateOperator)) {
|
||||
return buildDateAttributeFilterWhereClause(filter);
|
||||
}
|
||||
|
||||
// Apply the appropriate operator to the attribute value
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
@@ -81,17 +191,10 @@ const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prism
|
||||
valueQuery.attributes.some.value = { endsWith: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "greaterThan":
|
||||
valueQuery.attributes.some.value = { gt: String(value) };
|
||||
break;
|
||||
case "greaterEqual":
|
||||
valueQuery.attributes.some.value = { gte: String(value) };
|
||||
break;
|
||||
case "lessThan":
|
||||
valueQuery.attributes.some.value = { lt: String(value) };
|
||||
break;
|
||||
case "lessEqual":
|
||||
valueQuery.attributes.some.value = { lte: String(value) };
|
||||
break;
|
||||
return buildNumberAttributeFilterWhereClause(filter);
|
||||
default:
|
||||
valueQuery.attributes.some.value = String(value);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
ValidationError,
|
||||
} from "@formbricks/types/errors";
|
||||
import {
|
||||
DATE_OPERATORS,
|
||||
TAllOperators,
|
||||
TBaseFilters,
|
||||
TDateOperator,
|
||||
TEvaluateSegmentUserAttributeData,
|
||||
TEvaluateSegmentUserData,
|
||||
TSegment,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
TSegmentConnector,
|
||||
TSegmentCreateInput,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilterValue,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
TSegmentUpdateInput,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { isResourceFilter, searchForAttributeKeyInSegment } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { isSameDay, subtractTimeUnit } from "./date-utils";
|
||||
|
||||
export type PrismaSegment = Prisma.SegmentGetPayload<{
|
||||
include: {
|
||||
@@ -387,6 +391,12 @@ const evaluateAttributeFilter = (
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this is a date operator
|
||||
if (isDateOperator(qualifier.operator)) {
|
||||
return evaluateDateFilter(String(attributeValue), value, qualifier.operator);
|
||||
}
|
||||
|
||||
// Use standard comparison for non-date operators
|
||||
const attResult = compareValues(attributeValue, value, qualifier.operator);
|
||||
return attResult;
|
||||
};
|
||||
@@ -440,6 +450,86 @@ const evaluateDeviceFilter = (device: "phone" | "desktop", filter: TSegmentDevic
|
||||
return compareValues(device, value, qualifier.operator);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if an operator is a date-specific operator
|
||||
*/
|
||||
const isDateOperator = (operator: TAllOperators): operator is TDateOperator => {
|
||||
return DATE_OPERATORS.includes(operator as TDateOperator);
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a date filter against an attribute value
|
||||
*/
|
||||
const evaluateDateFilter = (
|
||||
attributeValue: string,
|
||||
filterValue: TSegmentFilterValue,
|
||||
operator: TDateOperator
|
||||
): boolean => {
|
||||
// Parse the attribute value as a date
|
||||
const attrDate = new Date(attributeValue);
|
||||
|
||||
// Validate the attribute value is a valid date
|
||||
if (isNaN(attrDate.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
switch (operator) {
|
||||
case "isOlderThan": {
|
||||
// filterValue should be { amount, unit }
|
||||
if (typeof filterValue === "object" && "amount" in filterValue && "unit" in filterValue) {
|
||||
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
|
||||
return attrDate < threshold;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isNewerThan": {
|
||||
// filterValue should be { amount, unit }
|
||||
if (typeof filterValue === "object" && "amount" in filterValue && "unit" in filterValue) {
|
||||
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
|
||||
return attrDate >= threshold;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isBefore": {
|
||||
// filterValue should be an ISO date string
|
||||
if (typeof filterValue === "string") {
|
||||
const compareDate = new Date(filterValue);
|
||||
return attrDate < compareDate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isAfter": {
|
||||
// filterValue should be an ISO date string
|
||||
if (typeof filterValue === "string") {
|
||||
const compareDate = new Date(filterValue);
|
||||
return attrDate > compareDate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isBetween": {
|
||||
// filterValue should be a tuple [startDate, endDate]
|
||||
if (Array.isArray(filterValue) && filterValue.length === 2) {
|
||||
const startDate = new Date(filterValue[0]);
|
||||
const endDate = new Date(filterValue[1]);
|
||||
return attrDate >= startDate && attrDate <= endDate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isSameDay": {
|
||||
// filterValue should be an ISO date string
|
||||
if (typeof filterValue === "string") {
|
||||
const compareDate = new Date(filterValue);
|
||||
return isSameDay(attrDate, compareDate);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const compareValues = (
|
||||
a: string | number | undefined,
|
||||
b: string | number,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TSegmentConnector,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentFilterValue,
|
||||
TSegmentOperator,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
@@ -50,6 +51,18 @@ export const convertOperatorToText = (operator: TAllOperators) => {
|
||||
return "User is in";
|
||||
case "userIsNotIn":
|
||||
return "User is not in";
|
||||
case "isOlderThan":
|
||||
return "is older than";
|
||||
case "isNewerThan":
|
||||
return "is newer than";
|
||||
case "isBefore":
|
||||
return "is before";
|
||||
case "isAfter":
|
||||
return "is after";
|
||||
case "isBetween":
|
||||
return "is between";
|
||||
case "isSameDay":
|
||||
return "is same day";
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
@@ -85,6 +98,18 @@ export const convertOperatorToTitle = (operator: TAllOperators) => {
|
||||
return "User is in";
|
||||
case "userIsNotIn":
|
||||
return "User is not in";
|
||||
case "isOlderThan":
|
||||
return "Is older than";
|
||||
case "isNewerThan":
|
||||
return "Is newer than";
|
||||
case "isBefore":
|
||||
return "Is before";
|
||||
case "isAfter":
|
||||
return "Is after";
|
||||
case "isBetween":
|
||||
return "Is between";
|
||||
case "isSameDay":
|
||||
return "Is same day";
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
@@ -398,7 +423,7 @@ export const updateSegmentIdInFilter = (group: TBaseFilters, filterId: string, n
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: string | number) => {
|
||||
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: TSegmentFilterValue) => {
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const { resource } = group[i];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { SegmentTable } from "@/modules/ee/contacts/segments/components/segment-table";
|
||||
import { SegmentTableUpdated } from "@/modules/ee/contacts/segments/components/segment-table-updated";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -46,7 +46,7 @@ export const SegmentsPage = async ({
|
||||
}
|
||||
upgradePromptTitle={t("environments.segments.unlock_segments_title")}
|
||||
upgradePromptDescription={t("environments.segments.unlock_segments_description")}>
|
||||
<SegmentTable
|
||||
<SegmentTableUpdated
|
||||
segments={filteredSegments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
export const ZContact = z.object({
|
||||
id: z.string().cuid2(),
|
||||
@@ -11,6 +12,7 @@ const ZContactTableAttributeData = z.object({
|
||||
key: z.string(),
|
||||
name: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
dataType: ZContactAttributeDataType,
|
||||
});
|
||||
|
||||
export const ZContactTableData = z.object({
|
||||
|
||||
@@ -30,7 +30,6 @@ const CONFIG = {
|
||||
env.ENVIRONMENT === "staging"
|
||||
? "https://staging.ee.formbricks.com/api/licenses/check"
|
||||
: "https://ee.formbricks.com/api/licenses/check",
|
||||
// ENDPOINT: "https://localhost:8080/api/licenses/check",
|
||||
TIMEOUT_MS: 5000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { subscribeToMailingList, subscribeUserToMailingList } from "./mailing-subscription";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
globalThis.fetch = vi.fn();
|
||||
|
||||
describe("subscribeToMailingList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("should successfully subscribe to security mailing list", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "test@example.com" }),
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ listId: "security" },
|
||||
"Successfully subscribed to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should successfully subscribe to product-updates mailing list", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "product-updates",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "test@example.com" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should return error when API returns non-ok response", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
);
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Failed to subscribe: 400" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ status: 400, error: "Bad Request" },
|
||||
"Failed to subscribe to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return error when fetch throws an error", async () => {
|
||||
vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Failed to subscribe to mailing list" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Error subscribing to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return timeout error when request times out", async () => {
|
||||
const abortError = new Error("Aborted");
|
||||
abortError.name = "AbortError";
|
||||
vi.mocked(globalThis.fetch).mockRejectedValueOnce(abortError);
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Request timed out" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ listId: "security" },
|
||||
"Mailing subscription request timed out"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeUserToMailingList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should subscribe to product-updates when isFormbricksCloud is true and subscribeToProductUpdates is true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: true,
|
||||
subscribeToSecurityUpdates: false,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("should not subscribe when isFormbricksCloud is true but subscribeToProductUpdates is false", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: false,
|
||||
subscribeToSecurityUpdates: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should subscribe to security when isFormbricksCloud is false and subscribeToSecurityUpdates is true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: false,
|
||||
subscribeToSecurityUpdates: true,
|
||||
subscribeToProductUpdates: false,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("should not subscribe when isFormbricksCloud is false but subscribeToSecurityUpdates is false", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: false,
|
||||
subscribeToSecurityUpdates: false,
|
||||
subscribeToProductUpdates: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not subscribe when both subscription flags are undefined", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should prioritize product-updates for cloud users even if security is also true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: true,
|
||||
subscribeToSecurityUpdates: true,
|
||||
});
|
||||
|
||||
// Should only call product-updates endpoint for cloud users
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export type TMailingListId = "security" | "product-updates";
|
||||
|
||||
const MAILING_LIST_ENDPOINTS: Record<TMailingListId, string> = {
|
||||
security: "https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
"product-updates": "https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
} as const;
|
||||
|
||||
const EE_SERVER_TIMEOUT_MS = 5000;
|
||||
|
||||
interface TSubscribeToMailingListParams {
|
||||
email: TUserEmail;
|
||||
listId: TMailingListId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a user to a mailing list via the EE server
|
||||
* @param email - The user's email address
|
||||
* @param listId - The mailing list ID ("security" or "product-updates")
|
||||
*/
|
||||
export const subscribeToMailingList = async ({
|
||||
email,
|
||||
listId,
|
||||
}: TSubscribeToMailingListParams): Promise<{ success: boolean; error?: string }> => {
|
||||
validateInputs([email, ZUserEmail.toLowerCase()]);
|
||||
|
||||
const endpoint = MAILING_LIST_ENDPOINTS[listId];
|
||||
if (!endpoint) {
|
||||
logger.error({ listId }, "Invalid mailing list ID");
|
||||
return { success: false, error: "Invalid mailing list ID" };
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), EE_SERVER_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error(
|
||||
{ status: response.status, error: errorText },
|
||||
`Failed to subscribe to ${listId} mailing list`
|
||||
);
|
||||
return { success: false, error: `Failed to subscribe: ${response.status}` };
|
||||
}
|
||||
|
||||
logger.info({ listId }, `Successfully subscribed to ${listId} mailing list`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
logger.error({ listId }, "Mailing subscription request timed out");
|
||||
return { success: false, error: "Request timed out" };
|
||||
}
|
||||
|
||||
logger.error(error, `Error subscribing to ${listId} mailing list`);
|
||||
return { success: false, error: "Failed to subscribe to mailing list" };
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeUserToMailingList = async ({
|
||||
email,
|
||||
isFormbricksCloud,
|
||||
subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates,
|
||||
}: {
|
||||
email: TUserEmail;
|
||||
isFormbricksCloud: boolean;
|
||||
subscribeToSecurityUpdates?: boolean;
|
||||
subscribeToProductUpdates?: boolean;
|
||||
}): Promise<void> => {
|
||||
if (isFormbricksCloud && subscribeToProductUpdates) {
|
||||
await subscribeToMailingList({ email, listId: "product-updates" });
|
||||
} else if (!isFormbricksCloud && subscribeToSecurityUpdates) {
|
||||
await subscribeToMailingList({ email, listId: "security" });
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user