mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-23 06:30:51 -06:00
Compare commits
38 Commits
chore/impr
...
chore/infr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
208eb7ce2d | ||
|
|
ec208960e8 | ||
|
|
b9505158b4 | ||
|
|
ad0c3421f0 | ||
|
|
916c00344b | ||
|
|
459cdee17e | ||
|
|
bb26a64dbb | ||
|
|
29a3fa532a | ||
|
|
738b8f9012 | ||
|
|
c95272288e | ||
|
|
919febd166 | ||
|
|
10ccc20b53 | ||
|
|
d9ca64da54 | ||
|
|
ce00ec97d1 | ||
|
|
2b9cd37c6c | ||
|
|
f8f14eb6f3 | ||
|
|
645fc863aa | ||
|
|
c53f030b24 | ||
|
|
45d74f9ba0 | ||
|
|
87870919ca | ||
|
|
ce2fdde474 | ||
|
|
6e2f30c6ed | ||
|
|
5c8040008a | ||
|
|
639e25d679 | ||
|
|
f7e5ef96d2 | ||
|
|
745f5487e9 | ||
|
|
0e7f3adf53 | ||
|
|
342d2b1fc4 | ||
|
|
15279685f7 | ||
|
|
12aa959f50 | ||
|
|
9478946c7a | ||
|
|
8560bbf28b | ||
|
|
df7afe1b64 | ||
|
|
df52b60d61 | ||
|
|
65b051f0eb | ||
|
|
7678084061 | ||
|
|
022d33d06f | ||
|
|
4d157bf8dc |
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
|
||||
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
|
||||
334
.cursor/rules/formbricks-architecture.mdc
Normal file
334
.cursor/rules/formbricks-architecture.mdc
Normal file
@@ -0,0 +1,334 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Formbricks Architecture & Patterns
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
### Apps Directory
|
||||
- `apps/web/` - Main Next.js web application
|
||||
- `packages/` - Shared packages and utilities
|
||||
|
||||
### Key Directories in Web App
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Next.js 13+ app directory
|
||||
│ ├── (app)/ # Main application routes
|
||||
│ ├── (auth)/ # Authentication routes
|
||||
│ ├── api/ # API routes
|
||||
│ └── share/ # Public sharing routes
|
||||
├── components/ # Shared components
|
||||
├── lib/ # Utility functions and services
|
||||
└── modules/ # Feature-specific modules
|
||||
```
|
||||
|
||||
## Routing Patterns
|
||||
|
||||
### App Router Structure
|
||||
The application uses Next.js 13+ app router with route groups:
|
||||
|
||||
```
|
||||
(app)/environments/[environmentId]/
|
||||
├── surveys/[surveyId]/
|
||||
│ ├── (analysis)/ # Analysis views
|
||||
│ │ ├── responses/ # Response management
|
||||
│ │ ├── summary/ # Survey summary
|
||||
│ │ └── hooks/ # Analysis-specific hooks
|
||||
│ ├── edit/ # Survey editing
|
||||
│ └── settings/ # Survey settings
|
||||
```
|
||||
|
||||
### Dynamic Routes
|
||||
- `[environmentId]` - Environment-specific routes
|
||||
- `[surveyId]` - Survey-specific routes
|
||||
- `[sharingKey]` - Public sharing routes
|
||||
|
||||
## Service Layer Pattern
|
||||
|
||||
### Service Organization
|
||||
Services are organized by domain in `apps/web/lib/`:
|
||||
|
||||
```typescript
|
||||
// Example: Response service
|
||||
// apps/web/lib/response/service.ts
|
||||
export const getResponseCountAction = async ({
|
||||
surveyId,
|
||||
filterCriteria,
|
||||
}: {
|
||||
surveyId: string;
|
||||
filterCriteria: any;
|
||||
}) => {
|
||||
// Service implementation
|
||||
};
|
||||
```
|
||||
|
||||
### Action Pattern
|
||||
Server actions follow a consistent pattern:
|
||||
|
||||
```typescript
|
||||
// Action wrapper for service calls
|
||||
export const getResponseCountAction = async (params) => {
|
||||
try {
|
||||
const result = await responseService.getCount(params);
|
||||
return { data: result };
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Context Patterns
|
||||
|
||||
### Provider Structure
|
||||
Context providers follow a consistent pattern:
|
||||
|
||||
```typescript
|
||||
// Provider component
|
||||
export const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [selectedFilter, setSelectedFilter] = useState(defaultFilter);
|
||||
|
||||
const value = {
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
// ... other state and methods
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponseFilterContext.Provider value={value}>
|
||||
{children}
|
||||
</ResponseFilterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook for consuming context
|
||||
export const useResponseFilter = () => {
|
||||
const context = useContext(ResponseFilterContext);
|
||||
if (!context) {
|
||||
throw new Error('useResponseFilter must be used within ResponseFilterProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
```
|
||||
|
||||
### Context Composition
|
||||
Multiple contexts are often composed together:
|
||||
|
||||
```typescript
|
||||
// Layout component with multiple providers
|
||||
export default function AnalysisLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ResponseFilterProvider>
|
||||
<ResponseCountProvider>
|
||||
{children}
|
||||
</ResponseCountProvider>
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Page Components
|
||||
Page components are located in the app directory and follow this pattern:
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
|
||||
export default function ResponsesPage() {
|
||||
return (
|
||||
<div>
|
||||
<ResponsesTable />
|
||||
<ResponsesPagination />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component Organization
|
||||
- **Pages** - Route components in app directory
|
||||
- **Components** - Reusable UI components
|
||||
- **Modules** - Feature-specific components and logic
|
||||
|
||||
### Shared Components
|
||||
Common components are in `apps/web/components/`:
|
||||
- UI components (buttons, inputs, modals)
|
||||
- Layout components (headers, sidebars)
|
||||
- Data display components (tables, charts)
|
||||
|
||||
## Hook Patterns
|
||||
|
||||
### Custom Hook Structure
|
||||
Custom hooks follow consistent patterns:
|
||||
|
||||
```typescript
|
||||
export const useResponseCount = ({
|
||||
survey,
|
||||
initialCount
|
||||
}: {
|
||||
survey: TSurvey;
|
||||
initialCount?: number;
|
||||
}) => {
|
||||
const [responseCount, setResponseCount] = useState(initialCount ?? 0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Hook logic...
|
||||
|
||||
return {
|
||||
responseCount,
|
||||
isLoading,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Hook Dependencies
|
||||
- Use context hooks for shared state
|
||||
- Implement proper cleanup with AbortController
|
||||
- Optimize dependency arrays to prevent unnecessary re-renders
|
||||
|
||||
## Data Fetching Patterns
|
||||
|
||||
### Server Actions
|
||||
The app uses Next.js server actions for data fetching:
|
||||
|
||||
```typescript
|
||||
// Server action
|
||||
export async function getResponsesAction(params: GetResponsesParams) {
|
||||
const responses = await getResponses(params);
|
||||
return { data: responses };
|
||||
}
|
||||
|
||||
// Client usage
|
||||
const { data } = await getResponsesAction(params);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
Consistent error handling across the application:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await apiCall();
|
||||
return { data: result };
|
||||
} catch (error) {
|
||||
console.error("Operation failed:", error);
|
||||
return { error: error.message };
|
||||
}
|
||||
```
|
||||
|
||||
## Type Safety
|
||||
|
||||
### Type Organization
|
||||
Types are organized in packages:
|
||||
- `@formbricks/types` - Shared type definitions
|
||||
- Local types in component/hook files
|
||||
|
||||
### Common Types
|
||||
```typescript
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Local State
|
||||
- Use `useState` for component-specific state
|
||||
- Use `useReducer` for complex state logic
|
||||
- Use refs for mutable values that don't trigger re-renders
|
||||
|
||||
### Global State
|
||||
- React Context for feature-specific shared state
|
||||
- URL state for filters and pagination
|
||||
- Server state through server actions
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Code Splitting
|
||||
- Dynamic imports for heavy components
|
||||
- Route-based code splitting with app router
|
||||
- Lazy loading for non-critical features
|
||||
|
||||
### Caching Strategy
|
||||
- Server-side caching for database queries
|
||||
- Client-side caching with React Query (where applicable)
|
||||
- Static generation for public pages
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Test Organization
|
||||
```
|
||||
component/
|
||||
├── Component.tsx
|
||||
├── Component.test.tsx
|
||||
└── hooks/
|
||||
├── useHook.ts
|
||||
└── useHook.test.tsx
|
||||
```
|
||||
|
||||
### Test Patterns
|
||||
- Unit tests for utilities and services
|
||||
- Integration tests for components with context
|
||||
- Hook tests with proper mocking
|
||||
|
||||
## Build & Deployment
|
||||
|
||||
### Build Process
|
||||
- TypeScript compilation
|
||||
- Next.js build optimization
|
||||
- Asset optimization and bundling
|
||||
|
||||
### Environment Configuration
|
||||
- Environment-specific configurations
|
||||
- Feature flags for gradual rollouts
|
||||
- Database connection management
|
||||
|
||||
## Security Patterns
|
||||
|
||||
### Authentication
|
||||
- Session-based authentication
|
||||
- Environment-based access control
|
||||
- API route protection
|
||||
|
||||
### Data Validation
|
||||
- Input validation on both client and server
|
||||
- Type-safe API contracts
|
||||
- Sanitization of user inputs
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Error Tracking
|
||||
- Client-side error boundaries
|
||||
- Server-side error logging
|
||||
- Performance monitoring
|
||||
|
||||
### Analytics
|
||||
- User interaction tracking
|
||||
- Performance metrics
|
||||
- Database query monitoring
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
### Code Organization
|
||||
- ✅ Follow the established directory structure
|
||||
- ✅ Use consistent naming conventions
|
||||
- ✅ Separate concerns (UI, logic, data)
|
||||
- ✅ Keep components focused and small
|
||||
|
||||
### Performance
|
||||
- ✅ Implement proper loading states
|
||||
- ✅ Use AbortController for async operations
|
||||
- ✅ Optimize database queries
|
||||
- ✅ Implement proper caching strategies
|
||||
|
||||
### Type Safety
|
||||
- ✅ Use TypeScript throughout
|
||||
- ✅ Define proper interfaces for props
|
||||
- ✅ Use type guards for runtime validation
|
||||
- ✅ Leverage shared type packages
|
||||
|
||||
### Testing
|
||||
- ✅ Write tests for critical functionality
|
||||
- ✅ Mock external dependencies properly
|
||||
- ✅ Test error scenarios and edge cases
|
||||
- ✅ Maintain good test coverage
|
||||
429
.cursor/rules/infrastructure.mdc
Normal file
429
.cursor/rules/infrastructure.mdc
Normal file
@@ -0,0 +1,429 @@
|
||||
---
|
||||
description: Infrastructure, Terraform, Kubernetes Cluster related
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Formbricks Infrastructure Comprehensive Guide
|
||||
|
||||
## Infrastructure Overview
|
||||
|
||||
Formbricks uses a modern, cloud-native infrastructure built on AWS EKS with a focus on scalability, security, and operational excellence. The infrastructure follows Infrastructure as Code (IaC) principles using Terraform and GitOps patterns with Helm. The system has been specifically optimized to minimize ELB 502/504 errors through careful configuration of connection handling, health checks, and pod lifecycle management.
|
||||
|
||||
## Repository Structure & Organization
|
||||
|
||||
### Terraform File Organization
|
||||
```
|
||||
infra/terraform/
|
||||
├── main.tf # Core infrastructure (VPC, EKS, Karpenter)
|
||||
├── cloudwatch.tf # Monitoring, alerting, and CloudWatch alarms
|
||||
├── rds.tf # Aurora PostgreSQL database configuration
|
||||
├── elasticache.tf # Redis/Valkey caching layer
|
||||
├── observability.tf # Loki, Grafana, and monitoring stack
|
||||
├── iam.tf # GitHub OIDC, security roles
|
||||
├── secrets.tf # AWS Secrets Manager integration
|
||||
├── provider.tf # AWS, Kubernetes, Helm providers
|
||||
├── versions.tf # Provider version constraints
|
||||
└── data.tf # Data sources and external references
|
||||
```
|
||||
|
||||
### Helm Configuration
|
||||
- **Helmfile**: [infra/formbricks-cloud-helm/helmfile.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/helmfile.yaml.gotmpl) - Multi-environment orchestration
|
||||
- **Production**: [infra/formbricks-cloud-helm/values.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values.yaml.gotmpl) - Optimized ALB and pod configurations
|
||||
- **Staging**: [infra/formbricks-cloud-helm/values-staging.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values-staging.yaml.gotmpl) - Staging with spot instances
|
||||
|
||||
### Key Infrastructure Files
|
||||
- **Main Infrastructure**: [infra/terraform/main.tf](mdc:infra/terraform/main.tf) - EKS cluster, VPC, Karpenter, and core AWS resources
|
||||
- **Monitoring**: [infra/terraform/cloudwatch.tf](mdc:infra/terraform/cloudwatch.tf) - CloudWatch alarms for 502/504 error tracking and alerting
|
||||
- **Database**: [infra/terraform/rds.tf](mdc:infra/terraform/rds.tf) - Aurora PostgreSQL configuration
|
||||
|
||||
## Core Architecture Principles
|
||||
|
||||
### 1. Multi-Environment Strategy
|
||||
```hcl
|
||||
# Environment-aware resource creation
|
||||
locals {
|
||||
envs = {
|
||||
prod = "${local.project}-prod"
|
||||
stage = "${local.project}-stage"
|
||||
}
|
||||
}
|
||||
|
||||
# Resource duplication pattern
|
||||
resource "aws_secretsmanager_secret" "formbricks_app_secrets" {
|
||||
for_each = local.envs
|
||||
name = "${each.key}/formbricks/secrets"
|
||||
}
|
||||
```
|
||||
|
||||
**Key Patterns:**
|
||||
- **Environment isolation** through separate namespaces and resources
|
||||
- **Consistent naming** conventions across environments
|
||||
- **Resource sharing** where appropriate (VPC, EKS cluster)
|
||||
- **Environment-specific** configurations and scaling parameters
|
||||
|
||||
### 2. Network Architecture
|
||||
```hcl
|
||||
# Strategic subnet allocation for different workload types
|
||||
private_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 4, k)] # /20 - Application workloads
|
||||
public_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 48)] # /24 - Load balancers
|
||||
intra_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 52)] # /24 - EKS control plane
|
||||
database_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 56)] # /24 - RDS/ElastiCache
|
||||
```
|
||||
|
||||
**Design Principles:**
|
||||
- **Private EKS cluster** with no public endpoint access
|
||||
- **Multi-AZ deployment** across 3 availability zones
|
||||
- **VPC endpoints** for AWS services to reduce NAT costs
|
||||
- **Single NAT Gateway** for cost optimization
|
||||
|
||||
### 3. Security Model
|
||||
```hcl
|
||||
# IRSA (IAM Roles for Service Accounts) pattern
|
||||
module "formbricks_app_iam_role" {
|
||||
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
|
||||
|
||||
oidc_providers = {
|
||||
eks = {
|
||||
provider_arn = module.eks.oidc_provider_arn
|
||||
namespace_service_accounts = ["formbricks:*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Security Best Practices:**
|
||||
- **GitHub OIDC** for CI/CD authentication (no long-lived credentials)
|
||||
- **Pod Identity** for workload AWS access
|
||||
- **AWS Secrets Manager** integration via External Secrets Operator
|
||||
- **Least privilege** IAM policies for all roles
|
||||
- **KMS encryption** for sensitive data at rest
|
||||
|
||||
## ALB Optimization & Error Reduction
|
||||
|
||||
### Connection Handling Optimizations
|
||||
```yaml
|
||||
# Key ALB annotations for reducing 502/504 errors
|
||||
alb.ingress.kubernetes.io/load-balancer-attributes: |
|
||||
idle_timeout.timeout_seconds=120,
|
||||
connection_logs.s3.enabled=false,
|
||||
access_logs.s3.enabled=false
|
||||
|
||||
alb.ingress.kubernetes.io/target-group-attributes: |
|
||||
deregistration_delay.timeout_seconds=30,
|
||||
stickiness.enabled=false,
|
||||
load_balancing.algorithm.type=least_outstanding_requests,
|
||||
target_group_health.dns_failover.minimum_healthy_targets.count=1
|
||||
```
|
||||
|
||||
### Health Check Configuration
|
||||
- **Interval**: 15 seconds for faster detection of unhealthy targets
|
||||
- **Timeout**: 5 seconds to prevent false positives
|
||||
- **Thresholds**: 2 healthy, 3 unhealthy for balanced responsiveness
|
||||
- **Path**: `/health` endpoint optimized for < 100ms response time
|
||||
|
||||
### Expected Improvements
|
||||
- **60-80% reduction** in ELB 502 errors
|
||||
- **Faster recovery** during pod restarts
|
||||
- **Better connection reuse** efficiency
|
||||
- **Improved autoscaling** responsiveness
|
||||
|
||||
## Kubernetes Platform Configuration
|
||||
|
||||
### 1. EKS Cluster Setup
|
||||
```hcl
|
||||
# Modern EKS configuration
|
||||
cluster_version = "1.32"
|
||||
enable_cluster_creator_admin_permissions = false
|
||||
cluster_endpoint_public_access = false
|
||||
|
||||
cluster_addons = {
|
||||
coredns = { most_recent = true }
|
||||
eks-pod-identity-agent = { most_recent = true }
|
||||
aws-ebs-csi-driver = { most_recent = true }
|
||||
kube-proxy = { most_recent = true }
|
||||
vpc-cni = { most_recent = true }
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Karpenter Autoscaling & Node Management
|
||||
```hcl
|
||||
# Intelligent node provisioning
|
||||
requirements = [
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-family"
|
||||
operator = "In"
|
||||
values = ["c8g", "c7g", "m8g", "m7g", "r8g", "r7g"] # ARM64 Graviton
|
||||
},
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-cpu"
|
||||
operator = "In"
|
||||
values = ["2", "4", "8"] # Cost-optimized sizes
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Node Lifecycle Optimization:**
|
||||
- **Startup Taints**: Prevent traffic during node initialization
|
||||
- **Graceful Shutdown**: 30s grace period for pod eviction
|
||||
- **Consolidation Delay**: 60s to reduce unnecessary churn
|
||||
- **Eviction Policies**: Configured for smooth pod migrations
|
||||
|
||||
**Instance Selection:**
|
||||
- **Families**: c8g, c7g, m8g, m7g, r8g, r7g (ARM64 Graviton)
|
||||
- **Sizes**: 2, 4, 8 vCPUs for cost optimization
|
||||
- **Bottlerocket AMI**: Enhanced security and performance
|
||||
|
||||
## Pod Lifecycle Management
|
||||
|
||||
### Graceful Shutdown Pattern
|
||||
```yaml
|
||||
# PreStop hook to allow connection draining
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["/bin/sh", "-c", "sleep 15"]
|
||||
|
||||
# Termination grace period for complete cleanup
|
||||
terminationGracePeriodSeconds: 45
|
||||
```
|
||||
|
||||
### Health Probe Strategy
|
||||
- **Startup Probe**: 5s initial delay, 5s interval, max 60s startup time
|
||||
- **Readiness Probe**: 10s delay, 10s interval for traffic readiness
|
||||
- **Liveness Probe**: 30s delay, 30s interval for container health
|
||||
|
||||
### Rolling Update Configuration
|
||||
```yaml
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 25% # Maintain capacity during updates
|
||||
maxSurge: 50% # Allow faster rollouts
|
||||
```
|
||||
|
||||
## Application Deployment Patterns
|
||||
|
||||
### 1. External Helm Chart Pattern
|
||||
```yaml
|
||||
# Helmfile configuration for external charts
|
||||
repositories:
|
||||
- name: helm-charts
|
||||
url: ghcr.io/formbricks/helm-charts
|
||||
oci: true
|
||||
|
||||
releases:
|
||||
- name: formbricks
|
||||
chart: helm-charts/formbricks
|
||||
version: ^3.0.0
|
||||
values: [values.yaml.gotmpl]
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
- **Separation of concerns** (infrastructure vs application)
|
||||
- **Version control** of application deployment
|
||||
- **Reusable charts** across environments
|
||||
- **OCI registry** for secure chart distribution
|
||||
|
||||
### 2. Configuration Management
|
||||
```yaml
|
||||
# External Secrets pattern
|
||||
externalSecret:
|
||||
enabled: true
|
||||
files:
|
||||
app-env:
|
||||
dataFrom:
|
||||
key: prod/formbricks/environment
|
||||
secretStore:
|
||||
kind: ClusterSecretStore
|
||||
name: aws-secrets-manager
|
||||
```
|
||||
|
||||
### 3. Environment-Specific Configurations
|
||||
- **Production**: On-demand instances, stricter resource limits
|
||||
- **Staging**: Spot instances, rate limiting disabled, relaxed resources
|
||||
|
||||
## Monitoring & Observability Stack
|
||||
|
||||
### 1. Critical ALB Metrics & CloudWatch Alarms
|
||||
```hcl
|
||||
# Comprehensive ALB monitoring
|
||||
alarms = {
|
||||
ALB_HTTPCode_ELB_502_Count = {
|
||||
alarm_description = "ALB 502 errors indicating backend connection issues"
|
||||
threshold = 20
|
||||
evaluation_periods = 3
|
||||
period = 300
|
||||
}
|
||||
ALB_HTTPCode_ELB_504_Count = {
|
||||
alarm_description = "ALB 504 timeout errors"
|
||||
threshold = 15
|
||||
evaluation_periods = 3
|
||||
period = 300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Monitoring Thresholds:**
|
||||
1. **ELB 502 Errors**: Threshold 20 over 5 minutes
|
||||
2. **ELB 504 Errors**: Threshold 15 over 5 minutes
|
||||
3. **Target Connection Errors**: Threshold 50 over 5 minutes
|
||||
4. **4XX Errors**: Threshold 100 over 10 minutes (client issues)
|
||||
|
||||
### 2. Log Aggregation & Analytics
|
||||
```hcl
|
||||
# Loki for centralized logging
|
||||
module "loki_s3_bucket" {
|
||||
source = "terraform-aws-modules/s3-bucket/aws"
|
||||
# S3 backend for long-term log storage
|
||||
}
|
||||
|
||||
module "observability_loki_iam_role" {
|
||||
# IRSA role for Loki to access S3
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Grafana Dashboards
|
||||
```hcl
|
||||
# Grafana with AWS CloudWatch integration
|
||||
policy = jsonencode({
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowReadingMetricsFromCloudWatch"
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"cloudwatch:DescribeAlarms",
|
||||
"cloudwatch:ListMetrics",
|
||||
"cloudwatch:GetMetricData"
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Cost Optimization Strategies
|
||||
|
||||
### 1. Instance & Compute Optimization
|
||||
- **ARM64 Graviton** processors (20% better price-performance)
|
||||
- **Spot instances** for staging environments
|
||||
- **Right-sizing** through Karpenter optimization
|
||||
- **Reserved capacity** for predictable production workloads
|
||||
|
||||
### 2. Network & Storage Optimization
|
||||
- **Single NAT Gateway** (vs. one per AZ)
|
||||
- **VPC endpoints** to reduce NAT traffic
|
||||
- **ELB cost optimization** through connection reuse
|
||||
- **GP3 storage** for better IOPS/cost ratio
|
||||
- **Lifecycle policies** for log retention
|
||||
|
||||
## Deployment Workflow & Best Practices
|
||||
|
||||
### 1. Infrastructure Updates
|
||||
```bash
|
||||
# Using the deployment script
|
||||
./infra/deploy-improvements.sh
|
||||
|
||||
# Manual process:
|
||||
cd infra/terraform
|
||||
terraform plan -out=changes.tfplan
|
||||
terraform apply changes.tfplan
|
||||
```
|
||||
|
||||
### 2. Application Updates
|
||||
```bash
|
||||
# Helmfile deployment
|
||||
cd infra/formbricks-cloud-helm
|
||||
helmfile sync
|
||||
|
||||
# Environment-specific deployment
|
||||
helmfile -e production sync
|
||||
helmfile -e staging sync
|
||||
```
|
||||
|
||||
### 3. Verification Steps
|
||||
1. **Infrastructure health**: Check EKS cluster status
|
||||
2. **Application readiness**: Verify pod status and health checks
|
||||
3. **Network connectivity**: Test ALB target group health
|
||||
4. **Monitoring**: Confirm CloudWatch metrics and alerts
|
||||
|
||||
### 4. Change Management Best Practices
|
||||
|
||||
**Testing Strategy:**
|
||||
- **Staging first**: Test all changes in staging environment with same configurations
|
||||
- **Gradual rollout**: Use blue-green or canary deployments
|
||||
- **Monitoring window**: Observe metrics for 24-48 hours after changes
|
||||
- **Rollback plan**: Always have a documented rollback strategy
|
||||
|
||||
**Performance Optimization:**
|
||||
- **Health endpoint** should respond < 100ms consistently
|
||||
- **Connection pooling** aligned with ALB idle timeouts
|
||||
- **Resource requests/limits** tuned for consistent performance
|
||||
- **Graceful shutdown** implemented in application code
|
||||
- **Maintain ALB timeout alignment** across all layers
|
||||
|
||||
**Security Considerations:**
|
||||
- **Least privilege**: Review IAM permissions regularly
|
||||
- **Secret rotation**: Implement regular credential rotation
|
||||
- **Vulnerability scanning**: Keep base images updated
|
||||
- **Network policies**: Implement pod-to-pod communication controls
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### 1. ALB Error Investigation
|
||||
|
||||
**502 Error Analysis:**
|
||||
1. Check pod readiness and health probe status
|
||||
2. Verify ALB target group health
|
||||
3. Review deregistration timing during deployments
|
||||
4. Monitor connection pool utilization
|
||||
|
||||
**504 Error Analysis:**
|
||||
1. Check application response times
|
||||
2. Verify timeout configurations (ALB: 120s, App: aligned)
|
||||
3. Review database query performance
|
||||
4. Monitor resource utilization during traffic spikes
|
||||
|
||||
**Connection Error Patterns:**
|
||||
1. Verify Karpenter node lifecycle timing
|
||||
2. Check pod termination grace periods
|
||||
3. Review ALB connection draining settings
|
||||
4. Monitor cluster autoscaling events
|
||||
|
||||
### 2. Infrastructure Issues
|
||||
|
||||
**Pod Startup Issues:**
|
||||
- Check **startup probes** and timing
|
||||
- Verify **resource requests** vs. available capacity
|
||||
- Review **image pull** policies and registry access
|
||||
- Monitor **Karpenter** node provisioning logs
|
||||
|
||||
**Connectivity Problems:**
|
||||
- Validate **security group** rules
|
||||
- Check **DNS resolution** within cluster
|
||||
- Verify **service mesh** configuration if applicable
|
||||
- Review **network policies** for pod communication
|
||||
|
||||
**Performance Degradation:**
|
||||
- Monitor **resource utilization** (CPU, memory, network)
|
||||
- Check **database connection** pooling and query performance
|
||||
- Review **cache hit ratios** for Redis/ElastiCache
|
||||
- Analyze **ALB metrics** for traffic patterns
|
||||
|
||||
### 3. Monitoring Strategy
|
||||
- **Real-time alerts** for error rate spikes
|
||||
- **Trend analysis** for connection patterns
|
||||
- **Capacity planning** based on LCU usage
|
||||
- **4XX pattern analysis** for client behavior insights
|
||||
|
||||
## Critical Considerations When Making Infrastructure Changes
|
||||
|
||||
1. **Always test in staging first** with identical configurations
|
||||
2. **Monitor ALB metrics** for 24-48 hours after changes
|
||||
3. **Use gradual rollouts** with proper health checks and canary deployments
|
||||
4. **Maintain timeout alignment** across ALB, application, and database layers
|
||||
5. **Verify security configurations** don't introduce vulnerabilities
|
||||
6. **Check cost impact** of infrastructure changes
|
||||
7. **Update monitoring and alerting** to cover new components
|
||||
8. **Document changes** and update runbooks accordingly
|
||||
|
||||
This comprehensive infrastructure provides a robust, scalable, and cost-effective platform for running Formbricks at scale while maintaining high availability, security standards, and minimal error rates.
|
||||
|
||||
5
.cursor/rules/performance-optimization.mdc
Normal file
5
.cursor/rules/performance-optimization.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
52
.cursor/rules/react-context-patterns.mdc
Normal file
52
.cursor/rules/react-context-patterns.mdc
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# React Context & Provider Patterns
|
||||
|
||||
## Context Provider Best Practices
|
||||
|
||||
### Provider Implementation
|
||||
- Use TypeScript interfaces for provider props with optional `initialCount` for testing
|
||||
- Implement proper cleanup in `useEffect` to avoid React hooks warnings
|
||||
- Reference: [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx)
|
||||
|
||||
### Cleanup Pattern for Refs
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const currentPendingRequests = pendingRequests.current;
|
||||
const currentAbortController = abortController.current;
|
||||
|
||||
return () => {
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
currentPendingRequests.clear();
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Testing Context Providers
|
||||
- Always wrap components using context in the provider during tests
|
||||
- Use `initialCount` prop for predictable test scenarios
|
||||
- Mock context dependencies like `useParams`, `useResponseFilter`
|
||||
- Example from [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx):
|
||||
|
||||
```typescript
|
||||
render(
|
||||
<ResponseCountProvider survey={dummySurvey} initialCount={5}>
|
||||
<ComponentUnderTest />
|
||||
</ResponseCountProvider>
|
||||
);
|
||||
```
|
||||
|
||||
### Required Mocks for Context Testing
|
||||
- Mock `next/navigation` with `useParams` returning environment and survey IDs
|
||||
- Mock response filter context and actions
|
||||
- Mock API actions that the provider depends on
|
||||
|
||||
### Context Hook Usage
|
||||
- Create custom hooks like `useResponseCountContext()` for consuming context
|
||||
- Provide meaningful error messages when context is used outside provider
|
||||
- Use context for shared state that multiple components need to access
|
||||
5
.cursor/rules/react-context-providers.mdc
Normal file
5
.cursor/rules/react-context-providers.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
282
.cursor/rules/testing-patterns.mdc
Normal file
282
.cursor/rules/testing-patterns.mdc
Normal file
@@ -0,0 +1,282 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Testing Patterns & Best Practices
|
||||
|
||||
## Test File Naming & Environment
|
||||
|
||||
### File Extensions
|
||||
- Use `.test.tsx` for React component/hook tests (runs in jsdom environment)
|
||||
- Use `.test.ts` for utility/service tests (runs in Node environment)
|
||||
- The vitest config uses `environmentMatchGlobs` to automatically set jsdom for `.tsx` files
|
||||
|
||||
### Test Structure
|
||||
```typescript
|
||||
// Import the mocked functions first
|
||||
import { useHook } from "@/path/to/hook";
|
||||
import { serviceFunction } from "@/path/to/service";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/path/to/hook", () => ({
|
||||
useHook: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ComponentName", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Setup default mocks
|
||||
});
|
||||
|
||||
test("descriptive test name", async () => {
|
||||
// Test implementation
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## React Hook Testing
|
||||
|
||||
### Context Mocking
|
||||
When testing hooks that use React Context:
|
||||
```typescript
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: {
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
},
|
||||
setSelectedFilter: vi.fn(),
|
||||
selectedOptions: {
|
||||
questionOptions: [],
|
||||
questionFilterOptions: [],
|
||||
},
|
||||
setSelectedOptions: vi.fn(),
|
||||
dateRange: { from: new Date(), to: new Date() },
|
||||
setDateRange: vi.fn(),
|
||||
resetState: vi.fn(),
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Async Hooks
|
||||
- Always use `waitFor` for async operations
|
||||
- Test both loading and completed states
|
||||
- Verify API calls with correct parameters
|
||||
|
||||
```typescript
|
||||
test("fetches data on mount", async () => {
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.data).toBe(expectedData);
|
||||
expect(vi.mocked(apiCall)).toHaveBeenCalledWith(expectedParams);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Hook Dependencies
|
||||
To test useEffect dependencies, ensure mocks return different values:
|
||||
```typescript
|
||||
// First render
|
||||
mockGetFormattedFilters.mockReturnValue(mockFilters);
|
||||
|
||||
// Change dependency and trigger re-render
|
||||
const newMockFilters = { ...mockFilters, finished: true };
|
||||
mockGetFormattedFilters.mockReturnValue(newMockFilters);
|
||||
rerender();
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Race Condition Testing
|
||||
Test AbortController implementation:
|
||||
```typescript
|
||||
test("cancels previous request when new request is made", async () => {
|
||||
let resolveFirst: (value: any) => void;
|
||||
let resolveSecond: (value: any) => void;
|
||||
|
||||
const firstPromise = new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const secondPromise = new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(apiCall)
|
||||
.mockReturnValueOnce(firstPromise as any)
|
||||
.mockReturnValueOnce(secondPromise as any);
|
||||
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
// Trigger second request
|
||||
result.current.refetch();
|
||||
|
||||
// Resolve in order - first should be cancelled
|
||||
resolveFirst!({ data: 100 });
|
||||
resolveSecond!({ data: 200 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Should have result from second request
|
||||
expect(result.current.data).toBe(200);
|
||||
});
|
||||
```
|
||||
|
||||
### Cleanup Testing
|
||||
```typescript
|
||||
test("cleans up on unmount", () => {
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
||||
|
||||
const { unmount } = renderHook(() => useHook());
|
||||
unmount();
|
||||
|
||||
expect(abortSpy).toHaveBeenCalled();
|
||||
abortSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling Testing
|
||||
|
||||
### API Error Testing
|
||||
```typescript
|
||||
test("handles API errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.mocked(apiCall).mockRejectedValue(new Error("API Error"));
|
||||
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error message:", expect.any(Error));
|
||||
expect(result.current.data).toBe(fallbackValue);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
### Cancelled Request Testing
|
||||
```typescript
|
||||
test("does not update state for cancelled requests", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
let rejectFirst: (error: any) => void;
|
||||
const firstPromise = new Promise((_, reject) => {
|
||||
rejectFirst = reject;
|
||||
});
|
||||
|
||||
vi.mocked(apiCall)
|
||||
.mockReturnValueOnce(firstPromise as any)
|
||||
.mockResolvedValueOnce({ data: 42 });
|
||||
|
||||
const { result } = renderHook(() => useHook());
|
||||
result.current.refetch();
|
||||
|
||||
const abortError = new Error("Request cancelled");
|
||||
rejectFirst!(abortError);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Should not log error for cancelled request
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
## Type Safety in Tests
|
||||
|
||||
### Mock Type Assertions
|
||||
Use type assertions for edge cases:
|
||||
```typescript
|
||||
vi.mocked(apiCall).mockResolvedValue({
|
||||
data: null as any, // For testing null handling
|
||||
});
|
||||
|
||||
vi.mocked(apiCall).mockResolvedValue({
|
||||
data: undefined as any, // For testing undefined handling
|
||||
});
|
||||
```
|
||||
|
||||
### Proper Mock Typing
|
||||
Ensure mocks match the actual interface:
|
||||
```typescript
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-123",
|
||||
name: "Test Survey",
|
||||
// ... other required properties
|
||||
} as unknown as TSurvey; // Use when partial mocking is needed
|
||||
```
|
||||
|
||||
## Common Test Patterns
|
||||
|
||||
### Testing State Changes
|
||||
```typescript
|
||||
test("updates state correctly", async () => {
|
||||
const { result } = renderHook(() => useHook());
|
||||
|
||||
// Initial state
|
||||
expect(result.current.value).toBe(initialValue);
|
||||
|
||||
// Trigger change
|
||||
result.current.updateValue(newValue);
|
||||
|
||||
// Verify change
|
||||
expect(result.current.value).toBe(newValue);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Multiple Scenarios
|
||||
```typescript
|
||||
test("handles different modes", async () => {
|
||||
// Test regular mode
|
||||
vi.mocked(useParams).mockReturnValue({ surveyId: "123" });
|
||||
const { rerender } = renderHook(() => useHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test sharing mode
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
surveyId: "123",
|
||||
sharingKey: "share-123"
|
||||
});
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(sharingApi)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
### Comprehensive Test Coverage
|
||||
For hooks, ensure you test:
|
||||
- ✅ Initialization (with/without initial values)
|
||||
- ✅ Data fetching (success/error cases)
|
||||
- ✅ State updates and refetching
|
||||
- ✅ Dependency changes triggering effects
|
||||
- ✅ Manual actions (refetch, reset)
|
||||
- ✅ Race condition prevention
|
||||
- ✅ Cleanup on unmount
|
||||
- ✅ Mode switching (if applicable)
|
||||
- ✅ Edge cases (null/undefined data)
|
||||
|
||||
### Test Naming
|
||||
Use descriptive test names that explain the scenario:
|
||||
- ✅ "initializes with initial count"
|
||||
- ✅ "fetches response count on mount for regular survey"
|
||||
- ✅ "cancels previous request when new request is made"
|
||||
- ❌ "test hook"
|
||||
- ❌ "it works"
|
||||
6
.cursor/rules/testing.mdc
Normal file
6
.cursor/rules/testing.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Whenever the user asks to write or update a test file for .tsx or .ts files.
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)
|
||||
@@ -212,4 +212,7 @@ UNKEY_ROOT_KEY=
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
|
||||
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
||||
# SESSION_MAX_AGE=86400
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"eslint-plugin-react-refresh": "0.4.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
|
||||
@@ -85,6 +85,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
||||
@@ -88,6 +88,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/service");
|
||||
|
||||
@@ -97,6 +97,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("Contact Page Re-export", () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integration/service");
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("AppConnectionPage Re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("GeneralSettingsPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("LanguagesPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("ProjectLookSettingsPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("TagsPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("ProjectTeams re-export", () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);
|
||||
|
||||
@@ -1,17 +1,87 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { deleteFile } from "@/lib/storage/service";
|
||||
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZUserUpdateInput } from "@formbricks/types/user";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
OperationNotAllowedError,
|
||||
TooManyRequestsError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
const limiter = rateLimit({
|
||||
interval: 60 * 60, // 1 hour
|
||||
allowedPerInterval: 3, // max 3 calls for email verification per hour
|
||||
});
|
||||
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
.schema(ZUserUpdateInput.pick({ name: true, locale: true }))
|
||||
.schema(
|
||||
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
|
||||
password: ZUserPassword.optional(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
return await updateUser(ctx.user.id, parsedInput);
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
|
||||
let payload: TUserUpdateInput = {
|
||||
...(parsedInput.name && { name: parsedInput.name }),
|
||||
...(parsedInput.locale && { locale: parsedInput.locale }),
|
||||
};
|
||||
|
||||
// Only process email update if a new email is provided and it's different from current email
|
||||
if (inputEmail && ctx.user.email !== inputEmail) {
|
||||
// Check rate limit
|
||||
try {
|
||||
await limiter(ctx.user.id);
|
||||
} catch {
|
||||
throw new TooManyRequestsError("Too many requests");
|
||||
}
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||
}
|
||||
|
||||
if (!parsedInput.password) {
|
||||
throw new AuthenticationError("Password is required to update email.");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
|
||||
if (!isCorrectPassword) {
|
||||
throw new AuthorizationError("Incorrect credentials");
|
||||
}
|
||||
|
||||
// Check if the new email is unique, no user exists with the new email
|
||||
const isEmailUnique = await getIsEmailUnique(inputEmail);
|
||||
|
||||
// If the new email is unique, proceed with the email update
|
||||
if (isEmailUnique) {
|
||||
if (EMAIL_VERIFICATION_DISABLED) {
|
||||
payload.email = inputEmail;
|
||||
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
|
||||
} else {
|
||||
await sendVerificationNewEmail(ctx.user.id, inputEmail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
if (Object.keys(payload).length > 0) {
|
||||
await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
|
||||
@@ -50,11 +50,10 @@ describe("EditProfileDetailsForm", () => {
|
||||
test("renders with initial user data and updates successfully", async () => {
|
||||
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
|
||||
|
||||
render(<EditProfileDetailsForm user={mockUser} />);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
expect(nameInput).toHaveValue(mockUser.name);
|
||||
expect(screen.getByDisplayValue(mockUser.email)).toBeDisabled();
|
||||
// Check initial language (English)
|
||||
expect(screen.getByText("English (US)")).toBeInTheDocument();
|
||||
|
||||
@@ -72,7 +71,11 @@ describe("EditProfileDetailsForm", () => {
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUserAction).toHaveBeenCalledWith({ name: "New Name", locale: "de-DE" });
|
||||
expect(updateUserAction).toHaveBeenCalledWith({
|
||||
name: "New Name",
|
||||
locale: "de-DE",
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
@@ -88,7 +91,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
const errorMessage = "Update failed";
|
||||
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(<EditProfileDetailsForm user={mockUser} />);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
await userEvent.clear(nameInput);
|
||||
@@ -106,7 +109,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
});
|
||||
|
||||
test("update button is disabled initially and enables on change", async () => {
|
||||
render(<EditProfileDetailsForm user={mockUser} />);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
const updateButton = screen.getByText("common.update");
|
||||
expect(updateButton).toBeDisabled();
|
||||
|
||||
|
||||
@@ -1,136 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TUser, ZUser } from "@formbricks/types/user";
|
||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||
import { updateUserAction } from "../actions";
|
||||
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true });
|
||||
// Schema & types
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
|
||||
email: ZUserEmail.transform((val) => val?.trim().toLowerCase()),
|
||||
});
|
||||
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
|
||||
|
||||
export const EditProfileDetailsForm = ({ user }: { user: TUser }) => {
|
||||
export const EditProfileDetailsForm = ({
|
||||
user,
|
||||
emailVerificationDisabled,
|
||||
}: {
|
||||
user: TUser;
|
||||
emailVerificationDisabled: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TEditProfileNameForm>({
|
||||
defaultValues: { name: user.name, locale: user.locale || "en" },
|
||||
defaultValues: {
|
||||
name: user.name,
|
||||
locale: user.locale,
|
||||
email: user.email,
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZEditProfileNameFormSchema),
|
||||
});
|
||||
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
const { t } = useTranslate();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const handleConfirmPassword = async (password: string) => {
|
||||
const values = form.getValues();
|
||||
const dirtyFields = form.formState.dirtyFields;
|
||||
|
||||
const emailChanged = "email" in dirtyFields;
|
||||
const nameChanged = "name" in dirtyFields;
|
||||
const localeChanged = "locale" in dirtyFields;
|
||||
|
||||
const name = values.name.trim();
|
||||
const email = values.email.trim().toLowerCase();
|
||||
const locale = values.locale;
|
||||
|
||||
const data: TUserUpdateInput = {};
|
||||
|
||||
if (emailChanged) {
|
||||
data.email = email;
|
||||
data.password = password;
|
||||
}
|
||||
if (nameChanged) {
|
||||
data.name = name;
|
||||
}
|
||||
if (localeChanged) {
|
||||
data.locale = locale;
|
||||
}
|
||||
|
||||
const updatedUserResult = await updateUserAction(data);
|
||||
|
||||
if (updatedUserResult?.data) {
|
||||
if (!emailVerificationDisabled) {
|
||||
toast.success(t("auth.verification-requested.new_email_verification_success"));
|
||||
} else {
|
||||
toast.success(t("environments.settings.profile.email_change_initiated"));
|
||||
await signOut({ redirect: false });
|
||||
router.push(`/email-change-without-verification-success`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedUserResult);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
|
||||
try {
|
||||
const name = data.name.trim();
|
||||
const locale = data.locale;
|
||||
await updateUserAction({ name, locale });
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
window.location.reload();
|
||||
form.reset({ name, locale });
|
||||
} catch (error) {
|
||||
toast.error(`${t("common.error")}: ${error.message}`);
|
||||
if (data.email !== user.email) {
|
||||
setShowModal(true);
|
||||
} else {
|
||||
try {
|
||||
await updateUserAction({
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
});
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
window.location.reload();
|
||||
form.reset(data);
|
||||
} catch (error: any) {
|
||||
toast.error(`${t("common.error")}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full max-w-sm items-center" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.full_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
placeholder={t("common.full_name")}
|
||||
required
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full max-w-sm" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.full_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
required
|
||||
placeholder={t("common.full_name")}
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* disabled email field */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="email">{t("common.email")}</Label>
|
||||
<Input type="email" id="email" defaultValue={user.email} disabled />
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
required
|
||||
isInvalid={!!form.formState.errors.email}
|
||||
disabled={user.identityProvider !== "email"}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locale"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 w-full border border-slate-300 px-3 text-left"
|
||||
variant="ghost">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{appLanguages.find((language) => language.code === field.value)?.label[field.value] ||
|
||||
"NA"}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-40 bg-slate-50 text-slate-700"
|
||||
align="start"
|
||||
side="bottom">
|
||||
{appLanguages.map((language) => (
|
||||
<DropdownMenuItem
|
||||
key={language.code}
|
||||
onClick={() => field.onChange(language.code)}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{language.label[field.value]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locale"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
|
||||
align="start">
|
||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||
{appLanguages.map((lang) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={lang.code}
|
||||
value={lang.code}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{lang.label["en-US"]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
{t("common.update")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
{t("common.update")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
<PasswordConfirmationModal
|
||||
open={showModal}
|
||||
setOpen={setShowModal}
|
||||
oldEmail={user.email}
|
||||
newEmail={form.getValues("email") || user.email}
|
||||
onConfirm={handleConfirmPassword}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { PasswordConfirmationModal } from "./password-confirmation-modal";
|
||||
|
||||
// Mock the Modal component
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, title }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{children}
|
||||
<button data-testid="modal-close" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Mock the PasswordInput component
|
||||
vi.mock("@/modules/ui/components/password-input", () => ({
|
||||
PasswordInput: ({ onChange, value, placeholder }: any) => (
|
||||
<input
|
||||
type="password"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
data-testid="password-input"
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the useTranslate hook
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("PasswordConfirmationModal", () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
setOpen: vi.fn(),
|
||||
oldEmail: "old@example.com",
|
||||
newEmail: "new@example.com",
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders nothing when open is false", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders modal content when open is true", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays old and new email addresses", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
expect(screen.getByText("old@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("new@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows password input field", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
expect(passwordInput).toBeInTheDocument();
|
||||
expect(passwordInput).toHaveAttribute("placeholder", "*******");
|
||||
});
|
||||
|
||||
test("disables confirm button when form is not dirty", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("disables confirm button when old and new emails are the same", () => {
|
||||
render(
|
||||
<PasswordConfirmationModal {...defaultProps} oldEmail="same@example.com" newEmail="same@example.com" />
|
||||
);
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("enables confirm button when password is entered and emails are different", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
await user.type(passwordInput, "password123");
|
||||
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
expect(confirmButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("shows error message when password is too short", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
await user.type(passwordInput, "short");
|
||||
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles cancel button click and resets form", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
await user.type(passwordInput, "password123");
|
||||
|
||||
const cancelButton = screen.getByText("common.cancel");
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(defaultProps.setOpen).toHaveBeenCalledWith(false);
|
||||
await waitFor(() => {
|
||||
expect(passwordInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ZUserPassword } from "@formbricks/types/user";
|
||||
|
||||
interface PasswordConfirmationModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
oldEmail: string;
|
||||
newEmail: string;
|
||||
onConfirm: (password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const PasswordConfirmationSchema = z.object({
|
||||
password: ZUserPassword,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof PasswordConfirmationSchema>;
|
||||
|
||||
export const PasswordConfirmationModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
oldEmail,
|
||||
newEmail,
|
||||
onConfirm,
|
||||
}: PasswordConfirmationModalProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(PasswordConfirmationSchema),
|
||||
});
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
|
||||
const onSubmit: SubmitHandler<FormValues> = async (data) => {
|
||||
try {
|
||||
await onConfirm(data.password);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
form.setError("password", {
|
||||
message: error instanceof Error ? error.message : "Authentication failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleCancel = () => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.email-change.confirm_password_description")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
|
||||
<p>
|
||||
<strong>{t("auth.email-change.old_email")}:</strong>
|
||||
<br /> {oldEmail.toLowerCase()}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("auth.email-change.new_email")}:</strong>
|
||||
<br /> {newEmail.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-x-2 text-right">
|
||||
<Button type="button" variant="secondary" onClick={handleCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getIsEmailUnique, verifyUserPassword } from "./user";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/user/cache", () => ({
|
||||
userCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `user-${id}-tag`),
|
||||
byEmail: vi.fn((email) => `user-email-${email}-tag`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// reactCache (from "react") and unstable_cache (from "next/cache") are mocked in vitestSetup.ts
|
||||
// to be pass-through, so the inner logic of cached functions is tested.
|
||||
|
||||
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
|
||||
|
||||
describe("User Library Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("verifyUserPassword", () => {
|
||||
const userId = "test-user-id";
|
||||
const password = "test-password";
|
||||
|
||||
test("should return true for correct password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(true);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should return false for incorrect password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(false);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if user not found", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if identityProvider is not email", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "google", // Not 'email'
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if password is not set for email provider", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: null, // Password not set
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsEmailUnique", () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
test("should return false if user exists", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
id: "some-user-id",
|
||||
} as any);
|
||||
|
||||
const result = await getIsEmailUnique(email);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return true if user does not exist", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await getIsEmailUnique(email);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { userCache } from "@/lib/user/cache";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getUserById = reactCache(
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> =>
|
||||
cache(
|
||||
async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
return user;
|
||||
},
|
||||
[`getUserById-${userId}`],
|
||||
{
|
||||
tags: [userCache.tag.byId(userId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (user.identityProvider !== "email" || !user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyPassword(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getIsEmailUnique = reactCache(
|
||||
async (email: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !user;
|
||||
},
|
||||
[`getIsEmailUnique-${email}`],
|
||||
{
|
||||
tags: [userCache.tag.byEmail(email)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -13,6 +13,7 @@ import Page from "./page";
|
||||
// Mock services and utils
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
EMAIL_VERIFICATION_DISABLED: true,
|
||||
}));
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -42,7 +42,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
<SettingsCard
|
||||
title={t("environments.settings.profile.personal_information")}
|
||||
description={t("environments.settings.profile.update_personal_info")}>
|
||||
<EditProfileDetailsForm user={user} />
|
||||
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("common.avatar")}
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("TeamsPage re-export", () => {
|
||||
|
||||
@@ -75,7 +75,6 @@ export const getSurveySummaryAction = authenticatedActionClient
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getSurveySummary(parsedInput.surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
@@ -45,13 +44,13 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
|
||||
vi.mock("@/app/lib/surveys/surveys");
|
||||
vi.mock("@/app/share/[sharingKey]/actions");
|
||||
vi.mock("@/lib/utils/hooks/useIntervalWhenFocused");
|
||||
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
|
||||
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
|
||||
}));
|
||||
@@ -68,7 +67,6 @@ const mockUseResponseFilter = vi.mocked(useResponseFilter);
|
||||
const mockGetResponseCountAction = vi.mocked(getResponseCountAction);
|
||||
const mockRevalidateSurveyIdPath = vi.mocked(revalidateSurveyIdPath);
|
||||
const mockGetFormattedFilters = vi.mocked(getFormattedFilters);
|
||||
const mockUseIntervalWhenFocused = vi.mocked(useIntervalWhenFocused);
|
||||
const MockSecondaryNavigation = vi.mocked(SecondaryNavigation);
|
||||
|
||||
const mockSurveyLanguages: TSurveyLanguage[] = [
|
||||
@@ -119,7 +117,6 @@ const mockSurvey = {
|
||||
const defaultProps = {
|
||||
environmentId: "testEnvId",
|
||||
survey: mockSurvey,
|
||||
initialTotalResponseCount: 10,
|
||||
activeId: "summary",
|
||||
};
|
||||
|
||||
@@ -166,23 +163,20 @@ describe("SurveyAnalysisNavigation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("passes correct runWhen flag to useIntervalWhenFocused based on share embed modal", () => {
|
||||
test("renders navigation correctly for sharing page", () => {
|
||||
mockUsePathname.mockReturnValue(
|
||||
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
|
||||
);
|
||||
mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id });
|
||||
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
|
||||
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
|
||||
mockGetFormattedFilters.mockReturnValue([] as any);
|
||||
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
|
||||
|
||||
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue("true") } as any);
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, false, false);
|
||||
cleanup();
|
||||
|
||||
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, true, false);
|
||||
expect(MockSecondaryNavigation).toHaveBeenCalled();
|
||||
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
|
||||
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
|
||||
});
|
||||
|
||||
test("displays correct response count string in label for various scenarios", async () => {
|
||||
@@ -195,8 +189,8 @@ describe("SurveyAnalysisNavigation", () => {
|
||||
mockGetFormattedFilters.mockReturnValue([] as any);
|
||||
|
||||
// Scenario 1: total = 10, filtered = null (initial state)
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
|
||||
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses (10)");
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses");
|
||||
cleanup();
|
||||
vi.resetAllMocks(); // Reset mocks for next case
|
||||
|
||||
@@ -212,11 +206,11 @@ describe("SurveyAnalysisNavigation", () => {
|
||||
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
|
||||
return { data: 15, error: null, success: true };
|
||||
});
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={15} />);
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
const lastCallArgs =
|
||||
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
|
||||
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
|
||||
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
|
||||
});
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
@@ -233,11 +227,11 @@ describe("SurveyAnalysisNavigation", () => {
|
||||
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
|
||||
return { data: 10, error: null, success: true };
|
||||
});
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
const lastCallArgs =
|
||||
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
|
||||
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
|
||||
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,105 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import {
|
||||
getResponseCountAction,
|
||||
revalidateSurveyIdPath,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getResponseCountBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
|
||||
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface SurveyAnalysisNavigationProps {
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
initialTotalResponseCount: number | null;
|
||||
activeId: string;
|
||||
}
|
||||
|
||||
export const SurveyAnalysisNavigation = ({
|
||||
environmentId,
|
||||
survey,
|
||||
initialTotalResponseCount,
|
||||
activeId,
|
||||
}: SurveyAnalysisNavigationProps) => {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslate();
|
||||
const params = useParams();
|
||||
const [filteredResponseCount, setFilteredResponseCount] = useState<number | null>(null);
|
||||
const [totalResponseCount, setTotalResponseCount] = useState<number | null>(initialTotalResponseCount);
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const isShareEmbedModalOpen = searchParams.get("share") === "true";
|
||||
|
||||
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
|
||||
const { selectedFilter, dateRange } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
[selectedFilter, dateRange, survey]
|
||||
);
|
||||
|
||||
const latestFiltersRef = useRef(filters);
|
||||
latestFiltersRef.current = filters;
|
||||
|
||||
const getResponseCount = () => {
|
||||
if (isSharingPage) return getResponseCountBySurveySharingKeyAction({ sharingKey });
|
||||
return getResponseCountAction({ surveyId: survey.id });
|
||||
};
|
||||
|
||||
const fetchResponseCount = async () => {
|
||||
const count = await getResponseCount();
|
||||
const responseCount = count?.data ?? 0;
|
||||
setTotalResponseCount(responseCount);
|
||||
};
|
||||
|
||||
const getFilteredResponseCount = useCallback(() => {
|
||||
if (isSharingPage)
|
||||
return getResponseCountBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: latestFiltersRef.current,
|
||||
});
|
||||
return getResponseCountAction({ surveyId: survey.id, filterCriteria: latestFiltersRef.current });
|
||||
}, [isSharingPage, sharingKey, survey.id]);
|
||||
|
||||
const fetchFilteredResponseCount = useCallback(async () => {
|
||||
const count = await getFilteredResponseCount();
|
||||
const responseCount = count?.data ?? 0;
|
||||
setFilteredResponseCount(responseCount);
|
||||
}, [getFilteredResponseCount]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilteredResponseCount();
|
||||
}, [filters, isSharingPage, sharingKey, survey.id, fetchFilteredResponseCount]);
|
||||
|
||||
useIntervalWhenFocused(
|
||||
() => {
|
||||
fetchResponseCount();
|
||||
fetchFilteredResponseCount();
|
||||
},
|
||||
10000,
|
||||
!isShareEmbedModalOpen,
|
||||
false
|
||||
);
|
||||
|
||||
const getResponseCountString = () => {
|
||||
if (totalResponseCount === null) return "";
|
||||
if (filteredResponseCount === null) return `(${totalResponseCount})`;
|
||||
|
||||
const totalCount = Math.max(totalResponseCount, filteredResponseCount);
|
||||
|
||||
if (totalCount === filteredResponseCount) return `(${totalCount})`;
|
||||
|
||||
return `(${filteredResponseCount} of ${totalCount})`;
|
||||
};
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
@@ -114,7 +39,7 @@ export const SurveyAnalysisNavigation = ({
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
label: `${t("common.responses")} ${getResponseCountString()}`,
|
||||
label: t("common.responses"),
|
||||
icon: <InboxIcon className="h-5 w-5" />,
|
||||
href: `${url}/responses?referer=true`,
|
||||
current: pathname?.includes("/responses"),
|
||||
|
||||
@@ -162,7 +162,6 @@ describe("ResponsePage", () => {
|
||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
|
||||
});
|
||||
expect(mockGetResponseCountAction).toHaveBeenCalled();
|
||||
expect(mockGetResponsesAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -179,7 +178,6 @@ describe("ResponsePage", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(mockGetResponseCountBySurveySharingKeyAction).toHaveBeenCalled();
|
||||
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -297,8 +295,7 @@ describe("ResponsePage", () => {
|
||||
rerender(<ResponsePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should fetch count and responses again due to filter change
|
||||
expect(mockGetResponseCountAction).toHaveBeenCalledTimes(2);
|
||||
// Should fetch responses again due to filter change
|
||||
expect(mockGetResponsesAction).toHaveBeenCalledTimes(2);
|
||||
// Check if it fetches with offset 0 (first page)
|
||||
expect(mockGetResponsesAction).toHaveBeenLastCalledWith(
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import {
|
||||
getResponseCountAction,
|
||||
getResponsesAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import {
|
||||
getResponseCountBySurveySharingKeyAction,
|
||||
getResponsesBySurveySharingKeyAction,
|
||||
} from "@/app/share/[sharingKey]/actions";
|
||||
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -49,7 +43,6 @@ export const ResponsePage = ({
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||
const [responses, setResponses] = useState<TResponse[]>([]);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
@@ -97,9 +90,6 @@ export const ResponsePage = ({
|
||||
|
||||
const deleteResponses = (responseIds: string[]) => {
|
||||
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
|
||||
if (responseCount) {
|
||||
setResponseCount(responseCount - responseIds.length);
|
||||
}
|
||||
};
|
||||
|
||||
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
|
||||
@@ -118,29 +108,6 @@ export const ResponsePage = ({
|
||||
}
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResponsesCount = async () => {
|
||||
let responseCount = 0;
|
||||
|
||||
if (isSharingPage) {
|
||||
const responseCountActionResponse = await getResponseCountBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
responseCount = responseCountActionResponse?.data || 0;
|
||||
} else {
|
||||
const responseCountActionResponse = await getResponseCountAction({
|
||||
surveyId,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
responseCount = responseCountActionResponse?.data || 0;
|
||||
}
|
||||
|
||||
setResponseCount(responseCount);
|
||||
};
|
||||
handleResponsesCount();
|
||||
}, [filters, isSharingPage, sharingKey, surveyId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialResponses = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,487 +1,494 @@
|
||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { ResponseTable } from "./ResponseTable";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
// Hoist variables used in mock factories
|
||||
const { DndContextMock, SortableContextMock, arrayMoveMock } = vi.hoisted(() => {
|
||||
const dndMock = vi.fn(({ children, onDragEnd }) => {
|
||||
// Store the onDragEnd prop to allow triggering it in tests
|
||||
(dndMock as any).lastOnDragEnd = onDragEnd;
|
||||
return <div data-testid="dnd-context">{children}</div>;
|
||||
});
|
||||
const sortableMock = vi.fn(({ children }) => <>{children}</>);
|
||||
const moveMock = vi.fn((array, from, to) => {
|
||||
const newArray = [...array];
|
||||
const [item] = newArray.splice(from, 1);
|
||||
newArray.splice(to, 0, item);
|
||||
return newArray;
|
||||
});
|
||||
return {
|
||||
DndContextMock: dndMock,
|
||||
SortableContextMock: sortableMock,
|
||||
arrayMoveMock: moveMock,
|
||||
};
|
||||
});
|
||||
// Mock react-hot-toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@dnd-kit/core")>();
|
||||
return {
|
||||
...actual,
|
||||
DndContext: DndContextMock,
|
||||
useSensor: vi.fn(),
|
||||
useSensors: vi.fn(),
|
||||
closestCenter: vi.fn(),
|
||||
};
|
||||
});
|
||||
// Mock components
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} data-testid="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock DndContext/SortableContext
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children }: any) => <div>{children}</div>,
|
||||
useSensor: vi.fn(),
|
||||
useSensors: vi.fn(() => "sensors"),
|
||||
closestCenter: vi.fn(),
|
||||
MouseSensor: vi.fn(),
|
||||
TouchSensor: vi.fn(),
|
||||
KeyboardSensor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/modifiers", () => ({
|
||||
restrictToHorizontalAxis: vi.fn(),
|
||||
restrictToHorizontalAxis: "restrictToHorizontalAxis",
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: SortableContextMock,
|
||||
arrayMove: arrayMoveMock,
|
||||
horizontalListSortingStrategy: vi.fn(),
|
||||
SortableContext: ({ children }: any) => <>{children}</>,
|
||||
horizontalListSortingStrategy: "horizontalListSortingStrategy",
|
||||
arrayMove: vi.fn((arr, oldIndex, newIndex) => {
|
||||
const result = [...arr];
|
||||
const [removed] = result.splice(oldIndex, 1);
|
||||
result.splice(newIndex, 0, removed);
|
||||
return result;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock AutoAnimate
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: () => [vi.fn()],
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/data-table", () => ({
|
||||
DataTableHeader: ({ header }: any) => <th data-testid={`header-${header.id}`}>{header.id}</th>,
|
||||
DataTableSettingsModal: ({ open, setOpen }: any) =>
|
||||
open ? (
|
||||
<div data-testid="settings-modal">
|
||||
Settings Modal <button onClick={() => setOpen(false)}>Close</button>
|
||||
</div>
|
||||
) : null,
|
||||
DataTableToolbar: ({
|
||||
table,
|
||||
deleteRowsAction,
|
||||
downloadRowsAction,
|
||||
setIsTableSettingsModalOpen,
|
||||
setIsExpanded,
|
||||
isExpanded,
|
||||
}: any) => (
|
||||
<div data-testid="table-toolbar">
|
||||
<button
|
||||
data-testid="toggle-expand"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-pressed={isExpanded}>
|
||||
Toggle Expand
|
||||
</button>
|
||||
<button data-testid="open-settings" onClick={() => setIsTableSettingsModalOpen(true)}>
|
||||
Open Settings
|
||||
</button>
|
||||
<button
|
||||
data-testid="delete-rows"
|
||||
onClick={() => deleteRowsAction(Object.keys(table.getState().rowSelection))}>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button
|
||||
data-testid="download-csv"
|
||||
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "csv")}>
|
||||
Download CSV
|
||||
</button>
|
||||
<button
|
||||
data-testid="download-xlsx"
|
||||
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "xlsx")}>
|
||||
Download XLSX
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock child components and hooks
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal",
|
||||
() => ({
|
||||
ResponseCardModal: vi.fn(({ open, setOpen, selectedResponseId }) =>
|
||||
ResponseCardModal: ({ open, setOpen }: any) =>
|
||||
open ? (
|
||||
<div data-testid="response-card-modal">
|
||||
Selected Response ID: {selectedResponseId}
|
||||
<button onClick={() => setOpen(false)}>Close ResponseCardModal</button>
|
||||
<div data-testid="response-modal">
|
||||
Response Modal <button onClick={() => setOpen(false)}>Close</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
) : null,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell",
|
||||
() => ({
|
||||
ResponseTableCell: vi.fn(({ cell, row, setSelectedResponseId }) => (
|
||||
<td data-testid={`cell-${cell.id}`} onClick={() => setSelectedResponseId(row.original.responseId)}>
|
||||
{typeof cell.getValue === "function" ? cell.getValue() : JSON.stringify(cell.getValue())}
|
||||
ResponseTableCell: ({ cell, row, setSelectedResponseId }: any) => (
|
||||
<td data-testid={`cell-${cell.id}-${row.id}`} onClick={() => setSelectedResponseId(row.id)}>
|
||||
Cell Content
|
||||
</td>
|
||||
)),
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
const mockGeneratedColumns = [
|
||||
{
|
||||
id: "select",
|
||||
header: () => "Select",
|
||||
cell: vi.fn(() => "SelectCell"),
|
||||
enableSorting: false,
|
||||
meta: { type: "select", questionType: null, hidden: false },
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: () => "Created At",
|
||||
cell: vi.fn(({ row }) => new Date(row.original.createdAt).toISOString()),
|
||||
enableSorting: true,
|
||||
meta: { type: "createdAt", questionType: null, hidden: false },
|
||||
},
|
||||
{
|
||||
id: "q1",
|
||||
header: () => "Question 1",
|
||||
cell: vi.fn(({ row }) => row.original.responseData.q1),
|
||||
enableSorting: true,
|
||||
meta: { type: "question", questionType: "openText", hidden: false },
|
||||
},
|
||||
];
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns",
|
||||
() => ({
|
||||
generateResponseTableColumns: vi.fn(() => mockGeneratedColumns),
|
||||
generateResponseTableColumns: vi.fn(() => [
|
||||
{ id: "select", accessorKey: "select", header: "Select" },
|
||||
{ id: "createdAt", accessorKey: "createdAt", header: "Created At" },
|
||||
{ id: "person", accessorKey: "person", header: "Person" },
|
||||
{ id: "status", accessorKey: "status", header: "Status" },
|
||||
]),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/modules/ui/components/table", () => ({
|
||||
Table: ({ children, ...props }: any) => <table {...props}>{children}</table>,
|
||||
TableBody: ({ children, ...props }: any) => <tbody {...props}>{children}</tbody>,
|
||||
TableCell: ({ children, ...props }: any) => <td {...props}>{children}</td>,
|
||||
TableHeader: ({ children, ...props }: any) => <thead {...props}>{children}</thead>,
|
||||
TableRow: ({ children, ...props }: any) => <tr {...props}>{children}</tr>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/skeleton", () => ({
|
||||
Skeleton: ({ children }: any) => <div data-testid="skeleton">{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock the actions
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
|
||||
getResponsesDownloadUrlAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({
|
||||
deleteResponseAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/data-table", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/ui/components/data-table")>();
|
||||
return {
|
||||
...actual,
|
||||
DataTableToolbar: vi.fn((props) => (
|
||||
<div data-testid="data-table-toolbar">
|
||||
<button data-testid="toolbar-expand-toggle" onClick={() => props.setIsExpanded(!props.isExpanded)}>
|
||||
Toggle Expand
|
||||
</button>
|
||||
<button data-testid="toolbar-open-settings" onClick={() => props.setIsTableSettingsModalOpen(true)}>
|
||||
Open Settings
|
||||
</button>
|
||||
<button
|
||||
data-testid="toolbar-delete-selected"
|
||||
onClick={() => props.deleteRows(props.table.getSelectedRowModel().rows.map((r) => r.id))}>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button data-testid="toolbar-delete-single" onClick={() => props.deleteAction("single_response_id")}>
|
||||
Delete Single Action
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
DataTableHeader: vi.fn(({ header }) => (
|
||||
<th
|
||||
data-testid={`header-${header.id}`}
|
||||
onClick={() => header.column.getToggleSortingHandler()?.(new MouseEvent("click"))}>
|
||||
{typeof header.column.columnDef.header === "function"
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
<button
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
data-testid={`resize-${header.id}`}>
|
||||
Resize
|
||||
</button>
|
||||
</th>
|
||||
)),
|
||||
DataTableSettingsModal: vi.fn(({ open, setOpen }) =>
|
||||
open ? (
|
||||
<div data-testid="data-table-settings-modal">
|
||||
<button onClick={() => setOpen(false)}>Close Settings</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: vi.fn(() => [vi.fn()]),
|
||||
// Mock helper functions
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: vi.fn((key) => key), // Simple pass-through mock
|
||||
}),
|
||||
}));
|
||||
|
||||
const localStorageMock = (() => {
|
||||
// Mock localStorage
|
||||
const mockLocalStorage = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value.toString();
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
store[key] = String(value);
|
||||
}),
|
||||
clear: () => {
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
},
|
||||
removeItem: vi.fn((key: string) => {
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete store[key];
|
||||
}),
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
||||
Object.defineProperty(window, "localStorage", { value: mockLocalStorage });
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
|
||||
variables: [{ id: "var1", name: "Variable 1", type: "text", value: "default" }],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: { default: "" },
|
||||
html: { default: "" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
closeOnDate: null,
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: null,
|
||||
singleUse: { enabled: false, isEncrypted: true },
|
||||
triggers: [],
|
||||
languages: [],
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
} as unknown as TSurvey;
|
||||
// Mock Tolgee
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockResponses: TResponse[] = [
|
||||
{
|
||||
id: "res1",
|
||||
surveyId: "survey1",
|
||||
finished: true,
|
||||
data: { q1: "Response 1 Text" },
|
||||
createdAt: new Date("2023-01-01T10:00:00.000Z"),
|
||||
// Define mock data for tests
|
||||
const mockProps = {
|
||||
data: [
|
||||
{ responseId: "resp1", createdAt: new Date().toISOString(), status: "completed", person: "Person 1" },
|
||||
{ responseId: "resp2", createdAt: new Date().toISOString(), status: "completed", person: "Person 2" },
|
||||
] as any[],
|
||||
survey: {
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
meta: {},
|
||||
singleUseId: null,
|
||||
ttc: {},
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
language: "en",
|
||||
contact: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
{
|
||||
id: "res2",
|
||||
surveyId: "survey1",
|
||||
finished: false,
|
||||
data: { q1: "Response 2 Text" },
|
||||
createdAt: new Date("2023-01-02T10:00:00.000Z"),
|
||||
updatedAt: new Date(),
|
||||
meta: {},
|
||||
singleUseId: null,
|
||||
ttc: {},
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
language: "en",
|
||||
contact: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockResponseTableData: TResponseTableData[] = [
|
||||
{
|
||||
responseId: "res1",
|
||||
responseData: { q1: "Response 1 Text" },
|
||||
createdAt: new Date("2023-01-01T10:00:00.000Z"),
|
||||
status: "Completed",
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
verifiedEmail: "",
|
||||
language: "en",
|
||||
person: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
{
|
||||
responseId: "res2",
|
||||
responseData: { q1: "Response 2 Text" },
|
||||
createdAt: new Date("2023-01-02T10:00:00.000Z"),
|
||||
status: "Not Completed",
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
verifiedEmail: "",
|
||||
language: "en",
|
||||
person: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
appSetupCompleted: false,
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "user@test.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: "project_manager",
|
||||
objective: "other",
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
} as unknown as TUser;
|
||||
|
||||
const mockEnvironmentTags: TTag[] = [
|
||||
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
const mockLocale: TUserLocale = "en-US";
|
||||
|
||||
const defaultProps = {
|
||||
data: mockResponseTableData,
|
||||
survey: mockSurvey,
|
||||
responses: mockResponses,
|
||||
environment: mockEnvironment,
|
||||
user: mockUser,
|
||||
environmentTags: mockEnvironmentTags,
|
||||
name: "name",
|
||||
type: "link",
|
||||
environmentId: "env-1",
|
||||
createdBy: null,
|
||||
status: "draft",
|
||||
} as TSurvey,
|
||||
responses: [
|
||||
{ id: "resp1", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: "resp2", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
|
||||
] as TResponse[],
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
environmentTags: [] as TTag[],
|
||||
isReadOnly: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasMore: true,
|
||||
hasMore: false,
|
||||
deleteResponses: vi.fn(),
|
||||
updateResponse: vi.fn(),
|
||||
isFetchingFirstPage: false,
|
||||
locale: mockLocale,
|
||||
locale: "en" as TUserLocale,
|
||||
};
|
||||
|
||||
// Setup a container for React Testing Library before each test
|
||||
beforeEach(() => {
|
||||
const container = document.createElement("div");
|
||||
container.id = "test-container";
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Reset all toast mocks before each test
|
||||
vi.mocked(toast.error).mockClear();
|
||||
vi.mocked(toast.success).mockClear();
|
||||
|
||||
// Create a mock anchor element for download tests
|
||||
const mockAnchor = {
|
||||
href: "",
|
||||
click: vi.fn(),
|
||||
style: {},
|
||||
};
|
||||
|
||||
// Update how we mock the document methods to avoid infinite recursion
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
|
||||
if (tagName === "a") return mockAnchor as any;
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
vi.spyOn(document.body, "appendChild").mockReturnValue(null as any);
|
||||
vi.spyOn(document.body, "removeChild").mockReturnValue(null as any);
|
||||
});
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
const container = document.getElementById("test-container");
|
||||
if (container) {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
cleanup();
|
||||
vi.restoreAllMocks(); // Restore mocks after each test
|
||||
});
|
||||
|
||||
describe("ResponseTable", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
cleanup(); // Keep cleanup within describe as per instructions
|
||||
});
|
||||
|
||||
test("renders skeleton when isFetchingFirstPage is true", () => {
|
||||
render(<ResponseTable {...defaultProps} isFetchingFirstPage={true} />);
|
||||
// Check for skeleton elements (implementation detail, might need adjustment)
|
||||
// For now, check that data is not directly rendered
|
||||
expect(screen.queryByText("Response 1 Text")).not.toBeInTheDocument();
|
||||
// Check if table headers are still there
|
||||
expect(screen.getByText("Created At")).toBeInTheDocument();
|
||||
test("renders the table with data", () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-toolbar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("loads settings from localStorage on mount", () => {
|
||||
const savedOrder = ["q1", "createdAt", "select"];
|
||||
const savedVisibility = { createdAt: false };
|
||||
const savedExpanded = true;
|
||||
localStorageMock.setItem(`${mockSurvey.id}-columnOrder`, JSON.stringify(savedOrder));
|
||||
localStorageMock.setItem(`${mockSurvey.id}-columnVisibility`, JSON.stringify(savedVisibility));
|
||||
localStorageMock.setItem(`${mockSurvey.id}-rowExpand`, JSON.stringify(savedExpanded));
|
||||
|
||||
render(<ResponseTable {...defaultProps} />);
|
||||
|
||||
// Check if generateResponseTableColumns was called with the loaded expanded state
|
||||
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
|
||||
mockSurvey,
|
||||
savedExpanded,
|
||||
false,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
test("saves settings to localStorage when they change", async () => {
|
||||
const { rerender } = render(<ResponseTable {...defaultProps} />);
|
||||
|
||||
// Simulate column order change via DND
|
||||
const dragEvent: DragEndEvent = {
|
||||
active: { id: "createdAt" },
|
||||
over: { id: "q1" },
|
||||
delta: { x: 0, y: 0 },
|
||||
activators: { x: 0, y: 0 },
|
||||
collisions: null,
|
||||
overNode: null,
|
||||
activeNode: null,
|
||||
} as any;
|
||||
act(() => {
|
||||
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
|
||||
});
|
||||
rerender(<ResponseTable {...defaultProps} />); // Rerender to reflect state change if necessary for useEffect
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
`${mockSurvey.id}-columnOrder`,
|
||||
JSON.stringify(["select", "q1", "createdAt"])
|
||||
);
|
||||
|
||||
// Simulate visibility change (e.g. via settings modal - direct state change for test)
|
||||
// This would typically happen via table.setColumnVisibility, which is internal to useReactTable
|
||||
// For this test, we'll assume a mechanism changes columnVisibility state
|
||||
// This part is hard to test without deeper mocking of useReactTable or exposing setColumnVisibility
|
||||
|
||||
// Simulate row expansion change
|
||||
await userEvent.click(screen.getByTestId("toolbar-expand-toggle")); // Toggle to true
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
|
||||
});
|
||||
|
||||
test("handles column drag and drop", () => {
|
||||
render(<ResponseTable {...defaultProps} />);
|
||||
const dragEvent: DragEndEvent = {
|
||||
active: { id: "createdAt" },
|
||||
over: { id: "q1" },
|
||||
delta: { x: 0, y: 0 },
|
||||
activators: { x: 0, y: 0 },
|
||||
collisions: null,
|
||||
overNode: null,
|
||||
activeNode: null,
|
||||
} as any;
|
||||
act(() => {
|
||||
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
|
||||
});
|
||||
expect(arrayMoveMock).toHaveBeenCalledWith(expect.arrayContaining(["createdAt", "q1"]), 1, 2); // Example indices
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
`${mockSurvey.id}-columnOrder`,
|
||||
JSON.stringify(["select", "q1", "createdAt"]) // Based on initial ['select', 'createdAt', 'q1']
|
||||
);
|
||||
});
|
||||
|
||||
test("interacts with DataTableToolbar: toggle expand, open settings, delete", async () => {
|
||||
const deleteResponsesMock = vi.fn();
|
||||
const deleteResponseActionMock = vi.mocked(deleteResponseAction);
|
||||
render(<ResponseTable {...defaultProps} deleteResponses={deleteResponsesMock} />);
|
||||
|
||||
// Toggle expand
|
||||
await userEvent.click(screen.getByTestId("toolbar-expand-toggle"));
|
||||
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
|
||||
mockSurvey,
|
||||
true,
|
||||
false,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
|
||||
|
||||
// Open settings
|
||||
await userEvent.click(screen.getByTestId("toolbar-open-settings"));
|
||||
expect(screen.getByTestId("data-table-settings-modal")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("Close Settings"));
|
||||
expect(screen.queryByTestId("data-table-settings-modal")).not.toBeInTheDocument();
|
||||
|
||||
// Delete selected (mock table selection)
|
||||
// This requires mocking table.getSelectedRowModel().rows
|
||||
// For simplicity, we assume the toolbar button calls deleteRows correctly
|
||||
// The mock for DataTableToolbar calls props.deleteRows with hardcoded IDs for now.
|
||||
// To test properly, we'd need to mock table.getSelectedRowModel
|
||||
// For now, let's assume the mock toolbar calls it.
|
||||
// await userEvent.click(screen.getByTestId("toolbar-delete-selected"));
|
||||
// expect(deleteResponsesMock).toHaveBeenCalledWith(["row1_id", "row2_id"]); // From mock toolbar
|
||||
|
||||
// Delete single action
|
||||
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
|
||||
expect(deleteResponseActionMock).toHaveBeenCalledWith({ responseId: "single_response_id" });
|
||||
});
|
||||
|
||||
test("calls fetchNextPage when 'Load More' is clicked", async () => {
|
||||
const fetchNextPageMock = vi.fn();
|
||||
render(<ResponseTable {...defaultProps} fetchNextPage={fetchNextPageMock} />);
|
||||
await userEvent.click(screen.getByText("common.load_more"));
|
||||
expect(fetchNextPageMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not show 'Load More' if hasMore is false", () => {
|
||||
render(<ResponseTable {...defaultProps} hasMore={false} />);
|
||||
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'No results' when data is empty", () => {
|
||||
render(<ResponseTable {...defaultProps} data={[]} responses={[]} />);
|
||||
test("renders no results message when data is empty", () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} data={[]} responses={[]} />, { container: container! });
|
||||
expect(screen.getByText("common.no_results")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("deleteResponse function calls deleteResponseAction", async () => {
|
||||
render(<ResponseTable {...defaultProps} />);
|
||||
// This function is called by DataTableToolbar's deleteAction prop
|
||||
// We can trigger it via the mocked DataTableToolbar
|
||||
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
|
||||
expect(vi.mocked(deleteResponseAction)).toHaveBeenCalledWith({ responseId: "single_response_id" });
|
||||
test("renders load more button when hasMore is true", () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
|
||||
expect(screen.getByText("common.load_more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls fetchNextPage when load more button is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
|
||||
const loadMoreButton = screen.getByText("common.load_more");
|
||||
await userEvent.click(loadMoreButton);
|
||||
expect(mockProps.fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("opens settings modal when toolbar button is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const openSettingsButton = screen.getByTestId("open-settings");
|
||||
await userEvent.click(openSettingsButton);
|
||||
expect(screen.getByTestId("settings-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("toggles expanded state when toolbar button is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const toggleExpandButton = screen.getByTestId("toggle-expand");
|
||||
|
||||
// Initially might be null, first click should set it to true
|
||||
await userEvent.click(toggleExpandButton);
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith("survey1-rowExpand", expect.any(String));
|
||||
});
|
||||
|
||||
test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: "https://download.url/file.csv",
|
||||
});
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
|
||||
surveyId: "survey1",
|
||||
format: "csv",
|
||||
filterCriteria: { responseIds: [] },
|
||||
});
|
||||
|
||||
// Check if link was created and clicked
|
||||
expect(document.createElement).toHaveBeenCalledWith("a");
|
||||
const mockLink = document.createElement("a");
|
||||
expect(mockLink.href).toBe("https://download.url/file.csv");
|
||||
expect(document.body.appendChild).toHaveBeenCalled();
|
||||
expect(mockLink.click).toHaveBeenCalled();
|
||||
expect(document.body.removeChild).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: "https://download.url/file.xlsx",
|
||||
});
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadXlsxButton = screen.getByTestId("download-xlsx");
|
||||
await userEvent.click(downloadXlsxButton);
|
||||
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
|
||||
surveyId: "survey1",
|
||||
format: "xlsx",
|
||||
filterCriteria: { responseIds: [] },
|
||||
});
|
||||
|
||||
// Check if link was created and clicked
|
||||
expect(document.createElement).toHaveBeenCalledWith("a");
|
||||
const mockLink = document.createElement("a");
|
||||
expect(mockLink.href).toBe("https://download.url/file.xlsx");
|
||||
expect(document.body.appendChild).toHaveBeenCalled();
|
||||
expect(mockLink.click).toHaveBeenCalled();
|
||||
expect(document.body.removeChild).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test response modal
|
||||
test("opens and closes response modal when a cell is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const cell = screen.getByTestId("cell-resp1_select-resp1");
|
||||
await userEvent.click(cell);
|
||||
expect(screen.getByTestId("response-modal")).toBeInTheDocument();
|
||||
// Close the modal
|
||||
const closeButton = screen.getByText("Close");
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
// Modal should be closed now
|
||||
expect(screen.queryByTestId("response-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error toast when download action returns error", async () => {
|
||||
const errorMsg = "Download failed";
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
serverError: errorMsg,
|
||||
});
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce(errorMsg);
|
||||
|
||||
// Reset document.createElement spy to fix the last test
|
||||
vi.mocked(document.createElement).mockClear();
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows default error toast when download action returns no data", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
});
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce("");
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows error toast when download action throws exception", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
|
||||
});
|
||||
});
|
||||
|
||||
test("does not create download link when download action fails", async () => {
|
||||
// Clear any previous calls to document.createElement
|
||||
vi.mocked(document.createElement).mockClear();
|
||||
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
serverError: "Download failed",
|
||||
});
|
||||
|
||||
// Create a fresh spy for createElement for this test only
|
||||
const createElementSpy = vi.spyOn(document, "createElement");
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
// Check specifically for "a" element creation, not any element
|
||||
expect(createElementSpy).not.toHaveBeenCalledWith("a");
|
||||
});
|
||||
});
|
||||
|
||||
test("loads saved settings from localStorage on mount", () => {
|
||||
const columnOrder = ["status", "person", "createdAt", "select"];
|
||||
const columnVisibility = { status: false };
|
||||
const isExpanded = true;
|
||||
|
||||
mockLocalStorage.getItem.mockImplementation((key) => {
|
||||
if (key === "survey1-columnOrder") return JSON.stringify(columnOrder);
|
||||
if (key === "survey1-columnVisibility") return JSON.stringify(columnVisibility);
|
||||
if (key === "survey1-rowExpand") return JSON.stringify(isExpanded);
|
||||
return null;
|
||||
});
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
|
||||
// Verify localStorage calls
|
||||
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnOrder");
|
||||
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnVisibility");
|
||||
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-rowExpand");
|
||||
|
||||
// The mock for generateResponseTableColumns returns this order:
|
||||
// ["select", "createdAt", "person", "status"]
|
||||
// Only visible columns should be rendered, in this order
|
||||
const expectedHeaders = ["select", "createdAt", "person"];
|
||||
const headers = screen.getAllByTestId(/^header-/);
|
||||
expect(headers).toHaveLength(expectedHeaders.length);
|
||||
expectedHeaders.forEach((columnId, index) => {
|
||||
expect(headers[index]).toHaveAttribute("data-testid", `header-${columnId}`);
|
||||
});
|
||||
|
||||
// Verify column visibility is applied
|
||||
const statusHeader = screen.queryByTestId("header-status");
|
||||
expect(statusHeader).not.toBeInTheDocument();
|
||||
|
||||
// Verify row expansion is applied
|
||||
const toggleExpandButton = screen.getByTestId("toggle-expand");
|
||||
expect(toggleExpandButton).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
|
||||
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
|
||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -25,15 +26,16 @@ import {
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface ResponseTableProps {
|
||||
data: TResponseTableData[];
|
||||
@@ -180,6 +182,32 @@ export const ResponseTable = ({
|
||||
await deleteResponseAction({ responseId });
|
||||
};
|
||||
|
||||
// Handle downloading selected responses
|
||||
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
|
||||
try {
|
||||
const downloadResponse = await getResponsesDownloadUrlAction({
|
||||
surveyId: survey.id,
|
||||
format: format,
|
||||
filterCriteria: { responseIds },
|
||||
});
|
||||
|
||||
if (downloadResponse?.data) {
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadResponse.data;
|
||||
link.download = "";
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else {
|
||||
toast.error(t("environments.surveys.responses.error_downloading_responses"));
|
||||
}
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
toast.error(t("environments.surveys.responses.error_downloading_responses"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DndContext
|
||||
@@ -193,9 +221,10 @@ export const ResponseTable = ({
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
deleteRows={deleteResponses}
|
||||
deleteRowsAction={deleteResponses}
|
||||
type="response"
|
||||
deleteAction={deleteResponse}
|
||||
downloadRowsAction={downloadSelectedRows}
|
||||
/>
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
||||
<div className="w-full overflow-x-auto">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
|
||||
@@ -61,6 +62,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
RESPONSES_PER_PAGE: 10,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getSurveyUrl", () => ({
|
||||
@@ -109,6 +111,14 @@ vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({
|
||||
environmentId: "test-env-id",
|
||||
surveyId: "test-survey-id",
|
||||
sharingKey: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockSurveyId = "test-survey-id";
|
||||
const mockUserId = "test-user-id";
|
||||
@@ -180,7 +190,7 @@ describe("ResponsesPage", () => {
|
||||
test("renders correctly with all data", async () => {
|
||||
const props = { params: mockParams };
|
||||
const jsx = await Page(props);
|
||||
render(jsx);
|
||||
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
|
||||
|
||||
await screen.findByTestId("page-content-wrapper");
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
@@ -196,7 +206,6 @@ describe("ResponsesPage", () => {
|
||||
isReadOnly: false,
|
||||
user: mockUser,
|
||||
surveyDomain: mockSurveyDomain,
|
||||
responseCount: 10,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
@@ -206,7 +215,6 @@ describe("ResponsesPage", () => {
|
||||
environmentId: mockEnvironmentId,
|
||||
survey: mockSurvey,
|
||||
activeId: "responses",
|
||||
initialTotalResponseCount: 10,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
@@ -33,7 +33,8 @@ const Page = async (props) => {
|
||||
|
||||
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||
|
||||
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const surveyDomain = getSurveyDomain();
|
||||
@@ -49,15 +50,10 @@ const Page = async (props) => {
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
surveyDomain={surveyDomain}
|
||||
responseCount={totalResponseCount}
|
||||
responseCount={responseCount}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
activeId="responses"
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
|
||||
</PageHeader>
|
||||
<ResponsePage
|
||||
environment={environment}
|
||||
|
||||
@@ -38,18 +38,10 @@ interface SummaryListProps {
|
||||
responseCount: number | null;
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
totalResponseCount: number;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const SummaryList = ({
|
||||
summary,
|
||||
environment,
|
||||
responseCount,
|
||||
survey,
|
||||
totalResponseCount,
|
||||
locale,
|
||||
}: SummaryListProps) => {
|
||||
export const SummaryList = ({ summary, environment, responseCount, survey, locale }: SummaryListProps) => {
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
const { t } = useTranslate();
|
||||
const setFilter = (
|
||||
@@ -115,11 +107,7 @@ export const SummaryList = ({
|
||||
type="response"
|
||||
environment={environment}
|
||||
noWidgetRequired={survey.type === "link"}
|
||||
emptyMessage={
|
||||
totalResponseCount === 0
|
||||
? undefined
|
||||
: t("environments.surveys.summary.no_response_matches_filter")
|
||||
}
|
||||
emptyMessage={t("environments.surveys.summary.no_responses_found")}
|
||||
/>
|
||||
) : (
|
||||
summary.map((questionSummary) => {
|
||||
|
||||
@@ -11,7 +11,11 @@ vi.mock("lucide-react", () => ({
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }) => <>{children}</>,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children, onClick }) => (
|
||||
<button tabIndex={0} onClick={onClick} style={{ display: "inline-block" }}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
TooltipContent: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
@@ -67,8 +71,10 @@ describe("SummaryMetadata", () => {
|
||||
expect(screen.getByText("25%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
const btn = screen
|
||||
.getAllByRole("button")
|
||||
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
|
||||
if (!btn) throw new Error("DropOffs toggle button not found");
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
@@ -101,8 +107,10 @@ describe("SummaryMetadata", () => {
|
||||
};
|
||||
render(<Wrapper />);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
const btn = screen
|
||||
.getAllByRole("button")
|
||||
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
|
||||
if (!btn) throw new Error("DropOffs toggle button not found");
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -100,8 +100,8 @@ export const SummaryMetadata = ({
|
||||
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<TooltipTrigger onClick={() => setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle">
|
||||
<div className="flex h-full cursor-pointer flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<span className="text-sm text-slate-600">
|
||||
{t("environments.surveys.summary.drop_offs")}
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
|
||||
@@ -117,15 +117,13 @@ export const SummaryMetadata = ({
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<button
|
||||
className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"
|
||||
onClick={() => setShowDropOffs(!showDropOffs)}>
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import {
|
||||
getResponseCountAction,
|
||||
getSurveySummaryAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
||||
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import {
|
||||
getResponseCountBySurveySharingKeyAction,
|
||||
getSummaryBySurveySharingKeyAction,
|
||||
} from "@/app/share/[sharingKey]/actions";
|
||||
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
|
||||
import { getSummaryBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { SummaryList } from "./SummaryList";
|
||||
import { SummaryMetadata } from "./SummaryMetadata";
|
||||
|
||||
const initialSurveySummary: TSurveySummary = {
|
||||
const defaultSurveySummary: TSurveySummary = {
|
||||
meta: {
|
||||
completedPercentage: 0,
|
||||
completedResponses: 0,
|
||||
@@ -44,11 +37,9 @@ interface SummaryPageProps {
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
webAppUrl: string;
|
||||
user?: TUser;
|
||||
totalResponseCount: number;
|
||||
documentsPerPage?: number;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
initialSurveySummary?: TSurveySummary;
|
||||
}
|
||||
|
||||
export const SummaryPage = ({
|
||||
@@ -56,98 +47,69 @@ export const SummaryPage = ({
|
||||
survey,
|
||||
surveyId,
|
||||
webAppUrl,
|
||||
totalResponseCount,
|
||||
locale,
|
||||
isReadOnly,
|
||||
initialSurveySummary,
|
||||
}: SummaryPageProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const isShareEmbedModalOpen = searchParams.get("share") === "true";
|
||||
|
||||
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(initialSurveySummary);
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
||||
initialSurveySummary || defaultSurveySummary
|
||||
);
|
||||
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
[selectedFilter, dateRange, survey]
|
||||
);
|
||||
// Only fetch data when filters change or when there's no initial data
|
||||
useEffect(() => {
|
||||
// If we have initial data and no filters are applied, don't fetch
|
||||
const hasNoFilters =
|
||||
(!selectedFilter ||
|
||||
Object.keys(selectedFilter).length === 0 ||
|
||||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
|
||||
(!dateRange || (!dateRange.from && !dateRange.to));
|
||||
|
||||
// Use a ref to keep the latest state and props
|
||||
const latestFiltersRef = useRef(filters);
|
||||
latestFiltersRef.current = filters;
|
||||
if (initialSurveySummary && hasNoFilters) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const getResponseCount = useCallback(() => {
|
||||
if (isSharingPage)
|
||||
return getResponseCountBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: latestFiltersRef.current,
|
||||
});
|
||||
return getResponseCountAction({
|
||||
surveyId,
|
||||
filterCriteria: latestFiltersRef.current,
|
||||
});
|
||||
}, [isSharingPage, sharingKey, surveyId]);
|
||||
|
||||
const getSummary = useCallback(() => {
|
||||
if (isSharingPage)
|
||||
return getSummaryBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: latestFiltersRef.current,
|
||||
});
|
||||
|
||||
return getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: latestFiltersRef.current,
|
||||
});
|
||||
}, [isSharingPage, sharingKey, surveyId]);
|
||||
|
||||
const handleInitialData = useCallback(
|
||||
async (isInitialLoad = false) => {
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
const fetchSummary = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const [updatedResponseCountData, updatedSurveySummary] = await Promise.all([
|
||||
getResponseCount(),
|
||||
getSummary(),
|
||||
]);
|
||||
// Recalculate filters inside the effect to ensure we have the latest values
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
let updatedSurveySummary;
|
||||
|
||||
const responseCount = updatedResponseCountData?.data ?? 0;
|
||||
const surveySummary = updatedSurveySummary?.data ?? initialSurveySummary;
|
||||
if (isSharingPage) {
|
||||
updatedSurveySummary = await getSummaryBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
} else {
|
||||
updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
}
|
||||
|
||||
setResponseCount(responseCount);
|
||||
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
||||
setSurveySummary(surveySummary);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[getResponseCount, getSummary]
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleInitialData(true);
|
||||
}, [filters, isSharingPage, sharingKey, surveyId, handleInitialData]);
|
||||
|
||||
useIntervalWhenFocused(
|
||||
() => {
|
||||
handleInitialData(false);
|
||||
},
|
||||
10000,
|
||||
!isShareEmbedModalOpen,
|
||||
false
|
||||
);
|
||||
fetchSummary();
|
||||
}, [selectedFilter, dateRange, survey.id, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
@@ -177,10 +139,9 @@ export const SummaryPage = ({
|
||||
<ScrollToTop containerId="mainContent" />
|
||||
<SummaryList
|
||||
summary={surveySummary.summary}
|
||||
responseCount={responseCount}
|
||||
responseCount={surveySummary.meta.totalResponses}
|
||||
survey={surveyMemoized}
|
||||
environment={environment}
|
||||
totalResponseCount={totalResponseCount}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
// Create a spy for refreshSingleUseId so we can override it in tests
|
||||
@@ -50,6 +51,7 @@ vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
usePathname: () => "/current",
|
||||
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
|
||||
}));
|
||||
|
||||
// Mock copySurveyLink to return a predictable string
|
||||
@@ -68,6 +70,23 @@ vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn((response) => response?.error || "Unknown error"),
|
||||
}));
|
||||
|
||||
// Mock ResponseCountProvider dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
useResponseFilter: vi.fn(() => ({ selectedFilter: "all", dateRange: {} })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({
|
||||
getResponseCountAction: vi.fn(() => Promise.resolve({ data: 5 })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||
getFormattedFilters: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getResponseCountBySurveySharingKeyAction: vi.fn(() => Promise.resolve({ data: 5 })),
|
||||
}));
|
||||
|
||||
vi.spyOn(toast, "success");
|
||||
vi.spyOn(toast, "error");
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ export const SurveyAnalysisCTA = ({
|
||||
icon: SquarePenIcon,
|
||||
tooltip: t("common.edit"),
|
||||
onClick: () => {
|
||||
responseCount && responseCount > 0
|
||||
responseCount > 0
|
||||
? setIsCautionDialogOpen(true)
|
||||
: router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`);
|
||||
},
|
||||
|
||||
@@ -758,7 +758,6 @@ describe("getSurveySummary", () => {
|
||||
expect(summary.dropOff).toBeDefined();
|
||||
expect(summary.summary).toBeDefined();
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, undefined);
|
||||
expect(prisma.response.findMany).toHaveBeenCalled(); // Check if getResponsesForSummary was effectively called
|
||||
expect(getDisplayCountBySurveyId).toHaveBeenCalled();
|
||||
});
|
||||
@@ -770,7 +769,6 @@ describe("getSurveySummary", () => {
|
||||
|
||||
test("handles filterCriteria", async () => {
|
||||
const filterCriteria: TResponseFilterCriteria = { finished: true };
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(2); // Assume 2 finished responses
|
||||
const finishedResponses = mockResponses
|
||||
.filter((r) => r.finished)
|
||||
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
|
||||
@@ -778,7 +776,6 @@ describe("getSurveySummary", () => {
|
||||
|
||||
await getSurveySummary(mockSurveyId, filterCriteria);
|
||||
|
||||
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, filterCriteria);
|
||||
expect(prisma.response.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ surveyId: mockSurveyId }), // buildWhereClause is mocked
|
||||
|
||||
@@ -5,7 +5,6 @@ import { displayCache } from "@/lib/display/cache";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { buildWhereClause } from "@/lib/response/utils";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -13,6 +12,7 @@ import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -917,22 +917,24 @@ export const getSurveySummary = reactCache(
|
||||
}
|
||||
|
||||
const batchSize = 5000;
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
|
||||
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
||||
|
||||
const pages = Math.ceil(responseCount / batchSize);
|
||||
// Use cursor-based pagination instead of count + offset to avoid expensive queries
|
||||
const responses: TSurveySummaryResponse[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
let hasMore = true;
|
||||
|
||||
// Create an array of batch fetch promises
|
||||
const batchPromises = Array.from({ length: pages }, (_, i) =>
|
||||
getResponsesForSummary(surveyId, batchSize, i * batchSize, filterCriteria)
|
||||
);
|
||||
while (hasMore) {
|
||||
const batch = await getResponsesForSummary(surveyId, batchSize, 0, filterCriteria, cursor);
|
||||
responses.push(...batch);
|
||||
|
||||
// Fetch all batches in parallel
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
|
||||
// Combine all batch results
|
||||
const responses = batchResults.flat();
|
||||
if (batch.length < batchSize) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
// Use the last response's ID as cursor for next batch
|
||||
cursor = batch[batch.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
|
||||
|
||||
@@ -972,7 +974,8 @@ export const getResponsesForSummary = reactCache(
|
||||
surveyId: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
filterCriteria?: TResponseFilterCriteria
|
||||
filterCriteria?: TResponseFilterCriteria,
|
||||
cursor?: string
|
||||
): Promise<TSurveySummaryResponse[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
@@ -980,18 +983,28 @@ export const getResponsesForSummary = reactCache(
|
||||
[surveyId, ZId],
|
||||
[limit, ZOptionalNumber],
|
||||
[offset, ZOptionalNumber],
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()]
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()],
|
||||
[cursor, z.string().cuid2().optional()]
|
||||
);
|
||||
|
||||
const queryLimit = limit ?? RESPONSES_PER_PAGE;
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) return [];
|
||||
try {
|
||||
const whereClause: Prisma.ResponseWhereInput = {
|
||||
surveyId,
|
||||
...buildWhereClause(survey, filterCriteria),
|
||||
};
|
||||
|
||||
// Add cursor condition for cursor-based pagination
|
||||
if (cursor) {
|
||||
whereClause.id = {
|
||||
lt: cursor, // Get responses with ID less than cursor (for desc order)
|
||||
};
|
||||
}
|
||||
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
surveyId,
|
||||
...buildWhereClause(survey, filterCriteria),
|
||||
},
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
data: true,
|
||||
@@ -1013,6 +1026,9 @@ export const getResponsesForSummary = reactCache(
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{
|
||||
id: "desc", // Secondary sort by ID for consistent pagination
|
||||
},
|
||||
],
|
||||
take: queryLimit,
|
||||
skip: offset,
|
||||
@@ -1043,7 +1059,9 @@ export const getResponsesForSummary = reactCache(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
|
||||
[
|
||||
`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}-${cursor || ""}`,
|
||||
],
|
||||
{
|
||||
tags: [responseCache.tag.bySurveyId(surveyId)],
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import SurveyPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page";
|
||||
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -38,7 +40,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
RESPONSES_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
@@ -78,6 +80,13 @@ vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary",
|
||||
() => ({
|
||||
getSurveySummary: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
@@ -100,6 +109,11 @@ vi.mock("@/tolgee/server", () => ({
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: vi.fn(),
|
||||
useParams: () => ({
|
||||
environmentId: "test-environment-id",
|
||||
surveyId: "test-survey-id",
|
||||
sharingKey: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-environment-id";
|
||||
@@ -172,6 +186,21 @@ const mockSession = {
|
||||
expires: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
|
||||
} as any;
|
||||
|
||||
const mockSurveySummary = {
|
||||
meta: {
|
||||
completedPercentage: 75,
|
||||
completedResponses: 15,
|
||||
displayCount: 20,
|
||||
dropOffPercentage: 25,
|
||||
dropOffCount: 5,
|
||||
startsPercentage: 80,
|
||||
totalResponses: 20,
|
||||
ttcAverage: 120,
|
||||
},
|
||||
dropOff: [],
|
||||
summary: [],
|
||||
};
|
||||
|
||||
describe("SurveyPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
@@ -183,6 +212,7 @@ describe("SurveyPage", () => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||
vi.mocked(getSurveyDomain).mockReturnValue("test.domain.com");
|
||||
vi.mocked(getSurveySummary).mockResolvedValue(mockSurveySummary);
|
||||
vi.mocked(notFound).mockClear();
|
||||
});
|
||||
|
||||
@@ -193,7 +223,8 @@ describe("SurveyPage", () => {
|
||||
|
||||
test("renders correctly with valid data", async () => {
|
||||
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
|
||||
render(await SurveyPage({ params }));
|
||||
const jsx = await SurveyPage({ params });
|
||||
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
|
||||
|
||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
@@ -204,7 +235,6 @@ describe("SurveyPage", () => {
|
||||
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId);
|
||||
expect(vi.mocked(getResponseCountBySurveyId)).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(vi.mocked(getSurveyDomain)).toHaveBeenCalled();
|
||||
|
||||
expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual(
|
||||
@@ -212,7 +242,6 @@ describe("SurveyPage", () => {
|
||||
environmentId: mockEnvironmentId,
|
||||
survey: mockSurvey,
|
||||
activeId: "summary",
|
||||
initialTotalResponseCount: 10,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -222,18 +251,17 @@ describe("SurveyPage", () => {
|
||||
survey: mockSurvey,
|
||||
surveyId: mockSurveyId,
|
||||
webAppUrl: WEBAPP_URL,
|
||||
user: mockUser,
|
||||
totalResponseCount: 10,
|
||||
documentsPerPage: DOCUMENTS_PER_PAGE,
|
||||
isReadOnly: false,
|
||||
locale: mockUser.locale ?? DEFAULT_LOCALE,
|
||||
initialSurveySummary: mockSurveySummary,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("calls notFound if surveyId is not present in params", async () => {
|
||||
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: undefined }) as any;
|
||||
render(await SurveyPage({ params }));
|
||||
const jsx = await SurveyPage({ params });
|
||||
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
|
||||
expect(vi.mocked(notFound)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -243,7 +271,7 @@ describe("SurveyPage", () => {
|
||||
try {
|
||||
// We need to await the component itself because it's an async component
|
||||
const SurveyPageComponent = await SurveyPage({ params });
|
||||
render(SurveyPageComponent);
|
||||
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toBe("common.survey_not_found");
|
||||
}
|
||||
@@ -256,7 +284,7 @@ describe("SurveyPage", () => {
|
||||
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
|
||||
try {
|
||||
const SurveyPageComponent = await SurveyPage({ params });
|
||||
render(SurveyPageComponent);
|
||||
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
|
||||
} catch (e: any) {
|
||||
expect(e.message).toBe("common.user_not_found");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -37,10 +37,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
// I took this out cause it's cloud only right?
|
||||
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||
|
||||
const surveyDomain = getSurveyDomain();
|
||||
|
||||
@@ -55,26 +53,19 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
surveyDomain={surveyDomain}
|
||||
responseCount={totalResponseCount}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
activeId="summary"
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
|
||||
</PageHeader>
|
||||
<SummaryPage
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={params.surveyId}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
user={user}
|
||||
totalResponseCount={totalResponseCount}
|
||||
documentsPerPage={DOCUMENTS_PER_PAGE}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
/>
|
||||
|
||||
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
|
||||
|
||||
@@ -250,6 +250,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
if (responsesDownloadUrlResponse?.data) {
|
||||
const link = document.createElement("a");
|
||||
link.href = responsesDownloadUrlResponse.data;
|
||||
link.download = "";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
POSTHOG_API_KEY: "test-posthog-api-key",
|
||||
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
|
||||
IS_FORMBRICKS_ENABLED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import EmailChangeWithoutVerificationSuccessPage from "./page";
|
||||
|
||||
vi.mock("@/modules/auth/email-change-without-verification-success/page", () => ({
|
||||
EmailChangeWithoutVerificationSuccessPage: ({ children }) => (
|
||||
<div data-testid="email-change-success-page">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("EmailChangeWithoutVerificationSuccessPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders EmailChangeWithoutVerificationSuccessPage", () => {
|
||||
const { getByTestId } = render(<EmailChangeWithoutVerificationSuccessPage />);
|
||||
expect(getByTestId("email-change-success-page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { EmailChangeWithoutVerificationSuccessPage } from "@/modules/auth/email-change-without-verification-success/page";
|
||||
|
||||
export default EmailChangeWithoutVerificationSuccessPage;
|
||||
3
apps/web/app/(auth)/verify-email-change/page.tsx
Normal file
3
apps/web/app/(auth)/verify-email-change/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { VerifyEmailChangePage } from "@/modules/auth/verify-email-change/page";
|
||||
|
||||
export default VerifyEmailChangePage;
|
||||
@@ -7,39 +7,133 @@ export const GET = async (req: NextRequest) => {
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div tw={`flex flex-col w-full h-full items-center bg-[${brandColor}]/75 rounded-xl `}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
|
||||
borderRadius: "0.75rem",
|
||||
}}>
|
||||
<div
|
||||
tw="flex flex-col w-[80%] h-[60%] bg-white rounded-xl mt-13 absolute left-12 top-3 opacity-20"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "80%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3.25rem",
|
||||
position: "absolute",
|
||||
left: "3rem",
|
||||
top: "0.75rem",
|
||||
opacity: 0.2,
|
||||
transform: "rotate(356deg)",
|
||||
}}></div>
|
||||
<div
|
||||
tw="flex flex-col w-[84%] h-[60%] bg-white rounded-xl mt-12 absolute top-5 left-13 border-2 opacity-60"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "84%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3rem",
|
||||
position: "absolute",
|
||||
top: "1.25rem",
|
||||
left: "3.25rem",
|
||||
borderWidth: "2px",
|
||||
opacity: 0.6,
|
||||
transform: "rotate(357deg)",
|
||||
}}></div>
|
||||
<div
|
||||
tw="flex flex-col w-[85%] h-[67%] items-center bg-white rounded-xl mt-8 absolute top-[2.3rem] left-14"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "85%",
|
||||
height: "67%",
|
||||
alignItems: "center",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "2rem",
|
||||
position: "absolute",
|
||||
top: "2.3rem",
|
||||
left: "3.5rem",
|
||||
transform: "rotate(360deg)",
|
||||
}}>
|
||||
<div tw="flex flex-col w-full">
|
||||
<div tw="flex flex-col md:flex-row w-full md:items-center justify-between ">
|
||||
<div tw="flex flex-col px-8">
|
||||
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15">
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingLeft: "2rem",
|
||||
paddingRight: "2rem",
|
||||
}}>
|
||||
<h2
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: "2rem",
|
||||
fontWeight: "700",
|
||||
letterSpacing: "-0.025em",
|
||||
color: "#0f172a",
|
||||
textAlign: "left",
|
||||
marginTop: "3.75rem",
|
||||
}}>
|
||||
{name}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div tw="flex justify-end mr-10 ">
|
||||
<div tw="flex rounded-2xl absolute -right-2 mt-2">
|
||||
<a tw={`rounded-xl border border-transparent bg-[${brandColor}] h-18 w-38 opacity-50`}></a>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
position: "absolute",
|
||||
right: "-0.5rem",
|
||||
marginTop: "0.5rem",
|
||||
}}>
|
||||
<div
|
||||
content=""
|
||||
style={{
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
opacity: 0.5,
|
||||
}}></div>
|
||||
</div>
|
||||
<div tw="flex rounded-2xl shadow ">
|
||||
<a
|
||||
tw={`flex items-center justify-center rounded-xl border border-transparent bg-[${brandColor}] text-2xl text-white h-18 w-38`}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
fontSize: "1.5rem",
|
||||
color: "white",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
}}>
|
||||
Begin!
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export const checkSurveyValidity = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
if (survey.type === "link" && survey.singleUse?.enabled) {
|
||||
if (!responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -59,7 +59,6 @@ describe("endpoint-validator", () => {
|
||||
|
||||
describe("isClientSideApiRoute", () => {
|
||||
test("should return true for client-side API routes", () => {
|
||||
expect(isClientSideApiRoute("/api/packages/something")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/storage")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/other")).toBe(true);
|
||||
|
||||
@@ -8,7 +8,6 @@ export const isVerifyEmailRoute = (url: string) => url === "/auth/verify-email";
|
||||
export const isForgotPasswordRoute = (url: string) => url === "/auth/forgot-password";
|
||||
|
||||
export const isClientSideApiRoute = (url: string): boolean => {
|
||||
if (url.includes("/api/packages/")) return true;
|
||||
if (url.includes("/api/v1/js/actions")) return true;
|
||||
if (url.includes("/api/v1/client/storage")) return true;
|
||||
const regex = /^\/api\/v\d+\/client\//;
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("SentryProvider", () => {
|
||||
expect(initSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dsn: sentryDsn,
|
||||
tracesSampleRate: 1,
|
||||
tracesSampleRate: 0,
|
||||
debug: false,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
@@ -81,6 +81,26 @@ describe("SentryProvider", () => {
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||
});
|
||||
|
||||
test("does not reinitialize Sentry when props change after initial render", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
const { rerender } = render(
|
||||
<SentryProvider sentryDsn={sentryDsn} isEnabled>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<SentryProvider sentryDsn="https://newDsn@o0.ingest.sentry.io/0" isEnabled={false}>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("processes beforeSend correctly", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
@@ -109,4 +129,36 @@ describe("SentryProvider", () => {
|
||||
const hintWithoutError = { originalException: undefined };
|
||||
expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent);
|
||||
});
|
||||
|
||||
test("processes beforeSend correctly when hint.originalException is not an Error object", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<SentryProvider sentryDsn={sentryDsn} isEnabled>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
const config = initSpy.mock.calls[0][0];
|
||||
expect(config).toHaveProperty("beforeSend");
|
||||
const beforeSend = config.beforeSend;
|
||||
|
||||
if (!beforeSend) {
|
||||
throw new Error("beforeSend is not defined");
|
||||
}
|
||||
|
||||
const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent;
|
||||
|
||||
const hintWithString = { originalException: "string exception" };
|
||||
expect(() => beforeSend(dummyEvent, hintWithString)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithString)).toEqual(dummyEvent);
|
||||
|
||||
const hintWithNumber = { originalException: 123 };
|
||||
expect(() => beforeSend(dummyEvent, hintWithNumber)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithNumber)).toEqual(dummyEvent);
|
||||
|
||||
const hintWithNull = { originalException: null };
|
||||
expect(() => beforeSend(dummyEvent, hintWithNull)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithNull)).toEqual(dummyEvent);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@ export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProvide
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
||||
tracesSampleRate: 0,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[
|
||||
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
@@ -46,19 +45,13 @@ const Page = async (props: ResponsesPageProps) => {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const totalResponseCount = await getResponseCountBySurveyId(surveyId);
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<PageContentWrapper className="w-full">
|
||||
<PageHeader pageTitle={survey.name}>
|
||||
<SurveyAnalysisNavigation
|
||||
survey={survey}
|
||||
environmentId={environment.id}
|
||||
activeId="responses"
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
<SurveyAnalysisNavigation survey={survey} environmentId={environment.id} activeId="responses" />
|
||||
</PageHeader>
|
||||
<ResponsePage
|
||||
environment={environment}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -47,27 +47,23 @@ const Page = async (props: SummaryPageProps) => {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const totalResponseCount = await getResponseCountBySurveyId(surveyId);
|
||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<PageContentWrapper className="w-full">
|
||||
<PageHeader pageTitle={survey.name}>
|
||||
<SurveyAnalysisNavigation
|
||||
survey={survey}
|
||||
environmentId={environment.id}
|
||||
activeId="summary"
|
||||
initialTotalResponseCount={totalResponseCount}
|
||||
/>
|
||||
<SurveyAnalysisNavigation survey={survey} environmentId={environment.id} activeId="summary" />
|
||||
</PageHeader>
|
||||
<SummaryPage
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={survey.id}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
totalResponseCount={totalResponseCount}
|
||||
isReadOnly={true}
|
||||
locale={DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const getSummaryBySurveySharingKeyAction = actionClient
|
||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await getSurveySummary(surveyId, parsedInput.filterCriteria);
|
||||
return getSurveySummary(surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGetResponseCountBySurveySharingKeyAction = z.object({
|
||||
@@ -57,7 +57,7 @@ export const getResponseCountBySurveySharingKeyAction = actionClient
|
||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await getResponseCountBySurveyId(surveyId, parsedInput.filterCriteria);
|
||||
return getResponseCountBySurveyId(surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGetSurveyFilterDataBySurveySharingKeyAction = z.object({
|
||||
|
||||
@@ -11,6 +11,8 @@ const { PHASE_PRODUCTION_BUILD } = require("next/constants");
|
||||
|
||||
// @fortedigital/nextjs-cache-handler dependencies
|
||||
const createRedisHandler = require("@fortedigital/nextjs-cache-handler/redis-strings").default;
|
||||
const createBufferStringHandler =
|
||||
require("@fortedigital/nextjs-cache-handler/buffer-string-decorator").default;
|
||||
const { Next15CacheHandler } = require("@fortedigital/nextjs-cache-handler/next-15-cache-handler");
|
||||
|
||||
// Usual onCreation from @neshca/cache-handler
|
||||
@@ -85,7 +87,7 @@ CacheHandler.onCreation(() => {
|
||||
global.cacheHandlerConfigPromise = null;
|
||||
|
||||
global.cacheHandlerConfig = {
|
||||
handlers: [redisCacheHandler],
|
||||
handlers: [createBufferStringHandler(redisCacheHandler)],
|
||||
};
|
||||
|
||||
return global.cacheHandlerConfig;
|
||||
|
||||
@@ -95,8 +95,6 @@ export const ITEMS_PER_PAGE = 30;
|
||||
export const SURVEYS_PER_PAGE = 12;
|
||||
export const RESPONSES_PER_PAGE = 25;
|
||||
export const TEXT_RESPONSES_PER_PAGE = 5;
|
||||
export const INSIGHTS_PER_PAGE = 10;
|
||||
export const DOCUMENTS_PER_PAGE = 10;
|
||||
export const MAX_RESPONSES_FOR_INSIGHT_GENERATION = 500;
|
||||
export const MAX_OTHER_OPTION_LENGTH = 250;
|
||||
|
||||
@@ -283,3 +281,5 @@ export const SENTRY_DSN = env.SENTRY_DSN;
|
||||
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
|
||||
|
||||
export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager";
|
||||
|
||||
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;
|
||||
|
||||
@@ -105,6 +105,7 @@ export const env = createEnv({
|
||||
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
|
||||
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
|
||||
SESSION_MAX_AGE: z.string().transform((val) => parseInt(val)).optional(),
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -200,5 +201,6 @@ export const env = createEnv({
|
||||
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
|
||||
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
|
||||
USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE,
|
||||
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,11 +2,13 @@ import { env } from "@/lib/env";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import {
|
||||
createEmailChangeToken,
|
||||
createEmailToken,
|
||||
createInviteToken,
|
||||
createToken,
|
||||
createTokenForLinkSurvey,
|
||||
getEmailFromEmailToken,
|
||||
verifyEmailChangeToken,
|
||||
verifyInviteToken,
|
||||
verifyToken,
|
||||
verifyTokenForLinkSurvey,
|
||||
@@ -46,16 +48,6 @@ describe("JWT Functions", () => {
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createToken(mockUser.id, mockUser.email)).toThrow("ENCRYPTION_KEY is not set");
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTokenForLinkSurvey", () => {
|
||||
@@ -65,18 +57,6 @@ describe("JWT Functions", () => {
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createTokenForLinkSurvey("test-survey-id", mockUser.email)).toThrow(
|
||||
"ENCRYPTION_KEY is not set"
|
||||
);
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEmailToken", () => {
|
||||
@@ -86,16 +66,6 @@ describe("JWT Functions", () => {
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createEmailToken(mockUser.email)).toThrow("ENCRYPTION_KEY is not set");
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", () => {
|
||||
const originalSecret = env.NEXTAUTH_SECRET;
|
||||
try {
|
||||
@@ -113,16 +83,6 @@ describe("JWT Functions", () => {
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => getEmailFromEmailToken("invalid-token")).toThrow("ENCRYPTION_KEY is not set");
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInviteToken", () => {
|
||||
@@ -132,18 +92,6 @@ describe("JWT Functions", () => {
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
});
|
||||
|
||||
test("should throw error if ENCRYPTION_KEY is not set", () => {
|
||||
const originalKey = env.ENCRYPTION_KEY;
|
||||
try {
|
||||
(env as any).ENCRYPTION_KEY = undefined;
|
||||
expect(() => createInviteToken("test-invite-id", mockUser.email)).toThrow(
|
||||
"ENCRYPTION_KEY is not set"
|
||||
);
|
||||
} finally {
|
||||
(env as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyTokenForLinkSurvey", () => {
|
||||
@@ -192,4 +140,32 @@ describe("JWT Functions", () => {
|
||||
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyEmailChangeToken", () => {
|
||||
test("should verify and decrypt valid email change token", async () => {
|
||||
const userId = "test-user-id";
|
||||
const email = "test@example.com";
|
||||
const token = createEmailChangeToken(userId, email);
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result).toEqual({ id: userId, email });
|
||||
});
|
||||
|
||||
test("should throw error if token is invalid or missing fields", async () => {
|
||||
// Create a token with missing fields
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return original id/email if decryption fails", async () => {
|
||||
// Create a token with non-encrypted id/email
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const payload = { id: "plain-id", email: "plain@example.com" };
|
||||
const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,27 +5,60 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const createToken = (userId: string, userEmail: string, options = {}): string => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
|
||||
};
|
||||
|
||||
export const createEmailToken = (email: string): string => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
|
||||
|
||||
if (!payload?.id || !payload?.email) {
|
||||
throw new Error("Token is invalid or missing required fields");
|
||||
}
|
||||
|
||||
let decryptedId: string;
|
||||
let decryptedEmail: string;
|
||||
|
||||
try {
|
||||
decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedId = payload.id;
|
||||
}
|
||||
|
||||
try {
|
||||
decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedEmail = payload.email;
|
||||
}
|
||||
|
||||
return {
|
||||
id: decryptedId,
|
||||
email: decryptedEmail,
|
||||
};
|
||||
};
|
||||
|
||||
export const createEmailChangeToken = (userId: string, email: string): string => {
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
|
||||
const payload = {
|
||||
id: encryptedUserId,
|
||||
email: encryptedEmail,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
};
|
||||
export const createEmailToken = (email: string): string => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
@@ -35,10 +68,6 @@ export const createEmailToken = (email: string): string => {
|
||||
};
|
||||
|
||||
export const getEmailFromEmailToken = (token: string): string => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
@@ -55,10 +84,6 @@ export const getEmailFromEmailToken = (token: string): string => {
|
||||
};
|
||||
|
||||
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
@@ -87,9 +112,6 @@ export const verifyTokenForLinkSurvey = (token: string, surveyId: string): strin
|
||||
};
|
||||
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
// First decode to get the ID
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
@@ -127,10 +149,6 @@ export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
|
||||
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
|
||||
try {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
@@ -98,7 +99,7 @@ export const getResponseContact = (
|
||||
if (!responsePrisma.contact) return null;
|
||||
|
||||
return {
|
||||
id: responsePrisma.contact.id as string,
|
||||
id: responsePrisma.contact.id,
|
||||
userId: responsePrisma.contact.attributes.find((attribute) => attribute.attributeKey.key === "userId")
|
||||
?.value as string,
|
||||
};
|
||||
@@ -291,7 +292,8 @@ export const getResponses = reactCache(
|
||||
surveyId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
filterCriteria?: TResponseFilterCriteria
|
||||
filterCriteria?: TResponseFilterCriteria,
|
||||
cursor?: string
|
||||
): Promise<TResponse[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
@@ -299,26 +301,39 @@ export const getResponses = reactCache(
|
||||
[surveyId, ZId],
|
||||
[limit, ZOptionalNumber],
|
||||
[offset, ZOptionalNumber],
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()]
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()],
|
||||
[cursor, z.string().cuid2().optional()]
|
||||
);
|
||||
|
||||
limit = limit ?? RESPONSES_PER_PAGE;
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) return [];
|
||||
try {
|
||||
const whereClause: Prisma.ResponseWhereInput = {
|
||||
surveyId,
|
||||
...buildWhereClause(survey, filterCriteria),
|
||||
};
|
||||
|
||||
// Add cursor condition for cursor-based pagination
|
||||
if (cursor) {
|
||||
whereClause.id = {
|
||||
lt: cursor, // Get responses with ID less than cursor (for desc order)
|
||||
};
|
||||
}
|
||||
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
surveyId,
|
||||
...buildWhereClause(survey, filterCriteria),
|
||||
},
|
||||
where: whereClause,
|
||||
select: responseSelection,
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{
|
||||
id: "desc", // Secondary sort by ID for consistent pagination
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
@@ -340,7 +355,7 @@ export const getResponses = reactCache(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getResponses-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
|
||||
[`getResponses-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}-${cursor}`],
|
||||
{
|
||||
tags: [responseCache.tag.bySurveyId(surveyId)],
|
||||
}
|
||||
@@ -360,19 +375,27 @@ export const getResponseDownloadUrl = async (
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
const environmentId = survey.environmentId as string;
|
||||
const environmentId = survey.environmentId;
|
||||
|
||||
const accessType = "private";
|
||||
const batchSize = 3000;
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
|
||||
const pages = Math.ceil(responseCount / batchSize);
|
||||
|
||||
const responsesArray = await Promise.all(
|
||||
Array.from({ length: pages }, (_, i) => {
|
||||
return getResponses(surveyId, batchSize, i * batchSize, filterCriteria);
|
||||
})
|
||||
);
|
||||
const responses = responsesArray.flat();
|
||||
// Use cursor-based pagination instead of count + offset to avoid expensive queries
|
||||
const responses: TResponse[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const batch = await getResponses(surveyId, batchSize, 0, filterCriteria, cursor);
|
||||
responses.push(...batch);
|
||||
|
||||
if (batch.length < batchSize) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
// Use the last response's ID as cursor for next batch
|
||||
cursor = batch[batch.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
const { metaDataFields, questions, hiddenFields, variables, userAttributes } = extractSurveyDetails(
|
||||
survey,
|
||||
@@ -442,8 +465,8 @@ export const getResponsesByEnvironmentId = reactCache(
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
@@ -478,8 +501,6 @@ export const updateResponse = async (
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseId, ZId], [responseInput, ZResponseUpdateInput]);
|
||||
try {
|
||||
// const currentResponse = await getResponse(responseId);
|
||||
|
||||
// use direct prisma call to avoid cache issues
|
||||
const currentResponse = await prisma.response.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -238,14 +238,14 @@ describe("Tests for getResponseDownloadUrl service", () => {
|
||||
expect(fileExtension).not.toEqual("xlsx");
|
||||
});
|
||||
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponseCountBySurveyId fails", async () => {
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponses fails", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
prisma.response.count.mockRejectedValue(errToThrow);
|
||||
prisma.response.findMany.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getResponseDownloadUrl(mockSurveyId, "csv")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,43 @@ export const calculateTtcTotal = (ttc: TResponseTtc) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const createFilterTags = (tags: TResponseFilterCriteria["tags"]) => {
|
||||
if (!tags) return [];
|
||||
|
||||
const filterTags: Record<string, any>[] = [];
|
||||
|
||||
if (tags?.applied) {
|
||||
const appliedTags = tags.applied.map((name) => ({
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
filterTags.push(appliedTags);
|
||||
}
|
||||
|
||||
if (tags?.notApplied) {
|
||||
const notAppliedTags = {
|
||||
tags: {
|
||||
every: {
|
||||
tag: {
|
||||
name: {
|
||||
notIn: tags.notApplied,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
filterTags.push(notAppliedTags);
|
||||
}
|
||||
|
||||
return filterTags.flat();
|
||||
};
|
||||
|
||||
export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilterCriteria) => {
|
||||
const whereClause: Prisma.ResponseWhereInput["AND"] = [];
|
||||
|
||||
@@ -49,39 +86,9 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
|
||||
|
||||
// For Tags
|
||||
if (filterCriteria?.tags) {
|
||||
const tags: Record<string, any>[] = [];
|
||||
|
||||
if (filterCriteria?.tags?.applied) {
|
||||
const appliedTags = filterCriteria.tags.applied.map((name) => ({
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
tags.push(appliedTags);
|
||||
}
|
||||
|
||||
if (filterCriteria?.tags?.notApplied) {
|
||||
const notAppliedTags = {
|
||||
tags: {
|
||||
every: {
|
||||
tag: {
|
||||
name: {
|
||||
notIn: filterCriteria.tags.notApplied,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
tags.push(notAppliedTags);
|
||||
}
|
||||
|
||||
const tagFilters = createFilterTags(filterCriteria.tags);
|
||||
whereClause.push({
|
||||
AND: tags.flat(),
|
||||
AND: tagFilters,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -442,6 +449,13 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
|
||||
AND: data,
|
||||
});
|
||||
}
|
||||
|
||||
// filter by explicit response IDs
|
||||
if (filterCriteria?.responseIds) {
|
||||
whereClause.push({
|
||||
id: { in: filterCriteria.responseIds },
|
||||
});
|
||||
}
|
||||
return { AND: whereClause };
|
||||
};
|
||||
|
||||
|
||||
@@ -15,12 +15,20 @@ import {
|
||||
describe("Time Utilities", () => {
|
||||
describe("convertDateString", () => {
|
||||
test("should format date string correctly", () => {
|
||||
expect(convertDateString("2024-03-20")).toBe("Mar 20, 2024");
|
||||
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
|
||||
});
|
||||
|
||||
test("should return empty string for empty input", () => {
|
||||
expect(convertDateString("")).toBe("");
|
||||
});
|
||||
|
||||
test("should return null for null input", () => {
|
||||
expect(convertDateString(null as any)).toBe(null);
|
||||
});
|
||||
|
||||
test("should handle invalid date strings", () => {
|
||||
expect(convertDateString("not-a-date")).toBe("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertDateTimeString", () => {
|
||||
@@ -73,7 +81,7 @@ describe("Time Utilities", () => {
|
||||
|
||||
describe("formatDate", () => {
|
||||
test("should format date correctly", () => {
|
||||
const date = new Date("2024-03-20");
|
||||
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
|
||||
expect(formatDate(date)).toBe("March 20, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,16 @@ import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, fr, pt, ptBR, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string) => {
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
if (dateString === null) return null;
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return "Invalid Date";
|
||||
}
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -9,18 +10,22 @@ import {
|
||||
InvalidInputError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
TooManyRequestsError,
|
||||
UnknownError,
|
||||
} from "@formbricks/types/errors";
|
||||
|
||||
export const actionClient = createSafeActionClient({
|
||||
handleServerError(e) {
|
||||
Sentry.captureException(e);
|
||||
|
||||
if (
|
||||
e instanceof ResourceNotFoundError ||
|
||||
e instanceof AuthorizationError ||
|
||||
e instanceof InvalidInputError ||
|
||||
e instanceof UnknownError ||
|
||||
e instanceof AuthenticationError ||
|
||||
e instanceof OperationNotAllowedError
|
||||
e instanceof OperationNotAllowedError ||
|
||||
e instanceof TooManyRequestsError
|
||||
) {
|
||||
return e.message;
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export const useIntervalWhenFocused = (
|
||||
callback: () => void,
|
||||
intervalDuration: number,
|
||||
isActive: boolean,
|
||||
shouldExecuteImmediately = true
|
||||
) => {
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (isActive) {
|
||||
if (shouldExecuteImmediately) {
|
||||
// Execute the callback immediately when the tab comes into focus
|
||||
callback();
|
||||
}
|
||||
|
||||
// Set the interval to execute the callback every `intervalDuration` milliseconds
|
||||
intervalRef.current = setInterval(() => {
|
||||
callback();
|
||||
}, intervalDuration);
|
||||
}
|
||||
}, [isActive, intervalDuration, callback, shouldExecuteImmediately]);
|
||||
|
||||
const handleBlur = () => {
|
||||
// Clear the interval when the tab loses focus
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Attach focus and blur event listeners
|
||||
window.addEventListener("focus", handleFocus);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
// Handle initial focus
|
||||
handleFocus();
|
||||
|
||||
// Cleanup interval and event listeners when the component unmounts or dependencies change
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, [isActive, intervalDuration, handleFocus]);
|
||||
};
|
||||
|
||||
export default useIntervalWhenFocused;
|
||||
@@ -7,6 +7,17 @@
|
||||
"continue_with_oidc": "Weiter mit {oidcDisplayName}",
|
||||
"continue_with_openid": "Login mit OpenID",
|
||||
"continue_with_saml": "Login mit SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Bitte bestätige dein Passwort, bevor du deine E-Mail-Adresse änderst",
|
||||
"email_change_success": "E-Mail erfolgreich geändert",
|
||||
"email_change_success_description": "Du hast deine E-Mail-Adresse erfolgreich geändert. Bitte logge dich mit deiner neuen E-Mail-Adresse ein.",
|
||||
"email_verification_failed": "E-Mail-Bestätigung fehlgeschlagen",
|
||||
"email_verification_loading": "E-Mail-Bestätigung läuft...",
|
||||
"email_verification_loading_description": "Wir aktualisieren Ihre E-Mail-Adresse in unserem System. Dies kann einige Sekunden dauern.",
|
||||
"invalid_or_expired_token": "E-Mail-Änderung fehlgeschlagen. Dein Token ist ungültig oder abgelaufen.",
|
||||
"new_email": "Neue E-Mail",
|
||||
"old_email": "Alte E-Mail"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "Zurück zum Login",
|
||||
"email-sent": {
|
||||
@@ -78,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Ungültige E-Mail-Adresse",
|
||||
"invalid_token": "Ungültiges Token ☹️",
|
||||
"new_email_verification_success": "Wenn die Adresse gültig ist, wurde eine Bestätigungs-E-Mail gesendet.",
|
||||
"no_email_provided": "Keine E-Mail bereitgestellt",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Bitte klicke auf den Link in der E-Mail, um dein Konto zu aktivieren.",
|
||||
"please_confirm_your_email_address": "Bitte bestätige deine E-Mail-Adresse",
|
||||
"resend_verification_email": "Bestätigungs-E-Mail erneut senden",
|
||||
"verification_email_successfully_sent": "Bestätigungs-E-Mail erfolgreich gesendet. Bitte überprüfe dein Postfach.",
|
||||
"verification_email_resent_successfully": "Bestätigungs-E-Mail gesendet! Bitte überprüfe dein Postfach.",
|
||||
"we_sent_an_email_to": "Wir haben eine E-Mail an {email} gesendet",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Hast Du keine E-Mail erhalten oder ist dein Link abgelaufen?"
|
||||
},
|
||||
@@ -451,6 +463,7 @@
|
||||
"live_survey_notification_view_more_responses": "Zeige {responseCount} weitere Antworten",
|
||||
"live_survey_notification_view_previous_responses": "Vorherige Antworten anzeigen",
|
||||
"live_survey_notification_view_response": "Antwort anzeigen",
|
||||
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
|
||||
"notification_footer_all_the_best": "Alles Gute,",
|
||||
"notification_footer_in_your_settings": "in deinen Einstellungen \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "Bitte ausstellen",
|
||||
@@ -500,6 +513,8 @@
|
||||
"verification_email_thanks": "Danke, dass Du deine E-Mail bestätigt hast!",
|
||||
"verification_email_to_fill_survey": "Um die Umfrage auszufüllen, klicke bitte auf den untenstehenden Button:",
|
||||
"verification_email_verify_email": "E-Mail bestätigen",
|
||||
"verification_new_email_subject": "E-Mail-Änderungsbestätigung",
|
||||
"verification_security_notice": "Wenn du diese E-Mail-Änderung nicht angefordert hast, ignoriere bitte diese E-Mail oder kontaktiere sofort den Support.",
|
||||
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Wähle einen 15-minütigen Termin im Kalender unseres Gründers aus.",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Lass keine Woche vergehen, ohne etwas über deine Nutzer zu lernen:",
|
||||
@@ -1136,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
|
||||
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
|
||||
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
|
||||
@@ -1667,6 +1683,7 @@
|
||||
"device": "Gerät",
|
||||
"device_info": "Geräteinfo",
|
||||
"email": "E-Mail",
|
||||
"error_downloading_responses": "Beim Herunterladen der Antworten ist ein Fehler aufgetreten",
|
||||
"first_name": "Vorname",
|
||||
"how_to_identify_users": "Wie man Benutzer identifiziert",
|
||||
"last_name": "Nachname",
|
||||
@@ -1752,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
|
||||
"make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist",
|
||||
"mobile_app": "Mobile App",
|
||||
"no_response_matches_filter": "Keine Antwort entspricht deinem Filter",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"only_completed": "Nur vollständige Antworten",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
@@ -1764,6 +1781,8 @@
|
||||
"quickstart_web_apps": "Schnellstart: Web-Apps",
|
||||
"quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:",
|
||||
"results_are_public": "Ergebnisse sind öffentlich",
|
||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
|
||||
"send_preview": "Vorschau senden",
|
||||
"send_to_panel": "An das Panel senden",
|
||||
"setup_instructions": "Einrichtung",
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"continue_with_oidc": "Continue with {oidcDisplayName}",
|
||||
"continue_with_openid": "Continue with OpenID",
|
||||
"continue_with_saml": "Continue with SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Please confirm your password before changing your email address",
|
||||
"email_change_success": "Email changed successfully",
|
||||
"email_change_success_description": "You have successfully changed your email address. Please log in with your new email address.",
|
||||
"email_verification_failed": "Email verification failed",
|
||||
"email_verification_loading": "Email verification in progress...",
|
||||
"email_verification_loading_description": "We are updating your email address in our system. This may take a few seconds.",
|
||||
"invalid_or_expired_token": "Email change failed. Your token is invalid or expired.",
|
||||
"new_email": "New Email",
|
||||
"old_email": "Old Email"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "Back to login",
|
||||
"email-sent": {
|
||||
@@ -78,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Invalid email address",
|
||||
"invalid_token": "Invalid token ☹️",
|
||||
"new_email_verification_success": "If the address is valid, a verification email has been sent.",
|
||||
"no_email_provided": "No email provided",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Please click the link in the email to activate your account.",
|
||||
"please_confirm_your_email_address": "Please confirm your email address",
|
||||
"resend_verification_email": "Resend verification email",
|
||||
"verification_email_successfully_sent": "Verification email successfully sent. Please check your inbox.",
|
||||
"verification_email_resent_successfully": "Verification email sent! Please check your inbox.",
|
||||
"we_sent_an_email_to": "We sent an email to {email}. ",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "You didn't receive an email or your link expired?"
|
||||
},
|
||||
@@ -451,6 +463,7 @@
|
||||
"live_survey_notification_view_more_responses": "View {responseCount} more Responses",
|
||||
"live_survey_notification_view_previous_responses": "View previous responses",
|
||||
"live_survey_notification_view_response": "View Response",
|
||||
"new_email_verification_text": "To verify your new email address, please click the button below:",
|
||||
"notification_footer_all_the_best": "All the best,",
|
||||
"notification_footer_in_your_settings": "in your settings \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "please turn them off",
|
||||
@@ -500,6 +513,8 @@
|
||||
"verification_email_thanks": "Thanks for validating your email!",
|
||||
"verification_email_to_fill_survey": "To fill out the survey please click on the button below:",
|
||||
"verification_email_verify_email": "Verify email",
|
||||
"verification_new_email_subject": "Email change verification",
|
||||
"verification_security_notice": "If you did not request this email change, please ignore this email or contact support immediately.",
|
||||
"verified_link_survey_email_subject": "Your survey is ready to be filled out.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Pick a 15-minute slot in our CEOs calendar",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Don't let a week pass without learning about your users:",
|
||||
@@ -971,7 +986,7 @@
|
||||
"2000_monthly_identified_users": "2000 Monthly Identified Users",
|
||||
"30000_monthly_identified_users": "30000 Monthly Identified Users",
|
||||
"3_projects": "3 Projects",
|
||||
"5000_monthly_responses": "5000 Monthly Responses",
|
||||
"5000_monthly_responses": "5,000 Monthly Responses",
|
||||
"5_projects": "5 Projects",
|
||||
"7500_monthly_identified_users": "7500 Monthly Identified Users",
|
||||
"advanced_targeting": "Advanced Targeting",
|
||||
@@ -1136,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "Disable two factor authentication",
|
||||
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||
"email_change_initiated": "Your email change request has been initiated.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
|
||||
@@ -1667,6 +1683,7 @@
|
||||
"device": "Device",
|
||||
"device_info": "Device info",
|
||||
"email": "Email",
|
||||
"error_downloading_responses": "An error occured while downloading responses",
|
||||
"first_name": "First Name",
|
||||
"how_to_identify_users": "How to identify users",
|
||||
"last_name": "Last Name",
|
||||
@@ -1752,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "Link to public results copied",
|
||||
"make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to",
|
||||
"mobile_app": "Mobile app",
|
||||
"no_response_matches_filter": "No response matches your filter",
|
||||
"no_responses_found": "No responses found",
|
||||
"only_completed": "Only completed",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
@@ -1764,6 +1781,8 @@
|
||||
"quickstart_web_apps": "Quickstart: Web apps",
|
||||
"quickstart_web_apps_description": "Please follow the Quickstart guide to get started:",
|
||||
"results_are_public": "Results are public",
|
||||
"selected_responses_csv": "Selected responses (CSV)",
|
||||
"selected_responses_excel": "Selected responses (Excel)",
|
||||
"send_preview": "Send preview",
|
||||
"send_to_panel": "Send to panel",
|
||||
"setup_instructions": "Setup instructions",
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"continue_with_oidc": "Continuer avec {oidcDisplayName}",
|
||||
"continue_with_openid": "Continuer avec OpenID",
|
||||
"continue_with_saml": "Continuer avec SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Veuillez confirmer votre mot de passe avant de changer votre adresse e-mail",
|
||||
"email_change_success": "E-mail changé avec succès",
|
||||
"email_change_success_description": "Vous avez changé votre adresse e-mail avec succès. Veuillez vous connecter avec votre nouvelle adresse e-mail.",
|
||||
"email_verification_failed": "Échec de la vérification de l'email",
|
||||
"email_verification_loading": "Vérification de l'email en cours...",
|
||||
"email_verification_loading_description": "Nous mettons à jour votre adresse email dans notre système. Cela peut prendre quelques secondes.",
|
||||
"invalid_or_expired_token": "Échec du changement d'email. Votre jeton est invalide ou expiré.",
|
||||
"new_email": "Nouvel Email",
|
||||
"old_email": "Ancien Email"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "Retour à la connexion",
|
||||
"email-sent": {
|
||||
@@ -78,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Adresse e-mail invalide",
|
||||
"invalid_token": "Jeton non valide ☹️",
|
||||
"new_email_verification_success": "Si l'adresse est valide, un email de vérification a été envoyé.",
|
||||
"no_email_provided": "Aucun e-mail fourni",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Veuillez cliquer sur le lien dans l'e-mail pour activer votre compte.",
|
||||
"please_confirm_your_email_address": "Veuillez confirmer votre adresse e-mail.",
|
||||
"resend_verification_email": "Renvoyer l'email de vérification",
|
||||
"verification_email_successfully_sent": "Email de vérification envoyé avec succès. Veuillez vérifier votre boîte de réception.",
|
||||
"verification_email_resent_successfully": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.",
|
||||
"we_sent_an_email_to": "Nous avons envoyé un email à {email}",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Vous n'avez pas reçu d'email ou votre lien a expiré ?"
|
||||
},
|
||||
@@ -451,6 +463,7 @@
|
||||
"live_survey_notification_view_more_responses": "Voir {responseCount} réponses supplémentaires",
|
||||
"live_survey_notification_view_previous_responses": "Voir les réponses précédentes",
|
||||
"live_survey_notification_view_response": "Voir la réponse",
|
||||
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
|
||||
"notification_footer_all_the_best": "Tous mes vœux,",
|
||||
"notification_footer_in_your_settings": "dans vos paramètres \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "veuillez les éteindre",
|
||||
@@ -500,6 +513,8 @@
|
||||
"verification_email_thanks": "Merci de valider votre email !",
|
||||
"verification_email_to_fill_survey": "Pour remplir le questionnaire, veuillez cliquer sur le bouton ci-dessous :",
|
||||
"verification_email_verify_email": "Vérifier l'email",
|
||||
"verification_new_email_subject": "Vérification du changement d'email",
|
||||
"verification_security_notice": "Si vous n'avez pas demandé ce changement d'email, veuillez ignorer cet email ou contacter le support immédiatement.",
|
||||
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Choisissez un créneau de 15 minutes dans le calendrier de notre PDG.",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Ne laissez pas une semaine passer sans en apprendre davantage sur vos utilisateurs :",
|
||||
@@ -971,7 +986,7 @@
|
||||
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
|
||||
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
|
||||
"3_projects": "3 Projets",
|
||||
"5000_monthly_responses": "5000 Réponses Mensuelles",
|
||||
"5000_monthly_responses": "5,000 Réponses Mensuelles",
|
||||
"5_projects": "5 Projets",
|
||||
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
|
||||
"advanced_targeting": "Ciblage Avancé",
|
||||
@@ -1136,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "Désactiver l'authentification à deux facteurs",
|
||||
"disable_two_factor_authentication_description": "Si vous devez désactiver l'authentification à deux facteurs, nous vous recommandons de la réactiver dès que possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Chaque code de sauvegarde peut être utilisé exactement une fois pour accorder l'accès sans votre authentificateur.",
|
||||
"email_change_initiated": "Votre demande de changement d'email a été initiée.",
|
||||
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
|
||||
"file_size_must_be_less_than_10mb": "La taille du fichier doit être inférieure à 10 Mo.",
|
||||
@@ -1667,6 +1683,7 @@
|
||||
"device": "Dispositif",
|
||||
"device_info": "Informations sur l'appareil",
|
||||
"email": "Email",
|
||||
"error_downloading_responses": "Une erreur s'est produite lors du téléchargement des réponses",
|
||||
"first_name": "Prénom",
|
||||
"how_to_identify_users": "Comment identifier les utilisateurs",
|
||||
"last_name": "Nom de famille",
|
||||
@@ -1752,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "Lien vers les résultats publics copié",
|
||||
"make_sure_the_survey_type_is_set_to": "Assurez-vous que le type d'enquête est défini sur",
|
||||
"mobile_app": "Application mobile",
|
||||
"no_response_matches_filter": "Aucune réponse ne correspond à votre filtre",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"only_completed": "Uniquement terminé",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
@@ -1764,6 +1781,8 @@
|
||||
"quickstart_web_apps": "Démarrage rapide : Applications web",
|
||||
"quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :",
|
||||
"results_are_public": "Les résultats sont publics.",
|
||||
"selected_responses_csv": "Réponses sélectionnées (CSV)",
|
||||
"selected_responses_excel": "Réponses sélectionnées (Excel)",
|
||||
"send_preview": "Envoyer un aperçu",
|
||||
"send_to_panel": "Envoyer au panneau",
|
||||
"setup_instructions": "Instructions d'installation",
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"continue_with_oidc": "Continuar com {oidcDisplayName}",
|
||||
"continue_with_openid": "Continuar com OpenID",
|
||||
"continue_with_saml": "Continuar com SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Por favor, confirme sua senha antes de mudar seu endereço de e-mail",
|
||||
"email_change_success": "E-mail alterado com sucesso",
|
||||
"email_change_success_description": "Você alterou seu endereço de e-mail com sucesso. Por favor, faça login com seu novo endereço de e-mail.",
|
||||
"email_verification_failed": "Falha na verificação do e-mail",
|
||||
"email_verification_loading": "Verificação de e-mail em andamento...",
|
||||
"email_verification_loading_description": "Estamos atualizando seu endereço de e-mail em nosso sistema. Isso pode levar alguns segundos.",
|
||||
"invalid_or_expired_token": "Falha na alteração do e-mail. Seu token é inválido ou expirou.",
|
||||
"new_email": "Novo Email",
|
||||
"old_email": "Email Antigo"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "Voltar para o login",
|
||||
"email-sent": {
|
||||
@@ -78,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Endereço de email inválido",
|
||||
"invalid_token": "Token inválido ☹️",
|
||||
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
|
||||
"no_email_provided": "Nenhum e-mail fornecido",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clica no link do e-mail pra ativar sua conta.",
|
||||
"please_confirm_your_email_address": "Por favor, confirme seu endereço de e-mail",
|
||||
"resend_verification_email": "Reenviar e-mail de verificação",
|
||||
"verification_email_successfully_sent": "Email de verificação enviado com sucesso. Por favor, verifique sua caixa de entrada.",
|
||||
"verification_email_resent_successfully": "E-mail de verificação enviado! Por favor, verifique sua caixa de entrada.",
|
||||
"we_sent_an_email_to": "Enviamos um email para {email}",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Você não recebeu um e-mail ou seu link expirou?"
|
||||
},
|
||||
@@ -451,6 +463,7 @@
|
||||
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
|
||||
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
|
||||
"live_survey_notification_view_response": "Ver Resposta",
|
||||
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
|
||||
"notification_footer_all_the_best": "Tudo de bom,",
|
||||
"notification_footer_in_your_settings": "nas suas configurações \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "por favor, desliga eles",
|
||||
@@ -500,6 +513,8 @@
|
||||
"verification_email_thanks": "Valeu por validar seu e-mail!",
|
||||
"verification_email_to_fill_survey": "Para preencher a pesquisa, por favor clique no botão abaixo:",
|
||||
"verification_email_verify_email": "Verificar e-mail",
|
||||
"verification_new_email_subject": "Verificação de alteração de e-mail",
|
||||
"verification_security_notice": "Se você não solicitou essa mudança de email, por favor ignore este email ou entre em contato com o suporte imediatamente.",
|
||||
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um horário de 15 minutos na agenda do nosso CEO",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe uma semana passar sem aprender sobre seus usuários:",
|
||||
@@ -971,7 +986,7 @@
|
||||
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5000 Respostas Mensais",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
|
||||
"advanced_targeting": "Mira Avançada",
|
||||
@@ -1136,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
|
||||
@@ -1667,6 +1683,7 @@
|
||||
"device": "dispositivo",
|
||||
"device_info": "Informações do dispositivo",
|
||||
"email": "Email",
|
||||
"error_downloading_responses": "Ocorreu um erro ao baixar respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar usuários",
|
||||
"last_name": "Sobrenome",
|
||||
@@ -1752,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "Link pros resultados públicos copiado",
|
||||
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como",
|
||||
"mobile_app": "app de celular",
|
||||
"no_response_matches_filter": "Nenhuma resposta corresponde ao seu filtro",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Somente concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "No geral",
|
||||
@@ -1764,6 +1781,8 @@
|
||||
"quickstart_web_apps": "Início rápido: Aplicativos web",
|
||||
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"send_preview": "Enviar prévia",
|
||||
"send_to_panel": "Enviar para o painel",
|
||||
"setup_instructions": "Instruções de configuração",
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"continue_with_oidc": "Continuar com {oidcDisplayName}",
|
||||
"continue_with_openid": "Continuar com OpenID",
|
||||
"continue_with_saml": "Continuar com SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Por favor, confirme a sua palavra-passe antes de alterar o seu endereço de email",
|
||||
"email_change_success": "Email alterado com sucesso",
|
||||
"email_change_success_description": "Alterou com sucesso o seu endereço de email. Por favor, inicie sessão com o seu novo endereço de email.",
|
||||
"email_verification_failed": "Falha na verificação do email",
|
||||
"email_verification_loading": "Verificação do email em progresso...",
|
||||
"email_verification_loading_description": "Estamos a atualizar o seu endereço de email no nosso sistema. Isto pode demorar alguns segundos.",
|
||||
"invalid_or_expired_token": "Falha na alteração do email. O seu token é inválido ou expirou.",
|
||||
"new_email": "Novo Email",
|
||||
"old_email": "Email Antigo"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "Voltar ao login",
|
||||
"email-sent": {
|
||||
@@ -78,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Endereço de email inválido",
|
||||
"invalid_token": "Token inválido ☹️",
|
||||
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
|
||||
"no_email_provided": "Nenhum email fornecido",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clique no link no email para ativar a sua conta.",
|
||||
"please_confirm_your_email_address": "Por favor, confirme o seu endereço de email",
|
||||
"resend_verification_email": "Reenviar email de verificação",
|
||||
"verification_email_successfully_sent": "Email de verificação enviado com sucesso. Por favor, verifique a sua caixa de entrada.",
|
||||
"verification_email_resent_successfully": "Email de verificação enviado! Por favor, verifique a sua caixa de entrada.",
|
||||
"we_sent_an_email_to": "Enviámos um email para {email}. ",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Não recebeu um email ou o seu link expirou?"
|
||||
},
|
||||
@@ -451,6 +463,7 @@
|
||||
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
|
||||
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
|
||||
"live_survey_notification_view_response": "Ver Resposta",
|
||||
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
|
||||
"notification_footer_all_the_best": "Tudo de bom,",
|
||||
"notification_footer_in_your_settings": "nas suas definições \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "por favor, desative-os",
|
||||
@@ -500,6 +513,8 @@
|
||||
"verification_email_thanks": "Obrigado por validar o seu email!",
|
||||
"verification_email_to_fill_survey": "Para preencher o questionário, clique no botão abaixo:",
|
||||
"verification_email_verify_email": "Verificar email",
|
||||
"verification_new_email_subject": "Verificação de alteração de email",
|
||||
"verification_security_notice": "Se não solicitou esta alteração de email, ignore este email ou contacte o suporte imediatamente.",
|
||||
"verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um intervalo de 15 minutos no calendário do nosso CEO",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe passar uma semana sem aprender sobre os seus utilizadores:",
|
||||
@@ -971,7 +986,7 @@
|
||||
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5000 Respostas Mensais",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
|
||||
"advanced_targeting": "Segmentação Avançada",
|
||||
@@ -1136,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "Desativar autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se precisar de desativar a autenticação de dois fatores, recomendamos que a reative o mais rapidamente possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do ficheiro deve ser inferior a 10MB.",
|
||||
@@ -1667,6 +1683,7 @@
|
||||
"device": "Dispositivo",
|
||||
"device_info": "Informações do dispositivo",
|
||||
"email": "Email",
|
||||
"error_downloading_responses": "Ocorreu um erro ao transferir respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar utilizadores",
|
||||
"last_name": "Apelido",
|
||||
@@ -1752,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "Link para resultados públicos copiado",
|
||||
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de inquérito está definido para",
|
||||
"mobile_app": "Aplicação móvel",
|
||||
"no_response_matches_filter": "Nenhuma resposta corresponde ao seu filtro",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Apenas concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "Geral",
|
||||
@@ -1764,6 +1781,8 @@
|
||||
"quickstart_web_apps": "Início rápido: Aplicações web",
|
||||
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"send_preview": "Enviar pré-visualização",
|
||||
"send_to_panel": "Enviar para painel",
|
||||
"setup_instructions": "Instruções de configuração",
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"continue_with_oidc": "使用 '{'oidcDisplayName'}' 繼續",
|
||||
"continue_with_openid": "使用 OpenID 繼續",
|
||||
"continue_with_saml": "使用 SAML SSO 繼續",
|
||||
"email-change": {
|
||||
"confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼",
|
||||
"email_change_success": "電子郵件已成功更改",
|
||||
"email_change_success_description": "您已成功更改電子郵件地址。請使用您的新電子郵件地址登入。",
|
||||
"email_verification_failed": "電子郵件驗證失敗",
|
||||
"email_verification_loading": "電子郵件驗證進行中...",
|
||||
"email_verification_loading_description": "我們正在系統中更新您的電子郵件地址。這可能需要幾秒鐘。",
|
||||
"invalid_or_expired_token": "電子郵件更改失敗。您的 token 無效或已過期。",
|
||||
"new_email": "新 電子郵件",
|
||||
"old_email": "舊 電子郵件"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "返回登入",
|
||||
"email-sent": {
|
||||
@@ -78,11 +89,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "無效的電子郵件地址",
|
||||
"invalid_token": "無效的權杖 ☹️",
|
||||
"new_email_verification_success": "如果地址有效,驗證電子郵件已發送。",
|
||||
"no_email_provided": "未提供電子郵件",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "請點擊電子郵件中的連結以啟用您的帳戶。",
|
||||
"please_confirm_your_email_address": "請確認您的電子郵件地址",
|
||||
"resend_verification_email": "重新發送驗證電子郵件",
|
||||
"verification_email_successfully_sent": "驗證電子郵件已成功發送。請檢查您的收件匣。",
|
||||
"verification_email_resent_successfully": "驗證電子郵件已發送!請檢查您的收件箱。",
|
||||
"we_sent_an_email_to": "我們已發送一封電子郵件至 <email>'{'email'}'</email>。",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "您沒有收到電子郵件或您的連結已過期?"
|
||||
},
|
||||
@@ -451,6 +463,7 @@
|
||||
"live_survey_notification_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
|
||||
"live_survey_notification_view_previous_responses": "檢視先前的回應",
|
||||
"live_survey_notification_view_response": "檢視回應",
|
||||
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
|
||||
"notification_footer_all_the_best": "祝您一切順利,",
|
||||
"notification_footer_in_your_settings": "在您的設定中 \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "請關閉它們",
|
||||
@@ -500,6 +513,8 @@
|
||||
"verification_email_thanks": "感謝您驗證您的電子郵件!",
|
||||
"verification_email_to_fill_survey": "若要填寫問卷,請點擊下方的按鈕:",
|
||||
"verification_email_verify_email": "驗證電子郵件",
|
||||
"verification_new_email_subject": "電子郵件更改驗證",
|
||||
"verification_security_notice": "如果您沒有要求更改此電子郵件,請忽略此電子郵件或立即聯繫支援。",
|
||||
"verified_link_survey_email_subject": "您的 survey 已準備好填寫。",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "在我們 CEO 的日曆中選擇一個 15 分鐘的時段",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "不要讓一週過去而沒有了解您的使用者:",
|
||||
@@ -1136,6 +1151,7 @@
|
||||
"disable_two_factor_authentication": "停用雙重驗證",
|
||||
"disable_two_factor_authentication_description": "如果您需要停用 2FA,我們建議您盡快重新啟用它。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每個備份碼只能使用一次,以便在沒有驗證器的情況下授予存取權限。",
|
||||
"email_change_initiated": "您的 email 更改請求已啟動。",
|
||||
"enable_two_factor_authentication": "啟用雙重驗證",
|
||||
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
|
||||
"file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。",
|
||||
@@ -1667,6 +1683,7 @@
|
||||
"device": "裝置",
|
||||
"device_info": "裝置資訊",
|
||||
"email": "電子郵件",
|
||||
"error_downloading_responses": "下載回應時發生錯誤",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何識別使用者",
|
||||
"last_name": "姓氏",
|
||||
@@ -1752,7 +1769,7 @@
|
||||
"link_to_public_results_copied": "已複製公開結果的連結",
|
||||
"make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為",
|
||||
"mobile_app": "行動應用程式",
|
||||
"no_response_matches_filter": "沒有任何回應符合您的篩選器",
|
||||
"no_responses_found": "找不到回應",
|
||||
"only_completed": "僅已完成",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
@@ -1764,6 +1781,8 @@
|
||||
"quickstart_web_apps": "快速入門:Web apps",
|
||||
"quickstart_web_apps_description": "請按照 Quickstart 指南開始:",
|
||||
"results_are_public": "結果是公開的",
|
||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||
"selected_responses_excel": "選擇的回應 (Excel)",
|
||||
"send_preview": "發送預覽",
|
||||
"send_to_panel": "發送到小組",
|
||||
"setup_instructions": "設定說明",
|
||||
|
||||
@@ -189,4 +189,30 @@ describe("ResponseNotes", () => {
|
||||
expect(updateFetchedResponses).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("pressing Enter in textarea only submits form and doesn't trigger parent button onClick", async () => {
|
||||
vi.mocked(createResponseNoteAction).mockResolvedValueOnce("createdNote" as any);
|
||||
render(
|
||||
<ResponseNotes
|
||||
user={dummyUser}
|
||||
responseId={dummyResponseId}
|
||||
notes={[]}
|
||||
isOpen={true}
|
||||
setIsOpen={setIsOpen}
|
||||
updateFetchedResponses={updateFetchedResponses}
|
||||
locale={dummyLocale}
|
||||
/>
|
||||
);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
await userEvent.type(textarea, "New note");
|
||||
await userEvent.type(textarea, "{enter}");
|
||||
await waitFor(() => {
|
||||
expect(createResponseNoteAction).toHaveBeenCalledWith({
|
||||
responseId: dummyResponseId,
|
||||
text: "New note",
|
||||
});
|
||||
expect(updateFetchedResponses).toHaveBeenCalled();
|
||||
expect(setIsOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,49 +98,56 @@ export const ResponseNotes = ({
|
||||
const unresolvedNotes = useMemo(() => notes.filter((note) => !note.isResolved), [notes]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
|
||||
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
|
||||
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
|
||||
isOpen
|
||||
? "-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
: unresolvedNotes.length
|
||||
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
|
||||
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isOpen) setIsOpen(true);
|
||||
}}>
|
||||
<>
|
||||
{!isOpen ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
|
||||
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
)}>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="group flex items-center">
|
||||
<h3 className="float-left ml-4 pb-1 text-sm text-slate-600">{t("common.note")}</h3>
|
||||
<button
|
||||
className={clsx(
|
||||
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
|
||||
unresolvedNotes.length
|
||||
? "group/hint cursor-pointer bg-white hover:-right-3"
|
||||
: "cursor-pointer bg-slate-50",
|
||||
unresolvedNotes.length
|
||||
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
|
||||
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
aria-label="Open notes"
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
style={{ outline: "none" }}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
|
||||
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
)}>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="group flex items-center">
|
||||
<h3 className="float-left ml-4 pb-1 text-sm text-slate-600">{t("common.note")}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="float-left mr-1.5">
|
||||
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex flex-1 items-center justify-end pr-3">
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-slate-400" />
|
||||
</span>
|
||||
) : (
|
||||
<div className="float-left mr-1.5">
|
||||
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex flex-1 items-center justify-end pr-3">
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-slate-400" />
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="relative flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
|
||||
"-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
)}>
|
||||
<div className="rounded-t-lg bg-amber-50 px-4 pb-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="group flex items-center">
|
||||
@@ -254,6 +261,6 @@ export const ResponseNotes = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -135,14 +135,11 @@ export const getResponses = async (
|
||||
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const query = getResponsesQuery(environmentIds, params);
|
||||
const whereClause = query.where;
|
||||
|
||||
const [responses, count] = await prisma.$transaction([
|
||||
prisma.response.findMany({
|
||||
...query,
|
||||
}),
|
||||
prisma.response.count({
|
||||
where: query.where,
|
||||
}),
|
||||
const [responses, totalCount] = await Promise.all([
|
||||
prisma.response.findMany(query),
|
||||
prisma.response.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
if (!responses) {
|
||||
@@ -152,7 +149,7 @@ export const getResponses = async (
|
||||
return ok({
|
||||
data: responses,
|
||||
meta: {
|
||||
total: count,
|
||||
total: totalCount,
|
||||
limit: params.limit,
|
||||
offset: params.skip,
|
||||
},
|
||||
|
||||
@@ -214,17 +214,18 @@ describe("Response Lib", () => {
|
||||
|
||||
describe("getResponses", () => {
|
||||
test("return responses with meta information", async () => {
|
||||
const responses = [response];
|
||||
prisma.$transaction = vi.fn().mockResolvedValue([responses, responses.length]);
|
||||
(prisma.response.findMany as any).mockResolvedValue([response]);
|
||||
(prisma.response.count as any).mockResolvedValue(1);
|
||||
|
||||
const result = await getResponses(environmentId, responseFilter);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
const result = await getResponses([environmentId], responseFilter);
|
||||
expect(prisma.response.findMany).toHaveBeenCalled();
|
||||
expect(prisma.response.count).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [response],
|
||||
meta: {
|
||||
total: responses.length,
|
||||
total: 1,
|
||||
limit: responseFilter.limit,
|
||||
offset: responseFilter.skip,
|
||||
},
|
||||
@@ -233,9 +234,10 @@ describe("Response Lib", () => {
|
||||
});
|
||||
|
||||
test("return a not_found error if responses are not found", async () => {
|
||||
prisma.$transaction = vi.fn().mockResolvedValue([null, 0]);
|
||||
(prisma.response.findMany as any).mockResolvedValue(null);
|
||||
(prisma.response.count as any).mockResolvedValue(0);
|
||||
|
||||
const result = await getResponses(environmentId, responseFilter);
|
||||
const result = await getResponses([environmentId], responseFilter);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
@@ -245,10 +247,25 @@ describe("Response Lib", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("return an internal_server_error error if prisma transaction fails", async () => {
|
||||
prisma.$transaction = vi.fn().mockRejectedValue(new Error("Internal server error"));
|
||||
test("return an internal_server_error error if prisma findMany fails", async () => {
|
||||
(prisma.response.findMany as any).mockRejectedValue(new Error("Internal server error"));
|
||||
(prisma.response.count as any).mockResolvedValue(0);
|
||||
|
||||
const result = await getResponses(environmentId, responseFilter);
|
||||
const result = await getResponses([environmentId], responseFilter);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "responses", issue: "Internal server error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return an internal_server_error error if prisma count fails", async () => {
|
||||
(prisma.response.findMany as any).mockResolvedValue([response]);
|
||||
(prisma.response.count as any).mockRejectedValue(new Error("Internal server error"));
|
||||
|
||||
const result = await getResponses([environmentId], responseFilter);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { EmailChangeWithoutVerificationSuccessPage } from "./page";
|
||||
|
||||
// Mock the necessary dependencies
|
||||
vi.mock("@/modules/auth/components/back-to-login-button", () => ({
|
||||
BackToLoginButton: () => <div data-testid="back-to-login">Back to Login</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/components/form-wrapper", () => ({
|
||||
FormWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="form-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
||||
|
||||
describe("EmailChangeWithoutVerificationSuccessPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders success page with correct translations when user is not logged in", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const page = await EmailChangeWithoutVerificationSuccessPage();
|
||||
render(page);
|
||||
|
||||
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("back-to-login")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth.email-change.email_change_success")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth.email-change.email_change_success_description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("redirects to home page when user is logged in", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({
|
||||
user: { id: "123", email: "test@example.com" },
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await EmailChangeWithoutVerificationSuccessPage();
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BackToLoginButton } from "@/modules/auth/components/back-to-login-button";
|
||||
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import type { Session } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const EmailChangeWithoutVerificationSuccessPage = async () => {
|
||||
const t = await getTranslate();
|
||||
const session: Session | null = await getServerSession(authOptions);
|
||||
|
||||
if (session) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
|
||||
<FormWrapper>
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">
|
||||
{t("auth.email-change.email_change_success")}
|
||||
</h1>
|
||||
<p className="text-center text-sm">{t("auth.email-change.email_change_success_description")}</p>
|
||||
<hr className="my-4" />
|
||||
<BackToLoginButton />
|
||||
</FormWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
FB_LOGO_URL: "https://formbricks.com/logo.png",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: "587",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { EMAIL_VERIFICATION_DISABLED, ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY } from "@/lib/constants";
|
||||
import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENCRYPTION_KEY,
|
||||
ENTERPRISE_LICENSE_KEY,
|
||||
SESSION_MAX_AGE,
|
||||
} from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
|
||||
@@ -178,7 +183,7 @@ export const authOptions: NextAuthOptions = {
|
||||
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
|
||||
],
|
||||
session: {
|
||||
maxAge: 3600,
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token }) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Response } from "node-fetch";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { createBrevoCustomer } from "./brevo";
|
||||
import { createBrevoCustomer, updateBrevoCustomer } from "./brevo";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
BREVO_API_KEY: "mock_api_key",
|
||||
@@ -42,18 +41,87 @@ describe("createBrevoCustomer", () => {
|
||||
|
||||
await createBrevoCustomer({ id: "123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error sending user to Brevo");
|
||||
});
|
||||
|
||||
test("should log the error response if fetch status is not 200", async () => {
|
||||
test("should log the error response if fetch status is not 201", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "error");
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(
|
||||
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
);
|
||||
|
||||
await createBrevoCustomer({ id: "123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error sending user to Brevo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateBrevoCustomer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return early if BREVO_API_KEY is not defined", async () => {
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
BREVO_API_KEY: undefined,
|
||||
BREVO_LIST_ID: "123",
|
||||
}));
|
||||
|
||||
const { updateBrevoCustomer } = await import("./brevo"); // Re-import to get the mocked version
|
||||
|
||||
const result = await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(validateInputs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should log an error if fetch fails", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "error");
|
||||
|
||||
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed"));
|
||||
|
||||
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error updating user in Brevo");
|
||||
});
|
||||
|
||||
test("should log the error response if fetch status is not 204", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "error");
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(
|
||||
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
);
|
||||
|
||||
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error updating user in Brevo");
|
||||
});
|
||||
|
||||
test("should successfully update a Brevo customer", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(new global.Response(null, { status: 204 }));
|
||||
|
||||
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.brevo.com/v3/contacts/user123?identifierType=ext_id",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"api-key": "mock_api_key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
attributes: { EMAIL: "test@example.com" },
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,26 @@ import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
|
||||
|
||||
type BrevoCreateContact = {
|
||||
email?: string;
|
||||
ext_id?: string;
|
||||
attributes?: Record<string, string | string[]>;
|
||||
emailBlacklisted?: boolean;
|
||||
smsBlacklisted?: boolean;
|
||||
listIds?: number[];
|
||||
updateEnabled?: boolean;
|
||||
smtpBlacklistSender?: string[];
|
||||
};
|
||||
|
||||
type BrevoUpdateContact = {
|
||||
attributes?: Record<string, string>;
|
||||
emailBlacklisted?: boolean;
|
||||
smsBlacklisted?: boolean;
|
||||
listIds?: number[];
|
||||
unlinkListIds?: number[];
|
||||
smtpBlacklistSender?: string[];
|
||||
};
|
||||
|
||||
export const createBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
|
||||
if (!BREVO_API_KEY) {
|
||||
return;
|
||||
@@ -12,7 +32,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
|
||||
validateInputs([id, ZId], [email, ZUserEmail]);
|
||||
|
||||
try {
|
||||
const requestBody: any = {
|
||||
const requestBody: BrevoCreateContact = {
|
||||
email,
|
||||
ext_id: id,
|
||||
updateEnabled: false,
|
||||
@@ -34,7 +54,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
if (res.status !== 201) {
|
||||
const errorText = await res.text();
|
||||
logger.error({ errorText }, "Error sending user to Brevo");
|
||||
}
|
||||
@@ -42,3 +62,36 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
|
||||
logger.error(error, "Error sending user to Brevo");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
|
||||
if (!BREVO_API_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateInputs([id, ZId], [email, ZUserEmail]);
|
||||
|
||||
try {
|
||||
const requestBody: BrevoUpdateContact = {
|
||||
attributes: {
|
||||
EMAIL: email,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(`https://api.brevo.com/v3/contacts/${id}?identifierType=ext_id`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"api-key": BREVO_API_KEY,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
const errorText = await res.text();
|
||||
logger.error({ errorText }, "Error updating user in Brevo");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Error updating user in Brevo");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { resendVerificationEmailAction } from "../actions";
|
||||
import { RequestVerificationEmail } from "./request-verification-email";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string, params?: { email?: string }) => {
|
||||
if (key === "auth.verification-requested.no_email_provided") {
|
||||
return "No email provided";
|
||||
}
|
||||
if (key === "auth.verification-requested.verification_email_resent_successfully") {
|
||||
return `Verification email sent! Please check your inbox.`;
|
||||
}
|
||||
if (key === "auth.verification-requested.resend_verification_email") {
|
||||
return "Resend verification email";
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
resendVerificationEmailAction: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("RequestVerificationEmail", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders resend verification email button", () => {
|
||||
render(<RequestVerificationEmail email="test@example.com" />);
|
||||
expect(screen.getByText("Resend verification email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error toast when no email is provided", async () => {
|
||||
render(<RequestVerificationEmail email={null} />);
|
||||
const button = screen.getByText("Resend verification email");
|
||||
await fireEvent.click(button);
|
||||
expect(toast.error).toHaveBeenCalledWith("No email provided");
|
||||
});
|
||||
|
||||
test("shows success toast when verification email is sent successfully", async () => {
|
||||
const mockEmail = "test@example.com";
|
||||
vi.mocked(resendVerificationEmailAction).mockResolvedValueOnce({ data: true });
|
||||
|
||||
render(<RequestVerificationEmail email={mockEmail} />);
|
||||
const button = screen.getByText("Resend verification email");
|
||||
await fireEvent.click(button);
|
||||
|
||||
expect(resendVerificationEmailAction).toHaveBeenCalledWith({ email: mockEmail });
|
||||
expect(toast.success).toHaveBeenCalledWith(`Verification email sent! Please check your inbox.`);
|
||||
});
|
||||
|
||||
test("reloads page when visibility changes to visible", () => {
|
||||
const mockReload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { reload: mockReload },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
render(<RequestVerificationEmail email="test@example.com" />);
|
||||
|
||||
// Simulate visibility change
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
|
||||
expect(mockReload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ export const RequestVerificationEmail = ({ email }: RequestVerificationEmailProp
|
||||
if (!email) return toast.error(t("auth.verification-requested.no_email_provided"));
|
||||
const response = await resendVerificationEmailAction({ email });
|
||||
if (response?.data) {
|
||||
toast.success(t("auth.verification-requested.verification_email_successfully_sent"));
|
||||
toast.success(t("auth.verification-requested.verification_email_resent_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
toast.error(errorMessage);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user