Compare commits

..

1 Commits

Author SHA1 Message Date
Matthias Nannt
a4811351be chore: improve js logging 2025-05-16 12:13:14 +02:00
216 changed files with 3878 additions and 7781 deletions

View File

@@ -1,61 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Build & Deployment Best Practices
## Build Process
### Running Builds
- Use `pnpm build` from project root for full build
- Monitor for React hooks warnings and fix them immediately
- Ensure all TypeScript errors are resolved before deployment
### Common Build Issues & Fixes
#### React Hooks Warnings
- Capture ref values in variables within useEffect cleanup
- Avoid accessing `.current` directly in cleanup functions
- Pattern for fixing ref cleanup warnings:
```typescript
useEffect(() => {
const currentRef = myRef.current;
return () => {
if (currentRef) {
currentRef.cleanup();
}
};
}, []);
```
#### Test Failures During Build
- Ensure all test mocks include required constants like `SESSION_MAX_AGE`
- Mock Next.js navigation hooks properly: `useParams`, `useRouter`, `useSearchParams`
- Remove unused imports and constants from test files
- Use literal values instead of imported constants when the constant isn't actually needed
### Test Execution
- Run `pnpm test` to execute all tests
- Use `pnpm test -- --run filename.test.tsx` for specific test files
- Fix test failures before merging code
- Ensure 100% test coverage for new components
### Performance Monitoring
- Monitor build times and optimize if necessary
- Watch for memory usage during builds
- Use proper caching strategies for faster rebuilds
### Deployment Checklist
1. All tests passing
2. Build completes without warnings
3. TypeScript compilation successful
4. No linter errors
5. Database migrations applied (if any)
6. Environment variables configured
### EKS Deployment Considerations
- Ensure latest code is deployed to all pods
- Monitor AWS RDS Performance Insights for database issues
- Verify environment-specific configurations
- Check pod health and resource usage

View File

@@ -1,41 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Database Performance & Prisma Best Practices
## Critical Performance Rules
### Response Count Queries
- **NEVER** use `skip`/`offset` with `prisma.response.count()` - this causes expensive subqueries with OFFSET
- Always use only `where` clauses for count operations: `prisma.response.count({ where: { ... } })`
- For pagination, separate count queries from data queries
- Reference: [apps/web/lib/response/service.ts](mdc:apps/web/lib/response/service.ts) line 654-686
### Prisma Query Optimization
- Use proper indexes defined in [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
- Leverage existing indexes: `@@index([surveyId, createdAt])`, `@@index([createdAt])`
- Use cursor-based pagination for large datasets instead of offset-based
- Cache frequently accessed data using React Cache and custom cache tags
### Date Range Filtering
- When filtering by `createdAt`, always use indexed queries
- Combine with `surveyId` for optimal performance: `{ surveyId, createdAt: { gte: start, lt: end } }`
- Avoid complex WHERE clauses that can't utilize indexes
### Count vs Data Separation
- Always separate count queries from data fetching queries
- Use `Promise.all()` to run count and data queries in parallel
- Example pattern from [apps/web/modules/api/v2/management/responses/lib/response.ts](mdc:apps/web/modules/api/v2/management/responses/lib/response.ts):
```typescript
const [responses, totalCount] = await Promise.all([
prisma.response.findMany(query),
prisma.response.count({ where: whereClause }),
]);
```
### Monitoring & Debugging
- Monitor AWS RDS Performance Insights for problematic queries
- Look for queries with OFFSET in count operations - these indicate performance issues
- Use proper error handling with `DatabaseError` for Prisma exceptions

View File

@@ -1,334 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Formbricks Architecture & Patterns
## Monorepo Structure
### Apps Directory
- `apps/web/` - Main Next.js web application
- `packages/` - Shared packages and utilities
### Key Directories in Web App
```
apps/web/
├── app/ # Next.js 13+ app directory
│ ├── (app)/ # Main application routes
│ ├── (auth)/ # Authentication routes
│ ├── api/ # API routes
│ └── 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

View File

@@ -1,5 +0,0 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -1,52 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# React Context & Provider Patterns
## Context Provider Best Practices
### Provider Implementation
- Use TypeScript interfaces for provider props with optional `initialCount` for testing
- Implement proper cleanup in `useEffect` to avoid React hooks warnings
- Reference: [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx)
### Cleanup Pattern for Refs
```typescript
useEffect(() => {
const currentPendingRequests = pendingRequests.current;
const currentAbortController = abortController.current;
return () => {
if (currentAbortController) {
currentAbortController.abort();
}
currentPendingRequests.clear();
};
}, []);
```
### Testing Context Providers
- Always wrap components using context in the provider during tests
- Use `initialCount` prop for predictable test scenarios
- Mock context dependencies like `useParams`, `useResponseFilter`
- Example from [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx):
```typescript
render(
<ResponseCountProvider survey={dummySurvey} initialCount={5}>
<ComponentUnderTest />
</ResponseCountProvider>
);
```
### Required Mocks for Context Testing
- Mock `next/navigation` with `useParams` returning environment and survey IDs
- Mock response filter context and actions
- Mock API actions that the provider depends on
### Context Hook Usage
- Create custom hooks like `useResponseCountContext()` for consuming context
- Provide meaningful error messages when context is used outside provider
- Use context for shared state that multiple components need to access

View File

@@ -1,5 +0,0 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -1,282 +0,0 @@
---
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"

View File

@@ -1,6 +0,0 @@
---
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)

View File

@@ -212,7 +212,4 @@ UNKEY_ROOT_KEY=
# SENTRY_AUTH_TOKEN=
# Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400
# USER_MANAGEMENT_MINIMUM_ROLE="manager"

View File

@@ -11,7 +11,9 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.20"
"eslint-plugin-react-refresh": "0.4.20",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",

View File

@@ -85,7 +85,6 @@ 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", () => ({

View File

@@ -88,7 +88,6 @@ 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");

View File

@@ -97,7 +97,6 @@ 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", () => ({

View File

@@ -34,7 +34,6 @@ 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", () => ({

View File

@@ -33,7 +33,6 @@ 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

View File

@@ -25,7 +25,6 @@ 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", () => {

View File

@@ -48,7 +48,6 @@ 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");

View File

@@ -31,7 +31,6 @@ 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

View File

@@ -24,7 +24,6 @@ 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", () => {

View File

@@ -24,7 +24,6 @@ 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", () => {

View File

@@ -24,7 +24,6 @@ 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", () => {

View File

@@ -24,7 +24,6 @@ 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", () => {

View File

@@ -24,7 +24,6 @@ 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", () => {

View File

@@ -24,7 +24,6 @@ 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", () => {

View File

@@ -40,7 +40,6 @@ 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);

View File

@@ -1,87 +1,17 @@
"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 {
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
});
import { ZUserUpdateInput } from "@formbricks/types/user";
export const updateUserAction = authenticatedActionClient
.schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
.schema(ZUserUpdateInput.pick({ name: true, locale: true }))
.action(async ({ parsedInput, ctx }) => {
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;
return await updateUser(ctx.user.id, parsedInput);
});
const ZUpdateAvatarAction = z.object({

View File

@@ -50,10 +50,11 @@ 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} emailVerificationDisabled={true} />);
render(<EditProfileDetailsForm user={mockUser} />);
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();
@@ -71,11 +72,7 @@ describe("EditProfileDetailsForm", () => {
await userEvent.click(updateButton);
await waitFor(() => {
expect(updateUserAction).toHaveBeenCalledWith({
name: "New Name",
locale: "de-DE",
email: mockUser.email,
});
expect(updateUserAction).toHaveBeenCalledWith({ name: "New Name", locale: "de-DE" });
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(
@@ -91,7 +88,7 @@ describe("EditProfileDetailsForm", () => {
const errorMessage = "Update failed";
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
render(<EditProfileDetailsForm user={mockUser} />);
const nameInput = screen.getByPlaceholderText("common.full_name");
await userEvent.clear(nameInput);
@@ -109,7 +106,7 @@ describe("EditProfileDetailsForm", () => {
});
test("update button is disabled initially and enables on change", async () => {
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
render(<EditProfileDetailsForm user={mockUser} />);
const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled();

View File

@@ -1,8 +1,6 @@
"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,
@@ -10,211 +8,129 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} 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 { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { TUser, ZUser } from "@formbricks/types/user";
import { updateUserAction } from "../actions";
// Schema & types
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
email: ZUserEmail.transform((val) => val?.trim().toLowerCase()),
});
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true });
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
export const EditProfileDetailsForm = ({
user,
emailVerificationDisabled,
}: {
user: TUser;
emailVerificationDisabled: boolean;
}) => {
const { t } = useTranslate();
const router = useRouter();
export const EditProfileDetailsForm = ({ user }: { user: TUser }) => {
const form = useForm<TEditProfileNameForm>({
defaultValues: {
name: user.name,
locale: user.locale,
email: user.email,
},
defaultValues: { name: user.name, locale: user.locale || "en" },
mode: "onChange",
resolver: zodResolver(ZEditProfileNameFormSchema),
});
const { isSubmitting, isDirty } = form.formState;
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 { t } = useTranslate();
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
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}`);
}
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}`);
}
};
return (
<>
<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>
)}
/>
<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>
)}
/>
<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>
)}
/>
{/* 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="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[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">
{appLanguages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => field.onChange(lang.code)}
className="min-h-8 cursor-pointer">
{lang.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"
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>
)}
/>
<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}
/>
</>
<Button
type="submit"
className="mt-4"
size="sm"
loading={isSubmitting}
disabled={isSubmitting || !isDirty}>
{t("common.update")}
</Button>
</form>
</FormProvider>
);
};

View File

@@ -1,132 +0,0 @@
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("");
});
});
});

View File

@@ -1,117 +0,0 @@
"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>
);
};

View File

@@ -1,146 +0,0 @@
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 },
});
});
});
});

View File

@@ -1,70 +0,0 @@
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)],
}
)()
);

View File

@@ -13,7 +13,6 @@ 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(),

View File

@@ -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 { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { 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 emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
<EditProfileDetailsForm user={user} />
</SettingsCard>
<SettingsCard
title={t("common.avatar")}

View File

@@ -29,7 +29,6 @@ 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", () => {

View File

@@ -75,6 +75,7 @@ export const getSurveySummaryAction = authenticatedActionClient
},
],
});
return getSurveySummary(parsedInput.surveyId, parsedInput.filterCriteria);
});

View File

@@ -5,6 +5,7 @@ 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";
@@ -44,13 +45,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" />),
}));
@@ -67,6 +68,7 @@ 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[] = [
@@ -117,6 +119,7 @@ const mockSurvey = {
const defaultProps = {
environmentId: "testEnvId",
survey: mockSurvey,
initialTotalResponseCount: 10,
activeId: "summary",
};
@@ -163,20 +166,23 @@ describe("SurveyAnalysisNavigation", () => {
);
});
test("renders navigation correctly for sharing page", () => {
test("passes correct runWhen flag to useIntervalWhenFocused based on share embed modal", () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
);
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id });
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();
expect(MockSecondaryNavigation).toHaveBeenCalled();
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, true, false);
});
test("displays correct response count string in label for various scenarios", async () => {
@@ -189,8 +195,8 @@ describe("SurveyAnalysisNavigation", () => {
mockGetFormattedFilters.mockReturnValue([] as any);
// Scenario 1: total = 10, filtered = null (initial state)
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses");
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses (10)");
cleanup();
vi.resetAllMocks(); // Reset mocks for next case
@@ -206,11 +212,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} />);
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={15} />);
await waitFor(() => {
const lastCallArgs =
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
});
cleanup();
vi.resetAllMocks();
@@ -227,11 +233,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} />);
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
await waitFor(() => {
const lastCallArgs =
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
});
});
});

View File

@@ -1,30 +1,105 @@
"use client";
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
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 { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { useTranslate } from "@tolgee/react";
import { InboxIcon, PresentationIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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 = [
{
@@ -39,7 +114,7 @@ export const SurveyAnalysisNavigation = ({
},
{
id: "responses",
label: t("common.responses"),
label: `${t("common.responses")} ${getResponseCountString()}`,
icon: <InboxIcon className="h-5 w-5" />,
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),

View File

@@ -162,6 +162,7 @@ describe("ResponsePage", () => {
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
});
expect(mockGetResponseCountAction).toHaveBeenCalled();
expect(mockGetResponsesAction).toHaveBeenCalled();
});
@@ -178,6 +179,7 @@ describe("ResponsePage", () => {
await waitFor(() => {
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
expect(mockGetResponseCountBySurveySharingKeyAction).toHaveBeenCalled();
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
});
@@ -295,7 +297,8 @@ describe("ResponsePage", () => {
rerender(<ResponsePage {...defaultProps} />);
await waitFor(() => {
// Should fetch responses again due to filter change
// Should fetch count and responses again due to filter change
expect(mockGetResponseCountAction).toHaveBeenCalledTimes(2);
expect(mockGetResponsesAction).toHaveBeenCalledTimes(2);
// Check if it fetches with offset 0 (first page)
expect(mockGetResponsesAction).toHaveBeenLastCalledWith(

View File

@@ -1,12 +1,18 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import {
getResponseCountAction,
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 { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import {
getResponseCountBySurveySharingKeyAction,
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";
@@ -43,6 +49,7 @@ 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);
@@ -90,6 +97,9 @@ 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) => {
@@ -108,6 +118,29 @@ 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 {

View File

@@ -1,494 +1,487 @@
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 { 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 userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUserLocale } from "@formbricks/types/user";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { ResponseTable } from "./ResponseTable";
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
success: vi.fn(),
dismiss: vi.fn(),
},
}));
// 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 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/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(),
};
});
vi.mock("@dnd-kit/modifiers", () => ({
restrictToHorizontalAxis: "restrictToHorizontalAxis",
restrictToHorizontalAxis: vi.fn(),
}));
vi.mock("@dnd-kit/sortable", () => ({
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>
),
SortableContext: SortableContextMock,
arrayMove: arrayMoveMock,
horizontalListSortingStrategy: vi.fn(),
}));
// Mock child components and hooks
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal",
() => ({
ResponseCardModal: ({ open, setOpen }: any) =>
ResponseCardModal: vi.fn(({ open, setOpen, selectedResponseId }) =>
open ? (
<div data-testid="response-modal">
Response Modal <button onClick={() => setOpen(false)}>Close</button>
<div data-testid="response-card-modal">
Selected Response ID: {selectedResponseId}
<button onClick={() => setOpen(false)}>Close ResponseCardModal</button>
</div>
) : null,
) : null
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell",
() => ({
ResponseTableCell: ({ cell, row, setSelectedResponseId }: any) => (
<td data-testid={`cell-${cell.id}-${row.id}`} onClick={() => setSelectedResponseId(row.id)}>
Cell Content
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())}
</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(() => [
{ 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" },
]),
generateResponseTableColumns: vi.fn(() => mockGeneratedColumns),
})
);
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(),
}));
// Mock helper functions
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: 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 localStorage
const mockLocalStorage = (() => {
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: vi.fn((key) => key), // Simple pass-through mock
}),
}));
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = String(value);
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value.toString();
}),
clear: vi.fn(() => {
clear: () => {
store = {};
}),
removeItem: vi.fn((key) => {
},
removeItem: vi.fn((key: string) => {
delete store[key];
}),
};
})();
Object.defineProperty(window, "localStorage", { value: mockLocalStorage });
Object.defineProperty(window, "localStorage", { value: localStorageMock });
// Mock Tolgee
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
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;
// 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(),
const mockResponses: TResponse[] = [
{
id: "res1",
surveyId: "survey1",
finished: true,
data: { q1: "Response 1 Text" },
createdAt: new Date("2023-01-01T10:00:00.000Z"),
updatedAt: new Date(),
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[],
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,
isReadOnly: false,
fetchNextPage: vi.fn(),
hasMore: false,
hasMore: true,
deleteResponses: vi.fn(),
updateResponse: vi.fn(),
isFetchingFirstPage: false,
locale: "en" as TUserLocale,
locale: mockLocale,
};
// 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(); // Keep cleanup within describe as per instructions
cleanup();
localStorageMock.clear();
vi.clearAllMocks();
});
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("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 no results message when data is empty", () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} data={[]} responses={[]} />, { container: container! });
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={[]} />);
expect(screen.getByText("common.no_results")).toBeInTheDocument();
});
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");
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" });
});
});

View File

@@ -3,7 +3,6 @@
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 {
@@ -26,16 +25,15 @@ 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, TUserLocale } from "@formbricks/types/user";
import { TUser } from "@formbricks/types/user";
import { TUserLocale } from "@formbricks/types/user";
interface ResponseTableProps {
data: TResponseTableData[];
@@ -182,32 +180,6 @@ 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
@@ -221,10 +193,9 @@ export const ResponseTable = ({
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
isExpanded={isExpanded ?? false}
table={table}
deleteRowsAction={deleteResponses}
deleteRows={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">

View File

@@ -1,4 +1,3 @@
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";
@@ -62,7 +61,6 @@ 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", () => ({
@@ -111,14 +109,6 @@ 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";
@@ -190,7 +180,7 @@ describe("ResponsesPage", () => {
test("renders correctly with all data", async () => {
const props = { params: mockParams };
const jsx = await Page(props);
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
render(jsx);
await screen.findByTestId("page-content-wrapper");
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -206,6 +196,7 @@ describe("ResponsesPage", () => {
isReadOnly: false,
user: mockUser,
surveyDomain: mockSurveyDomain,
responseCount: 10,
}),
undefined
);
@@ -215,6 +206,7 @@ describe("ResponsesPage", () => {
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "responses",
initialTotalResponseCount: 10,
}),
undefined
);

View File

@@ -33,8 +33,7 @@ const Page = async (props) => {
const tags = await getTagsByEnvironmentId(params.environmentId);
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const surveyDomain = getSurveyDomain();
@@ -50,10 +49,15 @@ const Page = async (props) => {
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
responseCount={responseCount}
responseCount={totalResponseCount}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}
activeId="responses"
initialTotalResponseCount={totalResponseCount}
/>
</PageHeader>
<ResponsePage
environment={environment}

View File

@@ -38,10 +38,18 @@ interface SummaryListProps {
responseCount: number | null;
environment: TEnvironment;
survey: TSurvey;
totalResponseCount: number;
locale: TUserLocale;
}
export const SummaryList = ({ summary, environment, responseCount, survey, locale }: SummaryListProps) => {
export const SummaryList = ({
summary,
environment,
responseCount,
survey,
totalResponseCount,
locale,
}: SummaryListProps) => {
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslate();
const setFilter = (
@@ -107,7 +115,11 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
type="response"
environment={environment}
noWidgetRequired={survey.type === "link"}
emptyMessage={t("environments.surveys.summary.no_responses_found")}
emptyMessage={
totalResponseCount === 0
? undefined
: t("environments.surveys.summary.no_response_matches_filter")
}
/>
) : (
summary.map((questionSummary) => {

View File

@@ -11,11 +11,7 @@ vi.mock("lucide-react", () => ({
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipProvider: ({ children }) => <>{children}</>,
Tooltip: ({ children }) => <>{children}</>,
TooltipTrigger: ({ children, onClick }) => (
<button tabIndex={0} onClick={onClick} style={{ display: "inline-block" }}>
{children}
</button>
),
TooltipTrigger: ({ children }) => <>{children}</>,
TooltipContent: ({ children }) => <>{children}</>,
}));
@@ -71,10 +67,8 @@ describe("SummaryMetadata", () => {
expect(screen.getByText("25%")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("1m 5.00s")).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");
const btn = screen.getByRole("button");
expect(screen.queryByTestId("down")).toBeInTheDocument();
await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument();
});
@@ -107,10 +101,8 @@ describe("SummaryMetadata", () => {
};
render(<Wrapper />);
expect(screen.getAllByText("-")).toHaveLength(1);
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");
const btn = screen.getByRole("button");
expect(screen.queryByTestId("down")).toBeInTheDocument();
await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument();
});

View File

@@ -100,8 +100,8 @@ export const SummaryMetadata = ({
<TooltipProvider delayDuration={50}>
<Tooltip>
<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">
<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">
<span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
@@ -117,13 +117,15 @@ export const SummaryMetadata = ({
)}
</span>
{!isLoading && (
<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">
<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)}>
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</span>
</button>
)}
</div>
</div>

View File

@@ -1,23 +1,30 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import {
getResponseCountAction,
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 { getSummaryBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import {
getResponseCountBySurveySharingKeyAction,
getSummaryBySurveySharingKeyAction,
} from "@/app/share/[sharingKey]/actions";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useParams, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata";
const defaultSurveySummary: TSurveySummary = {
const initialSurveySummary: TSurveySummary = {
meta: {
completedPercentage: 0,
completedResponses: 0,
@@ -37,9 +44,11 @@ interface SummaryPageProps {
survey: TSurvey;
surveyId: string;
webAppUrl: string;
user?: TUser;
totalResponseCount: number;
documentsPerPage?: number;
locale: TUserLocale;
isReadOnly: boolean;
initialSurveySummary?: TSurveySummary;
}
export const SummaryPage = ({
@@ -47,69 +56,98 @@ 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 [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary
);
const [responseCount, setResponseCount] = useState<number | null>(null);
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(initialSurveySummary);
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const [isLoading, setIsLoading] = useState(true);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
// 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));
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
[selectedFilter, dateRange, survey]
);
if (initialSurveySummary && hasNoFilters) {
setIsLoading(false);
return;
}
// Use a ref to keep the latest state and props
const latestFiltersRef = useRef(filters);
latestFiltersRef.current = filters;
const fetchSummary = async () => {
setIsLoading(true);
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);
}
try {
// Recalculate filters inside the effect to ensure we have the latest values
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
let updatedSurveySummary;
const [updatedResponseCountData, updatedSurveySummary] = await Promise.all([
getResponseCount(),
getSummary(),
]);
if (isSharingPage) {
updatedSurveySummary = await getSummaryBySurveySharingKeyAction({
sharingKey,
filterCriteria: currentFilters,
});
} else {
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
}
const responseCount = updatedResponseCountData?.data ?? 0;
const surveySummary = updatedSurveySummary?.data ?? initialSurveySummary;
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
setResponseCount(responseCount);
setSurveySummary(surveySummary);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
if (isInitialLoad) {
setIsLoading(false);
}
}
};
},
[getResponseCount, getSummary]
);
fetchSummary();
}, [selectedFilter, dateRange, survey.id, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
useEffect(() => {
handleInitialData(true);
}, [filters, isSharingPage, sharingKey, surveyId, handleInitialData]);
useIntervalWhenFocused(
() => {
handleInitialData(false);
},
10000,
!isShareEmbedModalOpen,
false
);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
@@ -139,9 +177,10 @@ export const SummaryPage = ({
<ScrollToTop containerId="mainContent" />
<SummaryList
summary={surveySummary.summary}
responseCount={surveySummary.meta.totalResponses}
responseCount={responseCount}
survey={surveyMemoized}
environment={environment}
totalResponseCount={totalResponseCount}
locale={locale}
/>
</>

View File

@@ -30,7 +30,6 @@ 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
@@ -51,7 +50,6 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
usePathname: () => "/current",
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
}));
// Mock copySurveyLink to return a predictable string
@@ -70,23 +68,6 @@ 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");

View File

@@ -171,7 +171,7 @@ export const SurveyAnalysisCTA = ({
icon: SquarePenIcon,
tooltip: t("common.edit"),
onClick: () => {
responseCount > 0
responseCount && responseCount > 0
? setIsCautionDialogOpen(true)
: router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`);
},

View File

@@ -758,6 +758,7 @@ 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();
});
@@ -769,6 +770,7 @@ 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: {} }));
@@ -776,6 +778,7 @@ 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

View File

@@ -5,6 +5,7 @@ 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";
@@ -12,7 +13,6 @@ 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,24 +917,22 @@ export const getSurveySummary = reactCache(
}
const batchSize = 5000;
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
// Use cursor-based pagination instead of count + offset to avoid expensive queries
const responses: TSurveySummaryResponse[] = [];
let cursor: string | undefined = undefined;
let hasMore = true;
const pages = Math.ceil(responseCount / batchSize);
while (hasMore) {
const batch = await getResponsesForSummary(surveyId, batchSize, 0, filterCriteria, cursor);
responses.push(...batch);
// Create an array of batch fetch promises
const batchPromises = Array.from({ length: pages }, (_, i) =>
getResponsesForSummary(surveyId, batchSize, i * batchSize, filterCriteria)
);
if (batch.length < batchSize) {
hasMore = false;
} else {
// Use the last response's ID as cursor for next batch
cursor = batch[batch.length - 1].id;
}
}
// Fetch all batches in parallel
const batchResults = await Promise.all(batchPromises);
// Combine all batch results
const responses = batchResults.flat();
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
@@ -974,8 +972,7 @@ export const getResponsesForSummary = reactCache(
surveyId: string,
limit: number,
offset: number,
filterCriteria?: TResponseFilterCriteria,
cursor?: string
filterCriteria?: TResponseFilterCriteria
): Promise<TSurveySummaryResponse[]> =>
cache(
async () => {
@@ -983,28 +980,18 @@ export const getResponsesForSummary = reactCache(
[surveyId, ZId],
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
[filterCriteria, ZResponseFilterCriteria.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: whereClause,
where: {
surveyId,
...buildWhereClause(survey, filterCriteria),
},
select: {
id: true,
data: true,
@@ -1026,9 +1013,6 @@ export const getResponsesForSummary = reactCache(
{
createdAt: "desc",
},
{
id: "desc", // Secondary sort by ID for consistent pagination
},
],
take: queryLimit,
skip: offset,
@@ -1059,9 +1043,7 @@ export const getResponsesForSummary = reactCache(
throw error;
}
},
[
`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}-${cursor || ""}`,
],
[`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
}

View File

@@ -1,9 +1,7 @@
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, WEBAPP_URL } from "@/lib/constants";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -40,7 +38,7 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
SESSION_MAX_AGE: 1000,
DOCUMENTS_PER_PAGE: 10,
}));
vi.mock(
@@ -80,13 +78,6 @@ 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(),
}));
@@ -109,11 +100,6 @@ 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";
@@ -186,21 +172,6 @@ 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({
@@ -212,7 +183,6 @@ 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();
});
@@ -223,8 +193,7 @@ describe("SurveyPage", () => {
test("renders correctly with valid data", async () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
const jsx = await SurveyPage({ params });
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
render(await SurveyPage({ params }));
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -235,6 +204,7 @@ 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(
@@ -242,6 +212,7 @@ describe("SurveyPage", () => {
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "summary",
initialTotalResponseCount: 10,
})
);
@@ -251,17 +222,18 @@ 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;
const jsx = await SurveyPage({ params });
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
render(await SurveyPage({ params }));
expect(vi.mocked(notFound)).toHaveBeenCalled();
});
@@ -271,7 +243,7 @@ describe("SurveyPage", () => {
try {
// We need to await the component itself because it's an async component
const SurveyPageComponent = await SurveyPage({ params });
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
render(SurveyPageComponent);
} catch (e: any) {
expect(e.message).toBe("common.survey_not_found");
}
@@ -284,7 +256,7 @@ describe("SurveyPage", () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
try {
const SurveyPageComponent = await SurveyPage({ params });
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
render(SurveyPageComponent);
} catch (e: any) {
expect(e.message).toBe("common.user_not_found");
}

View File

@@ -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 { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, 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,8 +37,10 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
throw new Error(t("common.user_not_found"));
}
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
// I took this out cause it's cloud only right?
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const surveyDomain = getSurveyDomain();
@@ -53,19 +55,26 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
responseCount={totalResponseCount}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}
activeId="summary"
initialTotalResponseCount={totalResponseCount}
/>
</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>

View File

@@ -250,7 +250,6 @@ 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);

View File

@@ -38,7 +38,6 @@ 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", () => ({

View File

@@ -1,20 +0,0 @@
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();
});
});

View File

@@ -1,3 +0,0 @@
import { EmailChangeWithoutVerificationSuccessPage } from "@/modules/auth/email-change-without-verification-success/page";
export default EmailChangeWithoutVerificationSuccessPage;

View File

@@ -1,3 +0,0 @@
import { VerifyEmailChangePage } from "@/modules/auth/verify-email-change/page";
export default VerifyEmailChangePage;

View File

@@ -7,133 +7,39 @@ export const GET = async (req: NextRequest) => {
return new ImageResponse(
(
<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-full h-full items-center bg-[${brandColor}]/75 rounded-xl `}>
<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 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",
}}>
<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">
{name}
</h2>
</div>
</div>
<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 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>
<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",
}}>
<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`}>
Begin!
</div>
</a>
</div>
</div>
</div>

View File

@@ -26,7 +26,7 @@ export const checkSurveyValidity = async (
);
}
if (survey.type === "link" && survey.singleUse?.enabled) {
if (survey.singleUse?.enabled) {
if (!responseInput.singleUseId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,

View File

@@ -38,7 +38,7 @@ describe("SentryProvider", () => {
expect(initSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: sentryDsn,
tracesSampleRate: 0,
tracesSampleRate: 1,
debug: false,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
@@ -81,26 +81,6 @@ 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);
@@ -129,36 +109,4 @@ 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);
});
});

View File

@@ -15,8 +15,8 @@ export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProvide
Sentry.init({
dsn: sentryDsn,
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 0,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,

View File

@@ -3,6 +3,7 @@ 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";
@@ -45,13 +46,19 @@ 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" />
<SurveyAnalysisNavigation
survey={survey}
environmentId={environment.id}
activeId="responses"
initialTotalResponseCount={totalResponseCount}
/>
</PageHeader>
<ResponsePage
environment={environment}

View File

@@ -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,23 +47,27 @@ const Page = async (props: SummaryPageProps) => {
throw new Error(t("common.project_not_found"));
}
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
const totalResponseCount = await getResponseCountBySurveyId(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" />
<SurveyAnalysisNavigation
survey={survey}
environmentId={environment.id}
activeId="summary"
initialTotalResponseCount={totalResponseCount}
/>
</PageHeader>
<SummaryPage
environment={environment}
survey={survey}
surveyId={survey.id}
webAppUrl={WEBAPP_URL}
totalResponseCount={totalResponseCount}
isReadOnly={true}
locale={DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
/>
</PageContentWrapper>
</div>

View File

@@ -43,7 +43,7 @@ export const getSummaryBySurveySharingKeyAction = actionClient
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
if (!surveyId) throw new AuthorizationError("Not authorized");
return getSurveySummary(surveyId, parsedInput.filterCriteria);
return await 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 getResponseCountBySurveyId(surveyId, parsedInput.filterCriteria);
return await getResponseCountBySurveyId(surveyId, parsedInput.filterCriteria);
});
const ZGetSurveyFilterDataBySurveySharingKeyAction = z.object({

View File

@@ -95,6 +95,8 @@ 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;
@@ -281,5 +283,3 @@ 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;

View File

@@ -105,7 +105,6 @@ 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(),
},
/*
@@ -201,6 +200,5 @@ 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,
},
});

View File

@@ -2,13 +2,11 @@ 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,
@@ -48,6 +46,16 @@ 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", () => {
@@ -57,6 +65,18 @@ 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", () => {
@@ -66,6 +86,16 @@ 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 {
@@ -83,6 +113,16 @@ 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", () => {
@@ -92,6 +132,18 @@ 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", () => {
@@ -140,32 +192,4 @@ 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);
});
});
});

View File

@@ -5,60 +5,27 @@ 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 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.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
if (!env.NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
@@ -68,6 +35,10 @@ 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");
}
@@ -84,6 +55,10 @@ 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");
}
@@ -112,6 +87,9 @@ 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;
@@ -149,6 +127,10 @@ 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;

View File

@@ -2,7 +2,6 @@ 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";
@@ -99,7 +98,7 @@ export const getResponseContact = (
if (!responsePrisma.contact) return null;
return {
id: responsePrisma.contact.id,
id: responsePrisma.contact.id as string,
userId: responsePrisma.contact.attributes.find((attribute) => attribute.attributeKey.key === "userId")
?.value as string,
};
@@ -292,8 +291,7 @@ export const getResponses = reactCache(
surveyId: string,
limit?: number,
offset?: number,
filterCriteria?: TResponseFilterCriteria,
cursor?: string
filterCriteria?: TResponseFilterCriteria
): Promise<TResponse[]> =>
cache(
async () => {
@@ -301,39 +299,26 @@ export const getResponses = reactCache(
[surveyId, ZId],
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
[filterCriteria, ZResponseFilterCriteria.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: whereClause,
where: {
surveyId,
...buildWhereClause(survey, filterCriteria),
},
select: responseSelection,
orderBy: [
{
createdAt: "desc",
},
{
id: "desc", // Secondary sort by ID for consistent pagination
},
],
take: limit,
skip: offset,
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
const transformedResponses: TResponse[] = await Promise.all(
@@ -355,7 +340,7 @@ export const getResponses = reactCache(
throw error;
}
},
[`getResponses-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}-${cursor}`],
[`getResponses-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
}
@@ -375,27 +360,19 @@ export const getResponseDownloadUrl = async (
throw new ResourceNotFoundError("Survey", surveyId);
}
const environmentId = survey.environmentId;
const environmentId = survey.environmentId as string;
const accessType = "private";
const batchSize = 3000;
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
const pages = Math.ceil(responseCount / batchSize);
// 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 responsesArray = await Promise.all(
Array.from({ length: pages }, (_, i) => {
return getResponses(surveyId, batchSize, i * batchSize, filterCriteria);
})
);
const responses = responsesArray.flat();
const { metaDataFields, questions, hiddenFields, variables, userAttributes } = extractSurveyDetails(
survey,
@@ -465,8 +442,8 @@ export const getResponsesByEnvironmentId = reactCache(
createdAt: "desc",
},
],
take: limit,
skip: offset,
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
const transformedResponses: TResponse[] = await Promise.all(
@@ -501,6 +478,8 @@ 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: {

View File

@@ -238,14 +238,14 @@ describe("Tests for getResponseDownloadUrl service", () => {
expect(fileExtension).not.toEqual("xlsx");
});
test("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponses fails", async () => {
test("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponseCountBySurveyId 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.findMany.mockRejectedValue(errToThrow);
prisma.response.count.mockRejectedValue(errToThrow);
await expect(getResponseDownloadUrl(mockSurveyId, "csv")).rejects.toThrow(DatabaseError);
});

View File

@@ -22,43 +22,6 @@ 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"] = [];
@@ -86,9 +49,39 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
// For Tags
if (filterCriteria?.tags) {
const tagFilters = createFilterTags(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);
}
whereClause.push({
AND: tagFilters,
AND: tags.flat(),
});
}
@@ -449,13 +442,6 @@ 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 };
};

View File

@@ -15,20 +15,12 @@ import {
describe("Time Utilities", () => {
describe("convertDateString", () => {
test("should format date string correctly", () => {
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
expect(convertDateString("2024-03-20")).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", () => {
@@ -81,7 +73,7 @@ describe("Time Utilities", () => {
describe("formatDate", () => {
test("should format date correctly", () => {
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
const date = new Date("2024-03-20");
expect(formatDate(date)).toBe("March 20, 2024");
});
});

View File

@@ -2,16 +2,11 @@ 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 | null) => {
if (dateString === null) return null;
export const convertDateString = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return "Invalid Date";
}
return intlFormat(
date,
{

View File

@@ -1,6 +1,5 @@
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";
@@ -10,22 +9,18 @@ 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 TooManyRequestsError
e instanceof OperationNotAllowedError
) {
return e.message;
}

View File

@@ -0,0 +1,52 @@
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;

View File

@@ -7,17 +7,6 @@
"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": {
@@ -89,12 +78,11 @@
"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 an {email} gesendet. Bitte überprüfen Sie, um das Update abzuschließen.",
"verification_email_successfully_sent": "Bestätigungs-E-Mail erfolgreich 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?"
},
@@ -463,7 +451,6 @@
"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",
@@ -513,8 +500,6 @@
"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:",
@@ -1151,7 +1136,6 @@
"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.",
@@ -1683,7 +1667,6 @@
"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",
@@ -1769,7 +1752,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_responses_found": "Keine Antworten gefunden",
"no_response_matches_filter": "Keine Antwort entspricht deinem Filter",
"only_completed": "Nur vollständige Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
@@ -1781,8 +1764,6 @@
"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",

View File

@@ -7,17 +7,6 @@
"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": {
@@ -89,12 +78,11 @@
"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 sent to {email}. Please verify to complete the update.",
"verification_email_successfully_sent": "Verification email successfully 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?"
},
@@ -463,7 +451,6 @@
"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",
@@ -513,8 +500,6 @@
"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:",
@@ -986,7 +971,7 @@
"2000_monthly_identified_users": "2000 Monthly Identified Users",
"30000_monthly_identified_users": "30000 Monthly Identified Users",
"3_projects": "3 Projects",
"5000_monthly_responses": "5,000 Monthly Responses",
"5000_monthly_responses": "5000 Monthly Responses",
"5_projects": "5 Projects",
"7500_monthly_identified_users": "7500 Monthly Identified Users",
"advanced_targeting": "Advanced Targeting",
@@ -1151,7 +1136,6 @@
"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.",
@@ -1683,7 +1667,6 @@
"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",
@@ -1769,7 +1752,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_responses_found": "No responses found",
"no_response_matches_filter": "No response matches your filter",
"only_completed": "Only completed",
"other_values_found": "Other values found",
"overall": "Overall",
@@ -1781,8 +1764,6 @@
"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",

View File

@@ -7,17 +7,6 @@
"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": {
@@ -89,12 +78,11 @@
"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é à {email}. Veuillez vérifier pour compléter la mise à jour.",
"verification_email_successfully_sent": "Email de vérification envoyé avec succès. 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é ?"
},
@@ -463,7 +451,6 @@
"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",
@@ -513,8 +500,6 @@
"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 :",
@@ -986,7 +971,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": "5,000 Réponses Mensuelles",
"5000_monthly_responses": "5000 Réponses Mensuelles",
"5_projects": "5 Projets",
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
"advanced_targeting": "Ciblage Avancé",
@@ -1151,7 +1136,6 @@
"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.",
@@ -1683,7 +1667,6 @@
"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",
@@ -1769,7 +1752,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_responses_found": "Aucune réponse trouvée",
"no_response_matches_filter": "Aucune réponse ne correspond à votre filtre",
"only_completed": "Uniquement terminé",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
@@ -1781,8 +1764,6 @@
"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",

View File

@@ -7,17 +7,6 @@
"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": {
@@ -89,12 +78,11 @@
"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": "E-mail de verificação enviado para {email}. Verifique para concluir a atualização.",
"verification_email_successfully_sent": "Email de verificação enviado com sucesso. 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?"
},
@@ -463,7 +451,6 @@
"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",
@@ -513,8 +500,6 @@
"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:",
@@ -986,7 +971,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": "5,000 Respostas Mensais",
"5000_monthly_responses": "5000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
"advanced_targeting": "Mira Avançada",
@@ -1151,7 +1136,6 @@
"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.",
@@ -1683,7 +1667,6 @@
"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",
@@ -1769,7 +1752,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_responses_found": "Nenhuma resposta encontrada",
"no_response_matches_filter": "Nenhuma resposta corresponde ao seu filtro",
"only_completed": "Somente concluído",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
@@ -1781,8 +1764,6 @@
"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",

View File

@@ -7,17 +7,6 @@
"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": {
@@ -89,12 +78,11 @@
"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 para {email}. Por favor, verifique para completar a atualização.",
"verification_email_successfully_sent": "Email de verificação enviado com sucesso. 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?"
},
@@ -463,7 +451,6 @@
"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",
@@ -513,8 +500,6 @@
"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:",
@@ -986,7 +971,7 @@
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"5000_monthly_responses": "5000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
"advanced_targeting": "Segmentação Avançada",
@@ -1151,7 +1136,6 @@
"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.",
@@ -1683,7 +1667,6 @@
"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",
@@ -1769,7 +1752,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_responses_found": "Nenhuma resposta encontrada",
"no_response_matches_filter": "Nenhuma resposta corresponde ao seu filtro",
"only_completed": "Apenas concluído",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
@@ -1781,8 +1764,6 @@
"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",

View File

@@ -7,17 +7,6 @@
"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": {
@@ -89,12 +78,11 @@
"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": "验证电子邮件已发送至 {email}。请验证以完成更新。",
"verification_email_successfully_sent": "驗證電子郵件已成功發送。請檢查您的收件匣。",
"we_sent_an_email_to": "我們已發送一封電子郵件至 <email>'{'email'}'</email>。",
"you_didnt_receive_an_email_or_your_link_expired": "您沒有收到電子郵件或您的連結已過期?"
},
@@ -463,7 +451,6 @@
"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": "請關閉它們",
@@ -513,8 +500,6 @@
"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": "不要讓一週過去而沒有了解您的使用者:",
@@ -1151,7 +1136,6 @@
"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。",
@@ -1683,7 +1667,6 @@
"device": "裝置",
"device_info": "裝置資訊",
"email": "電子郵件",
"error_downloading_responses": "下載回應時發生錯誤",
"first_name": "名字",
"how_to_identify_users": "如何識別使用者",
"last_name": "姓氏",
@@ -1769,7 +1752,7 @@
"link_to_public_results_copied": "已複製公開結果的連結",
"make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為",
"mobile_app": "行動應用程式",
"no_responses_found": "找不到回應",
"no_response_matches_filter": "沒有任何回應符合您的篩選器",
"only_completed": "僅已完成",
"other_values_found": "找到其他值",
"overall": "整體",
@@ -1781,8 +1764,6 @@
"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": "設定說明",

View File

@@ -189,30 +189,4 @@ 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();
});
});
});

View File

@@ -98,56 +98,49 @@ 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 ? (
<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 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>
<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 flex-1 items-center justify-end pr-3">
<span>
<PlusIcon className="h-5 w-5 text-slate-400" />
</span>
<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>
) : null}
) : (
<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>
</button>
{!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>
) : (
<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="relative flex h-full flex-col">
<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">
@@ -261,6 +254,6 @@ export const ResponseNotes = ({
</div>
</div>
)}
</>
</div>
);
};

View File

@@ -135,11 +135,14 @@ export const getResponses = async (
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
try {
const query = getResponsesQuery(environmentIds, params);
const whereClause = query.where;
const [responses, totalCount] = await Promise.all([
prisma.response.findMany(query),
prisma.response.count({ where: whereClause }),
const [responses, count] = await prisma.$transaction([
prisma.response.findMany({
...query,
}),
prisma.response.count({
where: query.where,
}),
]);
if (!responses) {
@@ -149,7 +152,7 @@ export const getResponses = async (
return ok({
data: responses,
meta: {
total: totalCount,
total: count,
limit: params.limit,
offset: params.skip,
},

View File

@@ -214,18 +214,17 @@ describe("Response Lib", () => {
describe("getResponses", () => {
test("return responses with meta information", async () => {
(prisma.response.findMany as any).mockResolvedValue([response]);
(prisma.response.count as any).mockResolvedValue(1);
const responses = [response];
prisma.$transaction = vi.fn().mockResolvedValue([responses, responses.length]);
const result = await getResponses([environmentId], responseFilter);
expect(prisma.response.findMany).toHaveBeenCalled();
expect(prisma.response.count).toHaveBeenCalled();
const result = await getResponses(environmentId, responseFilter);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: [response],
meta: {
total: 1,
total: responses.length,
limit: responseFilter.limit,
offset: responseFilter.skip,
},
@@ -234,10 +233,9 @@ describe("Response Lib", () => {
});
test("return a not_found error if responses are not found", async () => {
(prisma.response.findMany as any).mockResolvedValue(null);
(prisma.response.count as any).mockResolvedValue(0);
prisma.$transaction = vi.fn().mockResolvedValue([null, 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({
@@ -247,25 +245,10 @@ describe("Response Lib", () => {
}
});
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);
test("return an internal_server_error error if prisma transaction fails", async () => {
prisma.$transaction = vi.fn().mockRejectedValue(new Error("Internal server error"));
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);
const result = await getResponses(environmentId, responseFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({

View File

@@ -1,61 +0,0 @@
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("/");
});
});

View File

@@ -1,29 +0,0 @@
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>
);
};

View File

@@ -24,7 +24,6 @@ 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", () => ({

View File

@@ -1,9 +1,4 @@
import {
EMAIL_VERIFICATION_DISABLED,
ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY,
SESSION_MAX_AGE,
} from "@/lib/constants";
import { EMAIL_VERIFICATION_DISABLED, ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY } from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
@@ -183,7 +178,7 @@ export const authOptions: NextAuthOptions = {
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
session: {
maxAge: SESSION_MAX_AGE,
maxAge: 3600,
},
callbacks: {
async jwt({ token }) {

View File

@@ -1,7 +1,8 @@
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, updateBrevoCustomer } from "./brevo";
import { createBrevoCustomer } from "./brevo";
vi.mock("@/lib/constants", () => ({
BREVO_API_KEY: "mock_api_key",
@@ -41,87 +42,18 @@ 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 201", async () => {
test("should log the error response if fetch status is not 200", async () => {
const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockResolvedValueOnce(
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
new 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();
});
});

View File

@@ -4,26 +4,6 @@ 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;
@@ -32,7 +12,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
validateInputs([id, ZId], [email, ZUserEmail]);
try {
const requestBody: BrevoCreateContact = {
const requestBody: any = {
email,
ext_id: id,
updateEnabled: false,
@@ -54,7 +34,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
body: JSON.stringify(requestBody),
});
if (res.status !== 201) {
if (res.status !== 200) {
const errorText = await res.text();
logger.error({ errorText }, "Error sending user to Brevo");
}
@@ -62,36 +42,3 @@ 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");
}
};

View File

@@ -1,81 +0,0 @@
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_successfully_sent") {
return `Verification email sent to ${params?.email}`;
}
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 to ${mockEmail}`);
});
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();
});
});

View File

@@ -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", { email }));
toast.success(t("auth.verification-requested.verification_email_successfully_sent"));
} else {
const errorMessage = getFormattedErrorMessage(response);
toast.error(errorMessage);

View File

@@ -1,23 +0,0 @@
"use server";
import { verifyEmailChangeToken } from "@/lib/jwt";
import { actionClient } from "@/lib/utils/action-client";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { updateUser } from "@/modules/auth/lib/user";
import { z } from "zod";
export const verifyEmailChangeAction = actionClient
.schema(z.object({ token: z.string() }))
.action(async ({ parsedInput }) => {
const { id, email } = await verifyEmailChangeToken(parsedInput.token);
if (!email) {
throw new Error("Email not found in token");
}
const user = await updateUser(id, { email, emailVerified: new Date() });
if (!user) {
throw new Error("User not found or email update failed");
}
await updateBrevoCustomer({ id: user.id, email: user.email });
return user;
});

View File

@@ -1,68 +0,0 @@
import { verifyEmailChangeAction } from "@/modules/auth/verify-email-change/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { signOut } from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EmailChangeSignIn } from "./email-change-sign-in";
// Mock dependencies
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
vi.mock("next-auth/react", () => ({
signOut: vi.fn(),
}));
vi.mock("@/modules/auth/verify-email-change/actions", () => ({
verifyEmailChangeAction: vi.fn(),
}));
describe("EmailChangeSignIn", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("shows loading state initially", () => {
render(<EmailChangeSignIn token="valid-token" />);
expect(screen.getByText("auth.email-change.email_verification_loading")).toBeInTheDocument();
});
test("handles successful email change verification", async () => {
vi.mocked(verifyEmailChangeAction).mockResolvedValueOnce({
data: { id: "123", email: "test@example.com", emailVerified: new Date(), locale: "en-US" },
});
render(<EmailChangeSignIn token="valid-token" />);
await waitFor(() => {
expect(screen.getByText("auth.email-change.email_change_success")).toBeInTheDocument();
expect(screen.getByText("auth.email-change.email_change_success_description")).toBeInTheDocument();
});
expect(signOut).toHaveBeenCalledWith({ redirect: false });
});
test("handles failed email change verification", async () => {
vi.mocked(verifyEmailChangeAction).mockResolvedValueOnce({ serverError: "Error" });
render(<EmailChangeSignIn token="invalid-token" />);
await waitFor(() => {
expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument();
expect(screen.getByText("auth.email-change.invalid_or_expired_token")).toBeInTheDocument();
});
expect(signOut).not.toHaveBeenCalled();
});
test("handles empty token", () => {
render(<EmailChangeSignIn token="" />);
expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument();
expect(screen.getByText("auth.email-change.invalid_or_expired_token")).toBeInTheDocument();
});
});

View File

@@ -1,66 +0,0 @@
"use client";
import { verifyEmailChangeAction } from "@/modules/auth/verify-email-change/actions";
import { useTranslate } from "@tolgee/react";
import { signOut } from "next-auth/react";
import { useEffect, useState } from "react";
interface EmailChangeSignInProps {
token: string;
}
export const EmailChangeSignIn = ({ token }: EmailChangeSignInProps) => {
const { t } = useTranslate();
const [status, setStatus] = useState<"success" | "error" | "loading">("loading");
useEffect(() => {
const validateToken = async () => {
if (typeof token === "string" && token.trim() !== "") {
const result = await verifyEmailChangeAction({ token });
if (!result?.data) {
setStatus("error");
} else {
setStatus("success");
}
} else {
setStatus("error");
}
};
if (token) {
validateToken();
} else {
setStatus("error");
}
}, [token]);
useEffect(() => {
if (status === "success") {
signOut({ redirect: false });
}
}, [status]);
const text = {
heading: {
success: t("auth.email-change.email_change_success"),
error: t("auth.email-change.email_verification_failed"),
loading: t("auth.email-change.email_verification_loading"),
},
description: {
success: t("auth.email-change.email_change_success_description"),
error: t("auth.email-change.invalid_or_expired_token"),
loading: t("auth.email-change.email_verification_loading_description"),
},
};
return (
<>
<h1 className={`leading-2 mb-4 text-center font-bold ${status === "error" ? "text-red-600" : ""}`}>
{text.heading[status]}
</h1>
<p className="text-center text-sm">{text.description[status]}</p>
<hr className="my-4" />
</>
);
};

View File

@@ -1,47 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { VerifyEmailChangePage } 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/verify-email-change/components/email-change-sign-in", () => ({
EmailChangeSignIn: ({ token }: { token: string }) => (
<div data-testid="email-change-sign-in">Email Change Sign In with token: {token}</div>
),
}));
describe("VerifyEmailChangePage", () => {
afterEach(() => {
cleanup();
});
test("renders the page with form wrapper and components", async () => {
const searchParams = { token: "test-token" };
render(await VerifyEmailChangePage({ searchParams }));
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("email-change-sign-in")).toBeInTheDocument();
expect(screen.getByTestId("back-to-login")).toBeInTheDocument();
expect(screen.getByText("Email Change Sign In with token: test-token")).toBeInTheDocument();
});
test("handles missing token", async () => {
const searchParams = {};
render(await VerifyEmailChangePage({ searchParams }));
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("email-change-sign-in")).toBeInTheDocument();
expect(screen.getByTestId("back-to-login")).toBeInTheDocument();
expect(screen.getByText("Email Change Sign In with token:")).toBeInTheDocument();
});
});

View File

@@ -1,16 +0,0 @@
import { BackToLoginButton } from "@/modules/auth/components/back-to-login-button";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { EmailChangeSignIn } from "@/modules/auth/verify-email-change/components/email-change-sign-in";
export const VerifyEmailChangePage = async ({ searchParams }) => {
const { token } = await searchParams;
return (
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
<FormWrapper>
<EmailChangeSignIn token={token} />
<BackToLoginButton />
</FormWrapper>
</div>
);
};

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