Compare commits

...

9 Commits

Author SHA1 Message Date
Matti Nannt
2b9cd37c6c chore: enable rate limiting by default in helm chart (#5879) 2025-05-27 14:36:39 +02:00
Piyush Gupta
f8f14eb6f3 fix: weak cipher suite usage (#5873) 2025-05-27 12:09:16 +00:00
Matti Nannt
645fc863aa fix: performance issues on survey summary (#5885)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-27 12:07:31 +00:00
Anshuman Pandey
c53f030b24 fix: multiple close function calls because of timeouts (#5886) 2025-05-27 07:20:35 +00:00
devin-ai-integration[bot]
45d74f9ba0 fix: Update JS SDK log messages for clarity (#5819)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matti.sh>
2025-05-26 09:57:37 +00:00
Piyush Gupta
87870919ca fix: issues in the email change feature (#5868) 2025-05-24 12:04:58 +00:00
Piyush Gupta
ce2fdde474 fix: rtl issue in open text placeholder (#5855) 2025-05-23 11:47:38 +00:00
Harsh Bhat
6e2f30c6ed chore: add no index for survey pages (#5859) 2025-05-23 05:44:22 +00:00
Jakob Schott
5c8040008a fix: 602 modal height on small screens (#5863) 2025-05-23 05:34:43 +00:00
72 changed files with 1586 additions and 620 deletions

View File

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

View File

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

View File

@@ -0,0 +1,334 @@
---
description:
globs:
alwaysApply: false
---
# Formbricks Architecture & Patterns
## Monorepo Structure
### Apps Directory
- `apps/web/` - Main Next.js web application
- `packages/` - Shared packages and utilities
### Key Directories in Web App
```
apps/web/
├── app/ # Next.js 13+ app directory
│ ├── (app)/ # Main application routes
│ ├── (auth)/ # Authentication routes
│ ├── api/ # API routes
│ └── share/ # Public sharing routes
├── components/ # Shared components
├── lib/ # Utility functions and services
└── modules/ # Feature-specific modules
```
## Routing Patterns
### App Router Structure
The application uses Next.js 13+ app router with route groups:
```
(app)/environments/[environmentId]/
├── surveys/[surveyId]/
│ ├── (analysis)/ # Analysis views
│ │ ├── responses/ # Response management
│ │ ├── summary/ # Survey summary
│ │ └── hooks/ # Analysis-specific hooks
│ ├── edit/ # Survey editing
│ └── settings/ # Survey settings
```
### Dynamic Routes
- `[environmentId]` - Environment-specific routes
- `[surveyId]` - Survey-specific routes
- `[sharingKey]` - Public sharing routes
## Service Layer Pattern
### Service Organization
Services are organized by domain in `apps/web/lib/`:
```typescript
// Example: Response service
// apps/web/lib/response/service.ts
export const getResponseCountAction = async ({
surveyId,
filterCriteria,
}: {
surveyId: string;
filterCriteria: any;
}) => {
// Service implementation
};
```
### Action Pattern
Server actions follow a consistent pattern:
```typescript
// Action wrapper for service calls
export const getResponseCountAction = async (params) => {
try {
const result = await responseService.getCount(params);
return { data: result };
} catch (error) {
return { error: error.message };
}
};
```
## Context Patterns
### Provider Structure
Context providers follow a consistent pattern:
```typescript
// Provider component
export const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => {
const [selectedFilter, setSelectedFilter] = useState(defaultFilter);
const value = {
selectedFilter,
setSelectedFilter,
// ... other state and methods
};
return (
<ResponseFilterContext.Provider value={value}>
{children}
</ResponseFilterContext.Provider>
);
};
// Hook for consuming context
export const useResponseFilter = () => {
const context = useContext(ResponseFilterContext);
if (!context) {
throw new Error('useResponseFilter must be used within ResponseFilterProvider');
}
return context;
};
```
### Context Composition
Multiple contexts are often composed together:
```typescript
// Layout component with multiple providers
export default function AnalysisLayout({ children }: { children: React.ReactNode }) {
return (
<ResponseFilterProvider>
<ResponseCountProvider>
{children}
</ResponseCountProvider>
</ResponseFilterProvider>
);
}
```
## Component Patterns
### Page Components
Page components are located in the app directory and follow this pattern:
```typescript
// apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
export default function ResponsesPage() {
return (
<div>
<ResponsesTable />
<ResponsesPagination />
</div>
);
}
```
### Component Organization
- **Pages** - Route components in app directory
- **Components** - Reusable UI components
- **Modules** - Feature-specific components and logic
### Shared Components
Common components are in `apps/web/components/`:
- UI components (buttons, inputs, modals)
- Layout components (headers, sidebars)
- Data display components (tables, charts)
## Hook Patterns
### Custom Hook Structure
Custom hooks follow consistent patterns:
```typescript
export const useResponseCount = ({
survey,
initialCount
}: {
survey: TSurvey;
initialCount?: number;
}) => {
const [responseCount, setResponseCount] = useState(initialCount ?? 0);
const [isLoading, setIsLoading] = useState(false);
// Hook logic...
return {
responseCount,
isLoading,
refetch,
};
};
```
### Hook Dependencies
- Use context hooks for shared state
- Implement proper cleanup with AbortController
- Optimize dependency arrays to prevent unnecessary re-renders
## Data Fetching Patterns
### Server Actions
The app uses Next.js server actions for data fetching:
```typescript
// Server action
export async function getResponsesAction(params: GetResponsesParams) {
const responses = await getResponses(params);
return { data: responses };
}
// Client usage
const { data } = await getResponsesAction(params);
```
### Error Handling
Consistent error handling across the application:
```typescript
try {
const result = await apiCall();
return { data: result };
} catch (error) {
console.error("Operation failed:", error);
return { error: error.message };
}
```
## Type Safety
### Type Organization
Types are organized in packages:
- `@formbricks/types` - Shared type definitions
- Local types in component/hook files
### Common Types
```typescript
import { TSurvey } from "@formbricks/types/surveys/types";
import { TResponse } from "@formbricks/types/responses";
import { TEnvironment } from "@formbricks/types/environment";
```
## State Management
### Local State
- Use `useState` for component-specific state
- Use `useReducer` for complex state logic
- Use refs for mutable values that don't trigger re-renders
### Global State
- React Context for feature-specific shared state
- URL state for filters and pagination
- Server state through server actions
## Performance Considerations
### Code Splitting
- Dynamic imports for heavy components
- Route-based code splitting with app router
- Lazy loading for non-critical features
### Caching Strategy
- Server-side caching for database queries
- Client-side caching with React Query (where applicable)
- Static generation for public pages
## Testing Strategy
### Test Organization
```
component/
├── Component.tsx
├── Component.test.tsx
└── hooks/
├── useHook.ts
└── useHook.test.tsx
```
### Test Patterns
- Unit tests for utilities and services
- Integration tests for components with context
- Hook tests with proper mocking
## Build & Deployment
### Build Process
- TypeScript compilation
- Next.js build optimization
- Asset optimization and bundling
### Environment Configuration
- Environment-specific configurations
- Feature flags for gradual rollouts
- Database connection management
## Security Patterns
### Authentication
- Session-based authentication
- Environment-based access control
- API route protection
### Data Validation
- Input validation on both client and server
- Type-safe API contracts
- Sanitization of user inputs
## Monitoring & Observability
### Error Tracking
- Client-side error boundaries
- Server-side error logging
- Performance monitoring
### Analytics
- User interaction tracking
- Performance metrics
- Database query monitoring
## Best Practices Summary
### Code Organization
- ✅ Follow the established directory structure
- ✅ Use consistent naming conventions
- ✅ Separate concerns (UI, logic, data)
- ✅ Keep components focused and small
### Performance
- ✅ Implement proper loading states
- ✅ Use AbortController for async operations
- ✅ Optimize database queries
- ✅ Implement proper caching strategies
### Type Safety
- ✅ Use TypeScript throughout
- ✅ Define proper interfaces for props
- ✅ Use type guards for runtime validation
- ✅ Leverage shared type packages
### Testing
- ✅ Write tests for critical functionality
- ✅ Mock external dependencies properly
- ✅ Test error scenarios and edge cases
- ✅ Maintain good test coverage

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,282 @@
---
description:
globs:
alwaysApply: false
---
# Testing Patterns & Best Practices
## Test File Naming & Environment
### File Extensions
- Use `.test.tsx` for React component/hook tests (runs in jsdom environment)
- Use `.test.ts` for utility/service tests (runs in Node environment)
- The vitest config uses `environmentMatchGlobs` to automatically set jsdom for `.tsx` files
### Test Structure
```typescript
// Import the mocked functions first
import { useHook } from "@/path/to/hook";
import { serviceFunction } from "@/path/to/service";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock dependencies
vi.mock("@/path/to/hook", () => ({
useHook: vi.fn(),
}));
describe("ComponentName", () => {
beforeEach(() => {
vi.clearAllMocks();
// Setup default mocks
});
test("descriptive test name", async () => {
// Test implementation
});
});
```
## React Hook Testing
### Context Mocking
When testing hooks that use React Context:
```typescript
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [],
onlyComplete: false,
},
setSelectedFilter: vi.fn(),
selectedOptions: {
questionOptions: [],
questionFilterOptions: [],
},
setSelectedOptions: vi.fn(),
dateRange: { from: new Date(), to: new Date() },
setDateRange: vi.fn(),
resetState: vi.fn(),
});
```
### Testing Async Hooks
- Always use `waitFor` for async operations
- Test both loading and completed states
- Verify API calls with correct parameters
```typescript
test("fetches data on mount", async () => {
const { result } = renderHook(() => useHook());
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBe(expectedData);
expect(vi.mocked(apiCall)).toHaveBeenCalledWith(expectedParams);
});
```
### Testing Hook Dependencies
To test useEffect dependencies, ensure mocks return different values:
```typescript
// First render
mockGetFormattedFilters.mockReturnValue(mockFilters);
// Change dependency and trigger re-render
const newMockFilters = { ...mockFilters, finished: true };
mockGetFormattedFilters.mockReturnValue(newMockFilters);
rerender();
```
## Performance Testing
### Race Condition Testing
Test AbortController implementation:
```typescript
test("cancels previous request when new request is made", async () => {
let resolveFirst: (value: any) => void;
let resolveSecond: (value: any) => void;
const firstPromise = new Promise((resolve) => {
resolveFirst = resolve;
});
const secondPromise = new Promise((resolve) => {
resolveSecond = resolve;
});
vi.mocked(apiCall)
.mockReturnValueOnce(firstPromise as any)
.mockReturnValueOnce(secondPromise as any);
const { result } = renderHook(() => useHook());
// Trigger second request
result.current.refetch();
// Resolve in order - first should be cancelled
resolveFirst!({ data: 100 });
resolveSecond!({ data: 200 });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Should have result from second request
expect(result.current.data).toBe(200);
});
```
### Cleanup Testing
```typescript
test("cleans up on unmount", () => {
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
const { unmount } = renderHook(() => useHook());
unmount();
expect(abortSpy).toHaveBeenCalled();
abortSpy.mockRestore();
});
```
## Error Handling Testing
### API Error Testing
```typescript
test("handles API errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(apiCall).mockRejectedValue(new Error("API Error"));
const { result } = renderHook(() => useHook());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(consoleSpy).toHaveBeenCalledWith("Error message:", expect.any(Error));
expect(result.current.data).toBe(fallbackValue);
consoleSpy.mockRestore();
});
```
### Cancelled Request Testing
```typescript
test("does not update state for cancelled requests", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
let rejectFirst: (error: any) => void;
const firstPromise = new Promise((_, reject) => {
rejectFirst = reject;
});
vi.mocked(apiCall)
.mockReturnValueOnce(firstPromise as any)
.mockResolvedValueOnce({ data: 42 });
const { result } = renderHook(() => useHook());
result.current.refetch();
const abortError = new Error("Request cancelled");
rejectFirst!(abortError);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Should not log error for cancelled request
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
```
## Type Safety in Tests
### Mock Type Assertions
Use type assertions for edge cases:
```typescript
vi.mocked(apiCall).mockResolvedValue({
data: null as any, // For testing null handling
});
vi.mocked(apiCall).mockResolvedValue({
data: undefined as any, // For testing undefined handling
});
```
### Proper Mock Typing
Ensure mocks match the actual interface:
```typescript
const mockSurvey: TSurvey = {
id: "survey-123",
name: "Test Survey",
// ... other required properties
} as unknown as TSurvey; // Use when partial mocking is needed
```
## Common Test Patterns
### Testing State Changes
```typescript
test("updates state correctly", async () => {
const { result } = renderHook(() => useHook());
// Initial state
expect(result.current.value).toBe(initialValue);
// Trigger change
result.current.updateValue(newValue);
// Verify change
expect(result.current.value).toBe(newValue);
});
```
### Testing Multiple Scenarios
```typescript
test("handles different modes", async () => {
// Test regular mode
vi.mocked(useParams).mockReturnValue({ surveyId: "123" });
const { rerender } = renderHook(() => useHook());
await waitFor(() => {
expect(vi.mocked(regularApi)).toHaveBeenCalled();
});
// Test sharing mode
vi.mocked(useParams).mockReturnValue({
surveyId: "123",
sharingKey: "share-123"
});
rerender();
await waitFor(() => {
expect(vi.mocked(sharingApi)).toHaveBeenCalled();
});
});
```
## Test Organization
### Comprehensive Test Coverage
For hooks, ensure you test:
- ✅ Initialization (with/without initial values)
- ✅ Data fetching (success/error cases)
- ✅ State updates and refetching
- ✅ Dependency changes triggering effects
- ✅ Manual actions (refetch, reset)
- ✅ Race condition prevention
- ✅ Cleanup on unmount
- ✅ Mode switching (if applicable)
- ✅ Edge cases (null/undefined data)
### Test Naming
Use descriptive test names that explain the scenario:
- ✅ "initializes with initial count"
- ✅ "fetches response count on mount for regular survey"
- ✅ "cancels previous request when new request is made"
- ❌ "test hook"
- ❌ "it works"

View File

@@ -1,7 +1,7 @@
"use server";
import {
checkUserExistsByEmail,
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
@@ -10,13 +10,13 @@ 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,
InvalidInputError,
OperationNotAllowedError,
TooManyRequestsError,
} from "@formbricks/types/errors";
@@ -37,10 +37,11 @@ export const updateUserAction = authenticatedActionClient
const inputEmail = parsedInput.email?.trim().toLowerCase();
let payload: TUserUpdateInput = {
name: parsedInput.name,
locale: parsedInput.locale,
...(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 {
@@ -61,20 +62,26 @@ export const updateUserAction = authenticatedActionClient
throw new AuthorizationError("Incorrect credentials");
}
const doesUserExist = await checkUserExistsByEmail(inputEmail);
// Check if the new email is unique, no user exists with the new email
const isEmailUnique = await getIsEmailUnique(inputEmail);
if (doesUserExist) {
throw new InvalidInputError("This email is already in use");
}
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
} else {
await sendVerificationNewEmail(ctx.user.id, 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);
}
}
}
return await updateUser(ctx.user.id, payload);
// Only proceed with updateUser if we have actual changes to make
if (Object.keys(payload).length > 0) {
await updateUser(ctx.user.id, payload);
}
return true;
});
const ZUpdateAvatarAction = z.object({

View File

@@ -21,11 +21,13 @@ import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser } from "@formbricks/types/user";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { updateUserAction } from "../actions";
// Schema & types
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true });
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
email: ZUserEmail.transform((val) => val?.trim().toLowerCase()),
});
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
export const EditProfileDetailsForm = ({
@@ -80,9 +82,9 @@ export const EditProfileDetailsForm = ({
if (updatedUserResult?.data) {
if (!emailVerificationDisabled) {
toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email }));
toast.success(t("auth.verification-requested.new_email_verification_success"));
} else {
toast.success(t("environments.settings.profile.profile_updated_successfully"));
toast.success(t("environments.settings.profile.email_change_initiated"));
await signOut({ redirect: false });
router.push(`/email-change-without-verification-success`);
return;
@@ -98,11 +100,6 @@ export const EditProfileDetailsForm = ({
};
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
if (data.email !== user.email && data.email.toLowerCase() === user.email.toLowerCase()) {
toast.error(t("auth.email-change.email_already_exists"));
return;
}
if (data.email !== user.email) {
setShowModal(true);
} else {

View File

@@ -2,7 +2,7 @@ import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkUserExistsByEmail, verifyUserPassword } from "./user";
import { getIsEmailUnique, verifyUserPassword } from "./user";
// Mock dependencies
vi.mock("@/lib/user/cache", () => ({
@@ -116,27 +116,27 @@ describe("User Library Tests", () => {
});
});
describe("checkUserExistsByEmail", () => {
describe("getIsEmailUnique", () => {
const email = "test@example.com";
test("should return true if user exists", async () => {
test("should return false if user exists", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
id: "some-user-id",
} as any);
const result = await checkUserExistsByEmail(email);
expect(result).toBe(true);
const result = await getIsEmailUnique(email);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
test("should return false if user does not exist", async () => {
test("should return true if user does not exist", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
const result = await checkUserExistsByEmail(email);
expect(result).toBe(false);
const result = await getIsEmailUnique(email);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },

View File

@@ -47,7 +47,7 @@ export const verifyUserPassword = async (userId: string, password: string): Prom
return true;
};
export const checkUserExistsByEmail = reactCache(
export const getIsEmailUnique = reactCache(
async (email: string): Promise<boolean> =>
cache(
async () => {
@@ -60,9 +60,9 @@ export const checkUserExistsByEmail = reactCache(
},
});
return !!user;
return !user;
},
[`checkUserExistsByEmail-${email}`],
[`getIsEmailUnique-${email}`],
{
tags: [userCache.tag.byEmail(email)],
}

View File

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

View File

@@ -5,7 +5,6 @@ import {
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
@@ -52,7 +51,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterConte
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" />),
}));
@@ -69,7 +67,6 @@ const mockUseResponseFilter = vi.mocked(useResponseFilter);
const mockGetResponseCountAction = vi.mocked(getResponseCountAction);
const mockRevalidateSurveyIdPath = vi.mocked(revalidateSurveyIdPath);
const mockGetFormattedFilters = vi.mocked(getFormattedFilters);
const mockUseIntervalWhenFocused = vi.mocked(useIntervalWhenFocused);
const MockSecondaryNavigation = vi.mocked(SecondaryNavigation);
const mockSurveyLanguages: TSurveyLanguage[] = [
@@ -120,7 +117,6 @@ const mockSurvey = {
const defaultProps = {
environmentId: "testEnvId",
survey: mockSurvey,
initialTotalResponseCount: 10,
activeId: "summary",
};
@@ -167,23 +163,20 @@ describe("SurveyAnalysisNavigation", () => {
);
});
test("passes correct runWhen flag to useIntervalWhenFocused based on share embed modal", () => {
test("renders navigation correctly for sharing page", () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
);
mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id });
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
mockGetFormattedFilters.mockReturnValue([] as any);
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue("true") } as any);
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, false, false);
cleanup();
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, true, false);
expect(MockSecondaryNavigation).toHaveBeenCalled();
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
});
test("displays correct response count string in label for various scenarios", async () => {
@@ -196,8 +189,8 @@ describe("SurveyAnalysisNavigation", () => {
mockGetFormattedFilters.mockReturnValue([] as any);
// Scenario 1: total = 10, filtered = null (initial state)
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses (10)");
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses");
cleanup();
vi.resetAllMocks(); // Reset mocks for next case
@@ -213,11 +206,11 @@ describe("SurveyAnalysisNavigation", () => {
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
return { data: 15, error: null, success: true };
});
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={15} />);
render(<SurveyAnalysisNavigation {...defaultProps} />);
await waitFor(() => {
const lastCallArgs =
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
});
cleanup();
vi.resetAllMocks();
@@ -234,11 +227,11 @@ describe("SurveyAnalysisNavigation", () => {
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
return { data: 10, error: null, success: true };
});
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
render(<SurveyAnalysisNavigation {...defaultProps} />);
await waitFor(() => {
const lastCallArgs =
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
});
});
});

View File

@@ -1,105 +1,30 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
getResponseCountAction,
revalidateSurveyIdPath,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getResponseCountBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { useTranslate } from "@tolgee/react";
import { InboxIcon, PresentationIcon } from "lucide-react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, usePathname } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyAnalysisNavigationProps {
environmentId: string;
survey: TSurvey;
initialTotalResponseCount: number | null;
activeId: string;
}
export const SurveyAnalysisNavigation = ({
environmentId,
survey,
initialTotalResponseCount,
activeId,
}: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslate();
const params = useParams();
const [filteredResponseCount, setFilteredResponseCount] = useState<number | null>(null);
const [totalResponseCount, setTotalResponseCount] = useState<number | null>(initialTotalResponseCount);
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const searchParams = useSearchParams();
const isShareEmbedModalOpen = searchParams.get("share") === "true";
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
const { selectedFilter, dateRange } = useResponseFilter();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
[selectedFilter, dateRange, survey]
);
const latestFiltersRef = useRef(filters);
latestFiltersRef.current = filters;
const getResponseCount = () => {
if (isSharingPage) return getResponseCountBySurveySharingKeyAction({ sharingKey });
return getResponseCountAction({ surveyId: survey.id });
};
const fetchResponseCount = async () => {
const count = await getResponseCount();
const responseCount = count?.data ?? 0;
setTotalResponseCount(responseCount);
};
const getFilteredResponseCount = useCallback(() => {
if (isSharingPage)
return getResponseCountBySurveySharingKeyAction({
sharingKey,
filterCriteria: latestFiltersRef.current,
});
return getResponseCountAction({ surveyId: survey.id, filterCriteria: latestFiltersRef.current });
}, [isSharingPage, sharingKey, survey.id]);
const fetchFilteredResponseCount = useCallback(async () => {
const count = await getFilteredResponseCount();
const responseCount = count?.data ?? 0;
setFilteredResponseCount(responseCount);
}, [getFilteredResponseCount]);
useEffect(() => {
fetchFilteredResponseCount();
}, [filters, isSharingPage, sharingKey, survey.id, fetchFilteredResponseCount]);
useIntervalWhenFocused(
() => {
fetchResponseCount();
fetchFilteredResponseCount();
},
10000,
!isShareEmbedModalOpen,
false
);
const getResponseCountString = () => {
if (totalResponseCount === null) return "";
if (filteredResponseCount === null) return `(${totalResponseCount})`;
const totalCount = Math.max(totalResponseCount, filteredResponseCount);
if (totalCount === filteredResponseCount) return `(${totalCount})`;
return `(${filteredResponseCount} of ${totalCount})`;
};
const navigation = [
{
@@ -114,7 +39,7 @@ export const SurveyAnalysisNavigation = ({
},
{
id: "responses",
label: `${t("common.responses")} ${getResponseCountString()}`,
label: t("common.responses"),
icon: <InboxIcon className="h-5 w-5" />,
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),

View File

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

View File

@@ -1,18 +1,12 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
getResponseCountAction,
getResponsesAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import {
getResponseCountBySurveySharingKeyAction,
getResponsesBySurveySharingKeyAction,
} from "@/app/share/[sharingKey]/actions";
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -49,7 +43,6 @@ export const ResponsePage = ({
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const [responseCount, setResponseCount] = useState<number | null>(null);
const [responses, setResponses] = useState<TResponse[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
@@ -97,9 +90,6 @@ export const ResponsePage = ({
const deleteResponses = (responseIds: string[]) => {
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
if (responseCount) {
setResponseCount(responseCount - responseIds.length);
}
};
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
@@ -118,29 +108,6 @@ export const ResponsePage = ({
}
}, [searchParams, resetState]);
useEffect(() => {
const handleResponsesCount = async () => {
let responseCount = 0;
if (isSharingPage) {
const responseCountActionResponse = await getResponseCountBySurveySharingKeyAction({
sharingKey,
filterCriteria: filters,
});
responseCount = responseCountActionResponse?.data || 0;
} else {
const responseCountActionResponse = await getResponseCountAction({
surveyId,
filterCriteria: filters,
});
responseCount = responseCountActionResponse?.data || 0;
}
setResponseCount(responseCount);
};
handleResponsesCount();
}, [filters, isSharingPage, sharingKey, surveyId]);
useEffect(() => {
const fetchInitialResponses = async () => {
try {

View File

@@ -1,3 +1,4 @@
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
@@ -61,6 +62,7 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
SESSION_MAX_AGE: 1000,
}));
vi.mock("@/lib/getSurveyUrl", () => ({
@@ -109,6 +111,14 @@ vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
useParams: () => ({
environmentId: "test-env-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
const mockEnvironmentId = "test-env-id";
const mockSurveyId = "test-survey-id";
const mockUserId = "test-user-id";
@@ -180,7 +190,7 @@ describe("ResponsesPage", () => {
test("renders correctly with all data", async () => {
const props = { params: mockParams };
const jsx = await Page(props);
render(jsx);
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
await screen.findByTestId("page-content-wrapper");
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -196,7 +206,6 @@ describe("ResponsesPage", () => {
isReadOnly: false,
user: mockUser,
surveyDomain: mockSurveyDomain,
responseCount: 10,
}),
undefined
);
@@ -206,7 +215,6 @@ describe("ResponsesPage", () => {
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "responses",
initialTotalResponseCount: 10,
}),
undefined
);

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,7 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
usePathname: () => "/current",
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
}));
// Mock copySurveyLink to return a predictable string
@@ -69,6 +70,23 @@ vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((response) => response?.error || "Unknown error"),
}));
// Mock ResponseCountProvider dependencies
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: vi.fn(() => ({ selectedFilter: "all", dateRange: {} })),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({
getResponseCountAction: vi.fn(() => Promise.resolve({ data: 5 })),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
getFormattedFilters: vi.fn(() => []),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getResponseCountBySurveySharingKeyAction: vi.fn(() => Promise.resolve({ data: 5 })),
}));
vi.spyOn(toast, "success");
vi.spyOn(toast, "error");

View File

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

View File

@@ -758,7 +758,6 @@ describe("getSurveySummary", () => {
expect(summary.dropOff).toBeDefined();
expect(summary.summary).toBeDefined();
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, undefined);
expect(prisma.response.findMany).toHaveBeenCalled(); // Check if getResponsesForSummary was effectively called
expect(getDisplayCountBySurveyId).toHaveBeenCalled();
});
@@ -770,7 +769,6 @@ describe("getSurveySummary", () => {
test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true };
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(2); // Assume 2 finished responses
const finishedResponses = mockResponses
.filter((r) => r.finished)
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
@@ -778,7 +776,6 @@ describe("getSurveySummary", () => {
await getSurveySummary(mockSurveyId, filterCriteria);
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, filterCriteria);
expect(prisma.response.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ surveyId: mockSurveyId }), // buildWhereClause is mocked

View File

@@ -5,7 +5,6 @@ import { displayCache } from "@/lib/display/cache";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { responseCache } from "@/lib/response/cache";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { buildWhereClause } from "@/lib/response/utils";
import { surveyCache } from "@/lib/survey/cache";
import { getSurvey } from "@/lib/survey/service";
@@ -13,6 +12,7 @@ import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -917,22 +917,24 @@ export const getSurveySummary = reactCache(
}
const batchSize = 5000;
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
const pages = Math.ceil(responseCount / batchSize);
// Use cursor-based pagination instead of count + offset to avoid expensive queries
const responses: TSurveySummaryResponse[] = [];
let cursor: string | undefined = undefined;
let hasMore = true;
// Create an array of batch fetch promises
const batchPromises = Array.from({ length: pages }, (_, i) =>
getResponsesForSummary(surveyId, batchSize, i * batchSize, filterCriteria)
);
while (hasMore) {
const batch = await getResponsesForSummary(surveyId, batchSize, 0, filterCriteria, cursor);
responses.push(...batch);
// Fetch all batches in parallel
const batchResults = await Promise.all(batchPromises);
// Combine all batch results
const responses = batchResults.flat();
if (batch.length < batchSize) {
hasMore = false;
} else {
// Use the last response's ID as cursor for next batch
cursor = batch[batch.length - 1].id;
}
}
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
@@ -972,7 +974,8 @@ export const getResponsesForSummary = reactCache(
surveyId: string,
limit: number,
offset: number,
filterCriteria?: TResponseFilterCriteria
filterCriteria?: TResponseFilterCriteria,
cursor?: string
): Promise<TSurveySummaryResponse[]> =>
cache(
async () => {
@@ -980,18 +983,28 @@ export const getResponsesForSummary = reactCache(
[surveyId, ZId],
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()]
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
);
const queryLimit = limit ?? RESPONSES_PER_PAGE;
const survey = await getSurvey(surveyId);
if (!survey) return [];
try {
const whereClause: Prisma.ResponseWhereInput = {
surveyId,
...buildWhereClause(survey, filterCriteria),
};
// Add cursor condition for cursor-based pagination
if (cursor) {
whereClause.id = {
lt: cursor, // Get responses with ID less than cursor (for desc order)
};
}
const responses = await prisma.response.findMany({
where: {
surveyId,
...buildWhereClause(survey, filterCriteria),
},
where: whereClause,
select: {
id: true,
data: true,
@@ -1013,6 +1026,9 @@ export const getResponsesForSummary = reactCache(
{
createdAt: "desc",
},
{
id: "desc", // Secondary sort by ID for consistent pagination
},
],
take: queryLimit,
skip: offset,
@@ -1043,7 +1059,9 @@ export const getResponsesForSummary = reactCache(
throw error;
}
},
[`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
[
`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}-${cursor || ""}`,
],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
}

View File

@@ -1,7 +1,9 @@
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import SurveyPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -38,7 +40,7 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
SESSION_MAX_AGE: 1000,
}));
vi.mock(
@@ -78,6 +80,13 @@ vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary",
() => ({
getSurveySummary: vi.fn(),
})
);
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
@@ -100,6 +109,11 @@ vi.mock("@/tolgee/server", () => ({
vi.mock("next/navigation", () => ({
notFound: vi.fn(),
useParams: () => ({
environmentId: "test-environment-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
const mockEnvironmentId = "test-environment-id";
@@ -172,6 +186,21 @@ const mockSession = {
expires: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
} as any;
const mockSurveySummary = {
meta: {
completedPercentage: 75,
completedResponses: 15,
displayCount: 20,
dropOffPercentage: 25,
dropOffCount: 5,
startsPercentage: 80,
totalResponses: 20,
ttcAverage: 120,
},
dropOff: [],
summary: [],
};
describe("SurveyPage", () => {
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
@@ -183,6 +212,7 @@ describe("SurveyPage", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getSurveyDomain).mockReturnValue("test.domain.com");
vi.mocked(getSurveySummary).mockResolvedValue(mockSurveySummary);
vi.mocked(notFound).mockClear();
});
@@ -193,7 +223,8 @@ describe("SurveyPage", () => {
test("renders correctly with valid data", async () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
render(await SurveyPage({ params }));
const jsx = await SurveyPage({ params });
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -204,7 +235,6 @@ describe("SurveyPage", () => {
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId);
expect(vi.mocked(getResponseCountBySurveyId)).toHaveBeenCalledWith(mockSurveyId);
expect(vi.mocked(getSurveyDomain)).toHaveBeenCalled();
expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual(
@@ -212,7 +242,6 @@ describe("SurveyPage", () => {
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "summary",
initialTotalResponseCount: 10,
})
);
@@ -222,18 +251,17 @@ describe("SurveyPage", () => {
survey: mockSurvey,
surveyId: mockSurveyId,
webAppUrl: WEBAPP_URL,
user: mockUser,
totalResponseCount: 10,
documentsPerPage: DOCUMENTS_PER_PAGE,
isReadOnly: false,
locale: mockUser.locale ?? DEFAULT_LOCALE,
initialSurveySummary: mockSurveySummary,
})
);
});
test("calls notFound if surveyId is not present in params", async () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: undefined }) as any;
render(await SurveyPage({ params }));
const jsx = await SurveyPage({ params });
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
expect(vi.mocked(notFound)).toHaveBeenCalled();
});
@@ -243,7 +271,7 @@ describe("SurveyPage", () => {
try {
// We need to await the component itself because it's an async component
const SurveyPageComponent = await SurveyPage({ params });
render(SurveyPageComponent);
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
} catch (e: any) {
expect(e.message).toBe("common.survey_not_found");
}
@@ -256,7 +284,7 @@ describe("SurveyPage", () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
try {
const SurveyPageComponent = await SurveyPage({ params });
render(SurveyPageComponent);
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
} catch (e: any) {
expect(e.message).toBe("common.user_not_found");
}

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

View File

@@ -3,7 +3,6 @@ import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -46,19 +45,13 @@ const Page = async (props: ResponsesPageProps) => {
throw new Error(t("common.project_not_found"));
}
const totalResponseCount = await getResponseCountBySurveyId(surveyId);
const locale = await findMatchingLocale();
return (
<div className="flex w-full justify-center">
<PageContentWrapper className="w-full">
<PageHeader pageTitle={survey.name}>
<SurveyAnalysisNavigation
survey={survey}
environmentId={environment.id}
activeId="responses"
initialTotalResponseCount={totalResponseCount}
/>
<SurveyAnalysisNavigation survey={survey} environmentId={environment.id} activeId="responses" />
</PageHeader>
<ResponsePage
environment={environment}

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

View File

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

View File

@@ -95,8 +95,6 @@ export const ITEMS_PER_PAGE = 30;
export const SURVEYS_PER_PAGE = 12;
export const RESPONSES_PER_PAGE = 25;
export const TEXT_RESPONSES_PER_PAGE = 5;
export const INSIGHTS_PER_PAGE = 10;
export const DOCUMENTS_PER_PAGE = 10;
export const MAX_RESPONSES_FOR_INSIGHT_GENERATION = 500;
export const MAX_OTHER_OPTION_LENGTH = 250;

View File

@@ -2,6 +2,7 @@ import "server-only";
import { cache } from "@/lib/cache";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
@@ -98,7 +99,7 @@ export const getResponseContact = (
if (!responsePrisma.contact) return null;
return {
id: responsePrisma.contact.id as string,
id: responsePrisma.contact.id,
userId: responsePrisma.contact.attributes.find((attribute) => attribute.attributeKey.key === "userId")
?.value as string,
};
@@ -291,7 +292,8 @@ export const getResponses = reactCache(
surveyId: string,
limit?: number,
offset?: number,
filterCriteria?: TResponseFilterCriteria
filterCriteria?: TResponseFilterCriteria,
cursor?: string
): Promise<TResponse[]> =>
cache(
async () => {
@@ -299,26 +301,39 @@ export const getResponses = reactCache(
[surveyId, ZId],
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()]
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
);
limit = limit ?? RESPONSES_PER_PAGE;
const survey = await getSurvey(surveyId);
if (!survey) return [];
try {
const whereClause: Prisma.ResponseWhereInput = {
surveyId,
...buildWhereClause(survey, filterCriteria),
};
// Add cursor condition for cursor-based pagination
if (cursor) {
whereClause.id = {
lt: cursor, // Get responses with ID less than cursor (for desc order)
};
}
const responses = await prisma.response.findMany({
where: {
surveyId,
...buildWhereClause(survey, filterCriteria),
},
where: whereClause,
select: responseSelection,
orderBy: [
{
createdAt: "desc",
},
{
id: "desc", // Secondary sort by ID for consistent pagination
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
take: limit,
skip: offset,
});
const transformedResponses: TResponse[] = await Promise.all(
@@ -340,7 +355,7 @@ export const getResponses = reactCache(
throw error;
}
},
[`getResponses-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
[`getResponses-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}-${cursor}`],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
}
@@ -360,19 +375,27 @@ export const getResponseDownloadUrl = async (
throw new ResourceNotFoundError("Survey", surveyId);
}
const environmentId = survey.environmentId as string;
const environmentId = survey.environmentId;
const accessType = "private";
const batchSize = 3000;
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
const pages = Math.ceil(responseCount / batchSize);
const responsesArray = await Promise.all(
Array.from({ length: pages }, (_, i) => {
return getResponses(surveyId, batchSize, i * batchSize, filterCriteria);
})
);
const responses = responsesArray.flat();
// Use cursor-based pagination instead of count + offset to avoid expensive queries
const responses: TResponse[] = [];
let cursor: string | undefined = undefined;
let hasMore = true;
while (hasMore) {
const batch = await getResponses(surveyId, batchSize, 0, filterCriteria, cursor);
responses.push(...batch);
if (batch.length < batchSize) {
hasMore = false;
} else {
// Use the last response's ID as cursor for next batch
cursor = batch[batch.length - 1].id;
}
}
const { metaDataFields, questions, hiddenFields, variables, userAttributes } = extractSurveyDetails(
survey,
@@ -442,8 +465,8 @@ export const getResponsesByEnvironmentId = reactCache(
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
take: limit,
skip: offset,
});
const transformedResponses: TResponse[] = await Promise.all(
@@ -478,8 +501,6 @@ export const updateResponse = async (
): Promise<TResponse> => {
validateInputs([responseId, ZId], [responseInput, ZResponseUpdateInput]);
try {
// const currentResponse = await getResponse(responseId);
// use direct prisma call to avoid cache issues
const currentResponse = await prisma.response.findUnique({
where: {

View File

@@ -238,14 +238,14 @@ describe("Tests for getResponseDownloadUrl service", () => {
expect(fileExtension).not.toEqual("xlsx");
});
test("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponseCountBySurveyId fails", async () => {
test("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponses fails", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.count.mockRejectedValue(errToThrow);
prisma.response.findMany.mockRejectedValue(errToThrow);
await expect(getResponseDownloadUrl(mockSurveyId, "csv")).rejects.toThrow(DatabaseError);
});

View File

@@ -1,52 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
export const useIntervalWhenFocused = (
callback: () => void,
intervalDuration: number,
isActive: boolean,
shouldExecuteImmediately = true
) => {
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const handleFocus = useCallback(() => {
if (isActive) {
if (shouldExecuteImmediately) {
// Execute the callback immediately when the tab comes into focus
callback();
}
// Set the interval to execute the callback every `intervalDuration` milliseconds
intervalRef.current = setInterval(() => {
callback();
}, intervalDuration);
}
}, [isActive, intervalDuration, callback, shouldExecuteImmediately]);
const handleBlur = () => {
// Clear the interval when the tab loses focus
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
useEffect(() => {
// Attach focus and blur event listeners
window.addEventListener("focus", handleFocus);
window.addEventListener("blur", handleBlur);
// Handle initial focus
handleFocus();
// Cleanup interval and event listeners when the component unmounts or dependencies change
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
window.removeEventListener("focus", handleFocus);
window.removeEventListener("blur", handleBlur);
};
}, [isActive, intervalDuration, handleFocus]);
};
export default useIntervalWhenFocused;

View File

@@ -9,10 +9,11 @@
"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_already_exists": "Diese E-Mail wird bereits verwendet",
"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"
@@ -88,6 +89,7 @@
"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",
@@ -1149,6 +1151,7 @@
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
@@ -1766,7 +1769,7 @@
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
"make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist",
"mobile_app": "Mobile App",
"no_response_matches_filter": "Keine Antwort entspricht deinem Filter",
"no_responses_found": "Keine Antworten gefunden",
"only_completed": "Nur vollständige Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",

View File

@@ -9,10 +9,11 @@
"continue_with_saml": "Continue with SAML SSO",
"email-change": {
"confirm_password_description": "Please confirm your password before changing your email address",
"email_already_exists": "This email is already in use",
"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"
@@ -88,6 +89,7 @@
"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",
@@ -984,7 +986,7 @@
"2000_monthly_identified_users": "2000 Monthly Identified Users",
"30000_monthly_identified_users": "30000 Monthly Identified Users",
"3_projects": "3 Projects",
"5000_monthly_responses": "5000 Monthly Responses",
"5000_monthly_responses": "5,000 Monthly Responses",
"5_projects": "5 Projects",
"7500_monthly_identified_users": "7500 Monthly Identified Users",
"advanced_targeting": "Advanced Targeting",
@@ -1149,6 +1151,7 @@
"disable_two_factor_authentication": "Disable two factor authentication",
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
"email_change_initiated": "Your email change request has been initiated.",
"enable_two_factor_authentication": "Enable two factor authentication",
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
@@ -1766,7 +1769,7 @@
"link_to_public_results_copied": "Link to public results copied",
"make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to",
"mobile_app": "Mobile app",
"no_response_matches_filter": "No response matches your filter",
"no_responses_found": "No responses found",
"only_completed": "Only completed",
"other_values_found": "Other values found",
"overall": "Overall",

View File

@@ -9,10 +9,11 @@
"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_already_exists": "Cet e-mail est déjà utilisé",
"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"
@@ -88,6 +89,7 @@
"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.",
@@ -984,7 +986,7 @@
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
"3_projects": "3 Projets",
"5000_monthly_responses": "5000 Réponses Mensuelles",
"5000_monthly_responses": "5,000 Réponses Mensuelles",
"5_projects": "5 Projets",
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
"advanced_targeting": "Ciblage Avancé",
@@ -1149,6 +1151,7 @@
"disable_two_factor_authentication": "Désactiver l'authentification à deux facteurs",
"disable_two_factor_authentication_description": "Si vous devez désactiver l'authentification à deux facteurs, nous vous recommandons de la réactiver dès que possible.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Chaque code de sauvegarde peut être utilisé exactement une fois pour accorder l'accès sans votre authentificateur.",
"email_change_initiated": "Votre demande de changement d'email a été initiée.",
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
"file_size_must_be_less_than_10mb": "La taille du fichier doit être inférieure à 10 Mo.",
@@ -1766,7 +1769,7 @@
"link_to_public_results_copied": "Lien vers les résultats publics copié",
"make_sure_the_survey_type_is_set_to": "Assurez-vous que le type d'enquête est défini sur",
"mobile_app": "Application mobile",
"no_response_matches_filter": "Aucune réponse ne correspond à votre filtre",
"no_responses_found": "Aucune réponse trouvée",
"only_completed": "Uniquement terminé",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",

View File

@@ -9,10 +9,11 @@
"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_already_exists": "Este e-mail já está em uso",
"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"
@@ -88,6 +89,7 @@
"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",
@@ -984,7 +986,7 @@
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5000 Respostas Mensais",
"5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
"advanced_targeting": "Mira Avançada",
@@ -1149,6 +1151,7 @@
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
@@ -1766,7 +1769,7 @@
"link_to_public_results_copied": "Link pros resultados públicos copiado",
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como",
"mobile_app": "app de celular",
"no_response_matches_filter": "Nenhuma resposta corresponde ao seu filtro",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Somente concluído",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",

View File

@@ -9,10 +9,11 @@
"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_already_exists": "Este email já está a ser utilizado",
"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"
@@ -88,6 +89,7 @@
"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",
@@ -984,7 +986,7 @@
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5000 Respostas Mensais",
"5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
"advanced_targeting": "Segmentação Avançada",
@@ -1149,6 +1151,7 @@
"disable_two_factor_authentication": "Desativar autenticação de dois fatores",
"disable_two_factor_authentication_description": "Se precisar de desativar a autenticação de dois fatores, recomendamos que a reative o mais rapidamente possível.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
"file_size_must_be_less_than_10mb": "O tamanho do ficheiro deve ser inferior a 10MB.",
@@ -1766,7 +1769,7 @@
"link_to_public_results_copied": "Link para resultados públicos copiado",
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de inquérito está definido para",
"mobile_app": "Aplicação móvel",
"no_response_matches_filter": "Nenhuma resposta corresponde ao seu filtro",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Apenas concluído",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",

View File

@@ -9,10 +9,11 @@
"continue_with_saml": "使用 SAML SSO 繼續",
"email-change": {
"confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼",
"email_already_exists": "此電子郵件地址已被使用",
"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": "舊 電子郵件"
@@ -88,6 +89,7 @@
"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": "請確認您的電子郵件地址",
@@ -1149,6 +1151,7 @@
"disable_two_factor_authentication": "停用雙重驗證",
"disable_two_factor_authentication_description": "如果您需要停用 2FA我們建議您盡快重新啟用它。",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每個備份碼只能使用一次,以便在沒有驗證器的情況下授予存取權限。",
"email_change_initiated": "您的 email 更改請求已啟動。",
"enable_two_factor_authentication": "啟用雙重驗證",
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
"file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。",
@@ -1766,7 +1769,7 @@
"link_to_public_results_copied": "已複製公開結果的連結",
"make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為",
"mobile_app": "行動應用程式",
"no_response_matches_filter": "沒有任何回應符合您的篩選器",
"no_responses_found": "找不到回應",
"only_completed": "僅已完成",
"other_values_found": "找到其他值",
"overall": "整體",

View File

@@ -1,8 +1,7 @@
import { validateInputs } from "@/lib/utils/validate";
import { Response } from "node-fetch";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { createBrevoCustomer } from "./brevo";
import { createBrevoCustomer, updateBrevoCustomer } from "./brevo";
vi.mock("@/lib/constants", () => ({
BREVO_API_KEY: "mock_api_key",
@@ -42,18 +41,87 @@ describe("createBrevoCustomer", () => {
await createBrevoCustomer({ id: "123", email: "test@example.com" });
expect(validateInputs).toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error sending user to Brevo");
});
test("should log the error response if fetch status is not 200", async () => {
test("should log the error response if fetch status is not 201", async () => {
const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockResolvedValueOnce(
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
);
await createBrevoCustomer({ id: "123", email: "test@example.com" });
expect(validateInputs).toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error sending user to Brevo");
});
});
describe("updateBrevoCustomer", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should return early if BREVO_API_KEY is not defined", async () => {
vi.doMock("@/lib/constants", () => ({
BREVO_API_KEY: undefined,
BREVO_LIST_ID: "123",
}));
const { updateBrevoCustomer } = await import("./brevo"); // Re-import to get the mocked version
const result = await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
expect(result).toBeUndefined();
expect(global.fetch).not.toHaveBeenCalled();
expect(validateInputs).not.toHaveBeenCalled();
});
test("should log an error if fetch fails", async () => {
const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed"));
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
expect(validateInputs).toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error updating user in Brevo");
});
test("should log the error response if fetch status is not 204", async () => {
const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockResolvedValueOnce(
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
);
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
expect(validateInputs).toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error updating user in Brevo");
});
test("should successfully update a Brevo customer", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce(new global.Response(null, { status: 204 }));
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
expect(global.fetch).toHaveBeenCalledWith(
"https://api.brevo.com/v3/contacts/user123?identifierType=ext_id",
expect.objectContaining({
method: "PUT",
headers: {
Accept: "application/json",
"api-key": "mock_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
attributes: { EMAIL: "test@example.com" },
}),
})
);
expect(validateInputs).toHaveBeenCalled();
});
});

View File

@@ -4,6 +4,26 @@ import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
type BrevoCreateContact = {
email?: string;
ext_id?: string;
attributes?: Record<string, string | string[]>;
emailBlacklisted?: boolean;
smsBlacklisted?: boolean;
listIds?: number[];
updateEnabled?: boolean;
smtpBlacklistSender?: string[];
};
type BrevoUpdateContact = {
attributes?: Record<string, string>;
emailBlacklisted?: boolean;
smsBlacklisted?: boolean;
listIds?: number[];
unlinkListIds?: number[];
smtpBlacklistSender?: string[];
};
export const createBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
if (!BREVO_API_KEY) {
return;
@@ -12,7 +32,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
validateInputs([id, ZId], [email, ZUserEmail]);
try {
const requestBody: any = {
const requestBody: BrevoCreateContact = {
email,
ext_id: id,
updateEnabled: false,
@@ -34,7 +54,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
body: JSON.stringify(requestBody),
});
if (res.status !== 200) {
if (res.status !== 201) {
const errorText = await res.text();
logger.error({ errorText }, "Error sending user to Brevo");
}
@@ -42,3 +62,36 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
logger.error(error, "Error sending user to Brevo");
}
};
export const updateBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
if (!BREVO_API_KEY) {
return;
}
validateInputs([id, ZId], [email, ZUserEmail]);
try {
const requestBody: BrevoUpdateContact = {
attributes: {
EMAIL: email,
},
};
const res = await fetch(`https://api.brevo.com/v3/contacts/${id}?identifierType=ext_id`, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"api-key": BREVO_API_KEY,
},
body: JSON.stringify(requestBody),
});
if (res.status !== 204) {
const errorText = await res.text();
logger.error({ errorText }, "Error updating user in Brevo");
}
} catch (error) {
logger.error(error, "Error updating user in Brevo");
}
};

View File

@@ -5,17 +5,15 @@ import { getTranslate } from "@/tolgee/server";
export const SignupWithoutVerificationSuccessPage = async () => {
const t = await getTranslate();
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.signup_without_verification_success.user_successfully_created")}
</h1>
<p className="text-center text-sm">
{t("auth.signup_without_verification_success.user_successfully_created_description")}
</p>
<hr className="my-4" />
<BackToLoginButton />
</FormWrapper>
</div>
<FormWrapper>
<h1 className="leading-2 mb-4 text-center font-bold">
{t("auth.signup_without_verification_success.user_successfully_created")}
</h1>
<p className="text-center text-sm">
{t("auth.signup_without_verification_success.user_successfully_created_description")}
</p>
<hr className="my-4" />
<BackToLoginButton />
</FormWrapper>
);
};

View File

@@ -2,6 +2,7 @@
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";
@@ -17,5 +18,6 @@ export const verifyEmailChangeAction = actionClient
if (!user) {
throw new Error("User not found or email update failed");
}
await updateBrevoCustomer({ id: user.id, email: user.email });
return user;
});

View File

@@ -28,7 +28,7 @@ describe("EmailChangeSignIn", () => {
test("shows loading state initially", () => {
render(<EmailChangeSignIn token="valid-token" />);
expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument();
expect(screen.getByText("auth.email-change.email_verification_loading")).toBeInTheDocument();
});
test("handles successful email change verification", async () => {

View File

@@ -5,7 +5,11 @@ import { useTranslate } from "@tolgee/react";
import { signOut } from "next-auth/react";
import { useEffect, useState } from "react";
export const EmailChangeSignIn = ({ token }: { token: string }) => {
interface EmailChangeSignInProps {
token: string;
}
export const EmailChangeSignIn = ({ token }: EmailChangeSignInProps) => {
const { t } = useTranslate();
const [status, setStatus] = useState<"success" | "error" | "loading">("loading");
@@ -37,18 +41,25 @@ export const EmailChangeSignIn = ({ token }: { token: string }) => {
}
}, [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" : ""}`}>
{status === "success"
? t("auth.email-change.email_change_success")
: t("auth.email-change.email_verification_failed")}
{text.heading[status]}
</h1>
<p className="text-center text-sm">
{status === "success"
? t("auth.email-change.email_change_success_description")
: t("auth.email-change.invalid_or_expired_token")}
</p>
<p className="text-center text-sm">{text.description[status]}</p>
<hr className="my-4" />
</>
);

View File

@@ -200,11 +200,11 @@ export const TeamSettingsModal = ({
open={open}
setOpen={setOpen}
noPadding
className="overflow-visible"
className="flex max-h-[90dvh] flex-col overflow-visible"
size="md"
hideCloseButton
closeOnOutsideClick={true}>
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<div className="sticky top-0 z-10 rounded-t-lg bg-slate-100">
<button
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
@@ -213,27 +213,27 @@ export const TeamSettingsModal = ({
<XIcon className="h-6 w-6 rounded-md bg-white" />
<span className="sr-only">Close</span>
</button>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div>
<H4>
{t("environments.settings.teams.team_name_settings_title", {
teamName: team.name,
})}
</H4>
<Muted className="text-slate-500">
{t("environments.settings.teams.team_settings_description")}
</Muted>
</div>
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div>
<H4>
{t("environments.settings.teams.team_name_settings_title", {
teamName: team.name,
})}
</H4>
<Muted className="text-slate-500">
{t("environments.settings.teams.team_settings_description")}
</Muted>
</div>
</div>
</div>
</div>
<FormProvider {...form}>
<form className="w-full" onSubmit={handleSubmit(handleUpdateTeam)}>
<div className="flex flex-col gap-6 p-6">
<div className="max-h-[500px] space-y-6 overflow-y-auto">
<form
className="flex w-full flex-grow flex-col overflow-hidden"
onSubmit={handleSubmit(handleUpdateTeam)}>
<div className="flex-grow space-y-6 overflow-y-auto p-6">
<div className="space-y-6">
<FormField
control={control}
name="name"
@@ -512,6 +512,8 @@ export const TeamSettingsModal = ({
/>
</div>
</div>
</div>
<div className="sticky bottom-0 z-10 border-slate-200 p-6">
<div className="flex justify-between">
<Button size="default" type="button" variant="outline" onClick={closeSettingsModal}>
{t("common.cancel")}

View File

@@ -78,6 +78,15 @@ describe("getMetadataForLinkSurvey", () => {
alternates: {
canonical: `/s/${mockSurveyId}`,
},
robots: {
index: false,
follow: true,
googleBot: {
index: false,
follow: true,
noimageindex: true,
},
},
});
});
@@ -153,6 +162,15 @@ describe("getMetadataForLinkSurvey", () => {
alternates: {
canonical: `/s/${mockSurveyId}`,
},
robots: {
index: false,
follow: true,
googleBot: {
index: false,
follow: true,
noimageindex: true,
},
},
});
});
@@ -183,6 +201,15 @@ describe("getMetadataForLinkSurvey", () => {
alternates: {
canonical: `/s/${mockSurveyId}`,
},
robots: {
index: false,
follow: true,
googleBot: {
index: false,
follow: true,
noimageindex: true,
},
},
});
});
});

View File

@@ -35,5 +35,14 @@ export const getMetadataForLinkSurvey = async (surveyId: string): Promise<Metada
alternates: {
canonical: canonicalPath,
},
robots: {
index: false,
follow: true,
googleBot: {
index: false,
follow: true,
noimageindex: true,
},
},
};
};

View File

@@ -6,7 +6,7 @@ import * as React from "react";
const DialogPortal = DialogPrimitive.Portal;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { blur?: boolean }
>(({ className, blur, ...props }, ref) => (
<DialogPrimitive.Overlay
@@ -35,7 +35,7 @@ const sizeClassName = {
};
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & DialogContentProps
>(
(

View File

@@ -138,7 +138,7 @@ test.describe("JS Package Test", async () => {
const impressionsCount = await page.getByRole("button", { name: "Impressions" }).innerText();
expect(impressionsCount).toEqual("Impressions\n\n1");
await expect(page.getByRole("link", { name: "Responses (1)" })).toBeVisible();
await expect(page.getByRole("link", { name: "Responses" })).toBeVisible();
await expect(page.getByRole("button", { name: "Completed 100%" })).toBeVisible();
await expect(page.getByText("1 Responses", { exact: true }).first()).toBeVisible();
await expect(page.getByText("CTR100%")).toBeVisible();

View File

@@ -180,25 +180,23 @@ tls:
default:
minVersion: VersionTLS12
cipherSuites:
# TLS 1.2 Ciphers
# TLS 1.2 strong ciphers
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
- TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
- TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
# TLS 1.3 Ciphers (These are automatically used for TLS 1.3 connections)
- TLS_AES_128_GCM_SHA256
- TLS_AES_256_GCM_SHA384
- TLS_CHACHA20_POLY1305_SHA256
# Fallback
- TLS_FALLBACK_SCSV
# TLS 1.3 ciphers are not configurable in Traefik; they are enabled by default
curvePreferences:
- CurveP521
- CurveP384
sniStrict: true
alpnProtocols:
- h2
- http/1.1
EOT
echo "💡 Created traefik.yaml and traefik-dynamic.yaml file."

View File

@@ -82,8 +82,6 @@ deployment:
env:
DOCKER_CRON_ENABLED:
value: "0"
RATE_LIMITING_DISABLED:
value: "1"
envFrom:
app-env:
nameSuffix: app-env

View File

@@ -193,6 +193,7 @@ export const setup = async (
if (environmentStateResponse.ok) {
environmentState = environmentStateResponse.data;
logger.debug(`Fetched ${environmentState.data.surveys.length.toString()} surveys from the backend`);
} else {
logger.error(
`Error fetching environment state: ${environmentStateResponse.error.code} - ${environmentStateResponse.error.responseMessage ?? ""}`
@@ -257,7 +258,7 @@ export const setup = async (
});
const surveyNames = filteredSurveys.map((s) => s.name);
logger.debug(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`);
logger.debug(`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`);
} catch {
logger.debug("Error during sync. Please try again.");
}
@@ -303,6 +304,7 @@ export const setup = async (
}
const environmentState = environmentStateResponse.data;
logger.debug(`Fetched ${environmentState.data.surveys.length.toString()} surveys from the backend`);
const filteredSurveys = filterSurveys(environmentState, userState);
config.update({
@@ -312,6 +314,9 @@ export const setup = async (
environment: environmentState,
filteredSurveys,
});
const surveyNames = filteredSurveys.map((s) => s.name);
logger.debug(`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`);
} catch (e) {
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
}

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { FileInput } from "./file-input";
// Mock auto-animate hook to prevent React useState errors in Preact tests
@@ -37,7 +37,7 @@ describe("FileInput", () => {
vi.clearAllMocks();
});
it("uploads valid file and calls callbacks", async () => {
test("uploads valid file and calls callbacks", async () => {
render(
<FileInput
surveyId="survey1"
@@ -62,7 +62,7 @@ describe("FileInput", () => {
});
});
it("alerts on invalid file type", async () => {
test("alerts on invalid file type", async () => {
render(
<FileInput
surveyId="survey1"
@@ -82,7 +82,7 @@ describe("FileInput", () => {
expect(onUploadCallback).not.toHaveBeenCalled();
});
it("alerts when multiple files not allowed", () => {
test("alerts when multiple files not allowed", () => {
render(
<FileInput
surveyId="survey1"
@@ -100,7 +100,7 @@ describe("FileInput", () => {
expect(onFileUpload).not.toHaveBeenCalled();
});
it("renders existing fileUrls and handles delete", () => {
test("renders existing fileUrls and handles delete", () => {
const initialUrls = ["fileA.txt", "fileB.txt"];
render(
<FileInput
@@ -121,7 +121,7 @@ describe("FileInput", () => {
expect(onUploadCallback).toHaveBeenCalledWith(["fileB.txt"]);
});
it("alerts when duplicate files selected", () => {
test("alerts when duplicate files selected", () => {
render(
<FileInput
surveyId="survey1"
@@ -140,7 +140,7 @@ describe("FileInput", () => {
);
});
it("handles native file upload event", async () => {
test("handles native file upload event", async () => {
// Import the actual constant to ensure we're using the right event name
const FILE_PICK_EVENT = "formbricks:onFilePick";
const nativeFile = { name: "native.txt", type: "text/plain", base64: btoa("native content") };
@@ -174,7 +174,7 @@ describe("FileInput", () => {
});
});
it("tests file size validation", async () => {
test("tests file size validation", async () => {
// Instead of testing the alert directly, test that large files don't get uploaded
const largeFile = createFile("large.txt", 2 * 1024 * 1024, "text/plain"); // 2MB file
const smallFile = createFile("small.txt", 500, "text/plain"); // 500B file
@@ -215,7 +215,7 @@ describe("FileInput", () => {
expect(onFileUpload).not.toHaveBeenCalled();
});
it("does not upload when no valid files are selected", async () => {
test("does not upload when no valid files are selected", async () => {
render(
<FileInput
surveyId="survey1"
@@ -235,7 +235,7 @@ describe("FileInput", () => {
expect(onFileUpload).not.toHaveBeenCalled();
});
it("does not upload duplicates", async () => {
test("does not upload duplicates", async () => {
render(
<FileInput
surveyId="survey1"
@@ -257,7 +257,7 @@ describe("FileInput", () => {
expect(onFileUpload).not.toHaveBeenCalled();
});
it("handles native file upload with size limits", async () => {
test("handles native file upload with size limits", async () => {
// Import the actual constant to ensure we're using the right event name
const FILE_PICK_EVENT = "formbricks:onFilePick";
@@ -297,7 +297,7 @@ describe("FileInput", () => {
);
});
it("handles case when no files remain after filtering", async () => {
test("handles case when no files remain after filtering", async () => {
// Import the actual constant
const FILE_PICK_EVENT = "formbricks:onFilePick";
@@ -331,7 +331,7 @@ describe("FileInput", () => {
expect(onUploadCallback).not.toHaveBeenCalled();
});
it("deletes a file", () => {
test("deletes a file", () => {
const initialUrls = ["fileA.txt", "fileB.txt"];
render(
<FileInput
@@ -352,7 +352,7 @@ describe("FileInput", () => {
expect(onUploadCallback).toHaveBeenCalledWith(["fileB.txt"]);
});
it("handles drag and drop", async () => {
test("handles drag and drop", async () => {
render(
<FileInput
surveyId="survey1"
@@ -389,7 +389,7 @@ describe("FileInput", () => {
});
});
it("handles file upload errors", async () => {
test("handles file upload errors", async () => {
// Mock the toBase64 function to fail by making onFileUpload throw an error
// during the Promise.all for uploadPromises
onFileUpload.mockImplementationOnce(() => {
@@ -419,7 +419,7 @@ describe("FileInput", () => {
});
});
it("enforces file limit", () => {
test("enforces file limit", () => {
render(
<FileInput
surveyId="survey1"

View File

@@ -5,7 +5,7 @@ interface LabelProps {
export function Label({ text, htmlForId }: Readonly<LabelProps>) {
return (
<label htmlFor={htmlForId} className="fb-text-subheading fb-font-normal fb-text-sm">
<label htmlFor={htmlForId} className="fb-text-subheading fb-font-normal fb-text-sm fb-block" dir="auto">
{text}
</label>
);

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { LanguageSwitch } from "./language-switch";
@@ -59,7 +59,7 @@ describe("LanguageSwitch", () => {
cleanup();
});
it("toggles dropdown and lists only enabled languages", () => {
test("toggles dropdown and lists only enabled languages", () => {
render(
<LanguageSwitch
surveyLanguages={surveyLanguages}
@@ -83,7 +83,7 @@ describe("LanguageSwitch", () => {
expect(screen.queryByText("fr")).toBeNull();
});
it("calls setSelectedLanguageCode and setFirstRender correctly", () => {
test("calls setSelectedLanguageCode and setFirstRender correctly", () => {
render(
<LanguageSwitch
surveyLanguages={surveyLanguages}

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ProgressBar } from "./progress-bar";
// Mock Progress component to capture progress prop
@@ -24,12 +24,12 @@ describe("ProgressBar", () => {
endings: [{ id: "end1" }],
};
it("renders 0 for start", () => {
test("renders 0 for start", () => {
render(<ProgressBar survey={baseSurvey} questionId="start" />);
expect(screen.getByTestId("progress")).toHaveTextContent("0");
});
it("renders correct progress for questions", () => {
test("renders correct progress for questions", () => {
// totalCards = questions.length + 1 = 3
render(<ProgressBar survey={baseSurvey} questionId="q1" />);
expect(screen.getByTestId("progress")).toHaveTextContent("0");
@@ -41,7 +41,7 @@ describe("ProgressBar", () => {
expect(screen.getByTestId("progress")).toHaveTextContent((1 / 3).toString());
});
it("renders 1 for ending card", () => {
test("renders 1 for ending card", () => {
render(<ProgressBar survey={baseSurvey} questionId="end1" />);
expect(screen.getByTestId("progress")).toHaveTextContent("1");
});

View File

@@ -1,13 +1,13 @@
import { convertToEmbedUrl } from "@/lib/video-upload";
import { cleanup, render, screen } from "@testing-library/preact";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, test } from "vitest";
import { QuestionMedia } from "./question-media";
describe("QuestionMedia", () => {
afterEach(() => {
cleanup();
});
it("renders image correctly", () => {
test("renders image correctly", () => {
const imgUrl = "https://example.com/test.jpg";
const altText = "Test Image";
render(<QuestionMedia imgUrl={imgUrl} altText={altText} />);
@@ -17,7 +17,7 @@ describe("QuestionMedia", () => {
expect(img.getAttribute("src")).toBe(imgUrl);
});
it("renders YouTube video correctly", () => {
test("renders YouTube video correctly", () => {
const videoUrl = "https://www.youtube.com/watch?v=test123";
render(<QuestionMedia videoUrl={videoUrl} />);
@@ -26,7 +26,7 @@ describe("QuestionMedia", () => {
expect(iframe.getAttribute("src")).toBe(videoUrl + "?controls=0");
});
it("renders Vimeo video correctly", () => {
test("renders Vimeo video correctly", () => {
const videoUrl = "https://vimeo.com/test123";
render(<QuestionMedia videoUrl={videoUrl} />);
@@ -38,7 +38,7 @@ describe("QuestionMedia", () => {
);
});
it("renders Loom video correctly", () => {
test("renders Loom video correctly", () => {
const videoUrl = "https://www.loom.com/share/test123";
render(<QuestionMedia videoUrl={videoUrl} />);
@@ -49,14 +49,14 @@ describe("QuestionMedia", () => {
);
});
it("renders loading state initially", () => {
test("renders loading state initially", () => {
const { container } = render(<QuestionMedia imgUrl="https://example.com/test.jpg" />);
const loadingElement = container.querySelector(".fb-animate-pulse");
expect(loadingElement).toBeTruthy();
});
it("renders expand button with correct link", () => {
test("renders expand button with correct link", () => {
const imgUrl = "https://example.com/test.jpg";
render(<QuestionMedia imgUrl={imgUrl} />);
@@ -67,7 +67,7 @@ describe("QuestionMedia", () => {
expect(expandLink.getAttribute("rel")).toBe("noreferrer");
});
it("handles loading completion", async () => {
test("handles loading completion", async () => {
const imgUrl = "https://example.com/test.jpg";
const { container } = render(<QuestionMedia imgUrl={imgUrl} />);
@@ -81,14 +81,14 @@ describe("QuestionMedia", () => {
expect(loadingElements.length).toBe(0);
});
it("renders nothing when no media URLs are provided", () => {
test("renders nothing when no media URLs are provided", () => {
const { container } = render(<QuestionMedia />);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("iframe")).toBeNull();
});
it("uses default alt text when not provided", () => {
test("uses default alt text when not provided", () => {
const imgUrl = "https://example.com/test.jpg";
render(<QuestionMedia imgUrl={imgUrl} />);
@@ -96,7 +96,7 @@ describe("QuestionMedia", () => {
expect(img).toBeTruthy();
});
it("handles video loading state", async () => {
test("handles video loading state", async () => {
const videoUrl = "https://www.youtube.com/watch?v=test123";
const { container } = render(<QuestionMedia videoUrl={videoUrl} />);
@@ -115,7 +115,7 @@ describe("QuestionMedia", () => {
expect(loadingElements.length).toBe(0);
});
it("renders expand button with correct video link", () => {
test("renders expand button with correct video link", () => {
const videoUrl = "https://www.youtube.com/watch?v=test123";
render(<QuestionMedia videoUrl={videoUrl} />);
@@ -126,7 +126,7 @@ describe("QuestionMedia", () => {
expect(expandLink.getAttribute("rel")).toBe("noreferrer");
});
it("handles regular video URL without parameters", () => {
test("handles regular video URL without parameters", () => {
const videoUrl = "https://example.com/video.mp4";
render(<QuestionMedia videoUrl={videoUrl} />);

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { RenderSurvey } from "./render-survey";
// Stub SurveyContainer to render children and capture props
@@ -31,7 +31,7 @@ describe("RenderSurvey", () => {
vi.useRealTimers();
});
it("renders with default props and handles close", () => {
test("renders with default props and handles close", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
@@ -63,7 +63,7 @@ describe("RenderSurvey", () => {
expect(onClose).toHaveBeenCalled();
});
it("onFinished skips close if redirectToUrl", () => {
test("onFinished skips close if redirectToUrl", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "redirectToUrl" }] } as any;
@@ -88,7 +88,7 @@ describe("RenderSurvey", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("onFinished closes after delay for non-redirect endings", () => {
test("onFinished closes after delay for non-redirect endings", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
@@ -108,14 +108,14 @@ describe("RenderSurvey", () => {
const props = surveySpy.mock.calls[0][0];
props.onFinished();
// after first delay (survey finish), close schedules another delay
// wait for the onFinished timeout (3s) then the close timeout (1s)
vi.advanceTimersByTime(3000);
expect(onClose).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(onClose).toHaveBeenCalled();
});
it("onFinished does not auto-close when inline mode", () => {
test("onFinished does not auto-close when inline mode", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [] } as any;
@@ -139,4 +139,103 @@ describe("RenderSurvey", () => {
vi.advanceTimersByTime(5000);
expect(onClose).not.toHaveBeenCalled();
});
test("close clears any pending onFinished timeout", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
const { unmount } = render(
(
<RenderSurvey
survey={survey}
onClose={onClose}
onFinished={onFinished}
styling={{}}
isBrandingEnabled={false}
languageCode="en"
/>
) as any
);
const props = surveySpy.mock.calls[0][0];
// schedule the onFinished-based close
props.onFinished();
// immediately manually close, which should clear that pending timeout
props.onClose();
// manual close schedules onClose in 1s
vi.advanceTimersByTime(1000);
expect(onClose).toHaveBeenCalledTimes(1);
// advance past the original onFinished timeout (3s) + its would-be close delay
vi.advanceTimersByTime(4000);
// still only the one manual-close call
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
test("double close only schedules one onClose", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
render(
(
<RenderSurvey
survey={survey}
onClose={onClose}
onFinished={onFinished}
styling={{}}
isBrandingEnabled={false}
languageCode="en"
/>
) as any
);
const props = surveySpy.mock.calls[0][0];
// first close schedules user onClose at t=1000
props.onClose();
vi.advanceTimersByTime(500);
// before the first fires, call close again and clear it
props.onClose();
// advance to t=1000: first one would have fired if not cleared
vi.advanceTimersByTime(500);
expect(onClose).not.toHaveBeenCalled();
// advance to t=1500: only the second close should now fire
vi.advanceTimersByTime(500);
expect(onClose).toHaveBeenCalledTimes(1);
});
test("cleanup on unmount clears pending timers (useEffect)", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
const { unmount } = render(
(
<RenderSurvey
survey={survey}
onClose={onClose}
onFinished={onFinished}
styling={{}}
isBrandingEnabled={false}
languageCode="en"
/>
) as any
);
const props = surveySpy.mock.calls[0][0];
// schedule both timeouts
props.onFinished();
props.onClose();
// unmount should clear both pending timeouts
unmount();
// advance well past all delays
vi.advanceTimersByTime(10000);
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -1,20 +1,49 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { SurveyContainer } from "../wrappers/survey-container";
import { Survey } from "./survey";
export function RenderSurvey(props: SurveyContainerProps) {
const [isOpen, setIsOpen] = useState(true);
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const close = () => {
if (onFinishedTimeoutRef.current) {
clearTimeout(onFinishedTimeoutRef.current);
onFinishedTimeoutRef.current = null;
}
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
setIsOpen(false);
setTimeout(() => {
closeTimeoutRef.current = setTimeout(() => {
if (props.onClose) {
props.onClose();
}
}, 1000); // wait for animation to finish}
}, 1000);
};
useEffect(() => {
return () => {
if (onFinishedTimeoutRef.current) {
clearTimeout(onFinishedTimeoutRef.current);
}
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
if (!isOpen) {
return null;
}
return (
<SurveyContainer
mode={props.mode ?? "modal"}
@@ -32,7 +61,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
props.onFinished?.();
if (props.mode !== "inline") {
setTimeout(
onFinishedTimeoutRef.current = setTimeout(
() => {
const firstEnabledEnding = props.survey.endings?.[0];
if (firstEnabledEnding?.type !== "redirectToUrl") {

View File

@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { type TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { ResponseErrorComponent } from "./response-error-component";
@@ -37,7 +37,7 @@ describe("ResponseErrorComponent", () => {
q2: "Answer 2",
};
it("renders error message and retry button", () => {
test("renders error message and retry button", () => {
render(
<ResponseErrorComponent questions={mockQuestions} responseData={mockResponseData} onRetry={() => {}} />
);
@@ -47,7 +47,7 @@ describe("ResponseErrorComponent", () => {
expect(screen.getByText("Retry")).toBeDefined();
});
it("displays questions and responses correctly", () => {
test("displays questions and responses correctly", () => {
render(
<ResponseErrorComponent questions={mockQuestions} responseData={mockResponseData} onRetry={() => {}} />
);
@@ -63,7 +63,7 @@ describe("ResponseErrorComponent", () => {
expect(answers[1].textContent).toBe("Answer 2");
});
it("calls onRetry when retry button is clicked", () => {
test("calls onRetry when retry button is clicked", () => {
const mockOnRetry = vi.fn();
render(
<ResponseErrorComponent
@@ -79,7 +79,7 @@ describe("ResponseErrorComponent", () => {
expect(mockOnRetry).toHaveBeenCalledTimes(1);
});
it("handles missing responses gracefully", () => {
test("handles missing responses gracefully", () => {
const partialResponseData = {
q1: "Answer 1",
};

View File

@@ -1,5 +1,5 @@
import { render } from "@testing-library/preact";
import { describe, expect, it } from "vitest";
import { describe, expect, test } from "vitest";
import {
ConfusedFace,
FrowningFace,
@@ -34,7 +34,7 @@ describe("Smiley Components", () => {
components.forEach(({ name, Component }) => {
describe(name, () => {
it("renders with default props", () => {
test("renders with default props", () => {
const { container } = render(<Component />);
const svg = container.querySelector("svg");
expect(svg).to.exist;
@@ -53,7 +53,7 @@ describe("Smiley Components", () => {
expect(paths.length).to.be.greaterThan(0);
});
it("applies custom props correctly", () => {
test("applies custom props correctly", () => {
const { container } = render(
<Component {...testProps} style={{ stroke: "red", strokeWidth: 3, fill: "blue" }} />
);
@@ -65,7 +65,7 @@ describe("Smiley Components", () => {
expect(circle?.getAttribute("style")).to.include("fill: blue");
});
it("maintains accessibility", () => {
test("maintains accessibility", () => {
const { container } = render(<Component aria-label={`${name} emoji`} data-testid="smiley-svg" />);
const svg = container.querySelector("[data-testid='smiley-svg']");
expect(svg).to.exist;

View File

@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
import { JSX } from "preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { Survey } from "./survey";
@@ -243,7 +243,7 @@ describe("Survey", () => {
vi.clearAllMocks();
});
it("renders the survey with welcome card initially", () => {
test("renders the survey with welcome card initially", () => {
render(
<Survey
survey={mockSurvey}
@@ -272,7 +272,7 @@ describe("Survey", () => {
expect(onDisplayMock).toHaveBeenCalled();
});
it("handles question submission and navigation", async () => {
test("handles question submission and navigation", async () => {
// For this test, we'll use startAtQuestionId to force rendering the question card
render(
<Survey
@@ -317,7 +317,7 @@ describe("Survey", () => {
});
});
it("renders branding when enabled", () => {
test("renders branding when enabled", () => {
render(
<Survey
survey={mockSurvey}
@@ -345,7 +345,7 @@ describe("Survey", () => {
expect(screen.getByTestId("formbricks-branding")).toBeInTheDocument();
});
it("renders progress bar by default", () => {
test("renders progress bar by default", () => {
render(
<Survey
survey={mockSurvey}
@@ -373,7 +373,7 @@ describe("Survey", () => {
expect(screen.getByTestId("progress-bar")).toBeInTheDocument();
});
it("hides progress bar when hideProgressBar is true", () => {
test("hides progress bar when hideProgressBar is true", () => {
render(
<Survey
survey={mockSurvey}
@@ -402,7 +402,7 @@ describe("Survey", () => {
expect(screen.queryByTestId("progress-bar")).not.toBeInTheDocument();
});
it("handles file uploads in preview mode", async () => {
test("handles file uploads in preview mode", async () => {
// The createDisplay function in the Survey component calls onDisplayCreated
// We need to make sure it resolves before checking if onDisplayCreated was called
@@ -444,7 +444,7 @@ describe("Survey", () => {
expect(onFileUploadMock).toBeDefined();
});
it("calls onResponseCreated in preview mode", async () => {
test("calls onResponseCreated in preview mode", async () => {
// This test verifies that onResponseCreated is called in preview mode
// when a question is submitted in preview mode
@@ -489,7 +489,7 @@ describe("Survey", () => {
expect(onResponseCreatedMock).toHaveBeenCalled();
});
it("adds response to queue with correct user and contact IDs", async () => {
test("adds response to queue with correct user and contact IDs", async () => {
// This test is focused on the functionality in lines 445-472 of survey.tsx
// We will verify that the 'add' method of the ResponseQueue (mockRQAdd) is called.
// No need to import ResponseQueue or get mock instances dynamically here.
@@ -541,7 +541,7 @@ describe("Survey", () => {
);
});
it("makes questions required based on logic actions", async () => {
test("makes questions required based on logic actions", async () => {
// This test is focused on the functionality in lines 409-411 of survey.tsx
// We'll customize the performActions mock to return requiredQuestionIds
@@ -609,7 +609,7 @@ describe("Survey", () => {
expect(performActions).toHaveBeenCalled();
});
it("starts at a specific question when startAtQuestionId is provided", () => {
test("starts at a specific question when startAtQuestionId is provided", () => {
render(
<Survey
survey={mockSurvey}

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen } from "@testing-library/preact";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WelcomeCard } from "./welcome-card";
describe("WelcomeCard", () => {
@@ -35,7 +35,7 @@ describe("WelcomeCard", () => {
variablesData: {},
};
it("renders welcome card with basic content", () => {
test("renders welcome card with basic content", () => {
const { container } = render(<WelcomeCard {...defaultProps} />);
expect(container.querySelector(".fb-text-heading")).toHaveTextContent("Welcome to our survey");
@@ -43,7 +43,7 @@ describe("WelcomeCard", () => {
expect(container.querySelector("button")).toHaveTextContent("Start");
});
it("shows time to complete when timeToFinish is true", () => {
test("shows time to complete when timeToFinish is true", () => {
const { container } = render(<WelcomeCard {...defaultProps} />);
const timeDisplay = container.querySelector(".fb-text-subheading");
@@ -51,14 +51,14 @@ describe("WelcomeCard", () => {
expect(timeDisplay).toHaveTextContent(/Takes/);
});
it("shows response count when showResponseCount is true and count > 3", () => {
test("shows response count when showResponseCount is true and count > 3", () => {
const { container } = render(<WelcomeCard {...defaultProps} responseCount={10} />);
const responseText = container.querySelector(".fb-text-xs");
expect(responseText).toHaveTextContent(/10 people responded/);
});
it("handles submit button click", () => {
test("handles submit button click", () => {
const { container } = render(<WelcomeCard {...defaultProps} />);
const button = container.querySelector("button");
@@ -68,7 +68,7 @@ describe("WelcomeCard", () => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith({ welcomeCard: "clicked" }, {});
});
it("handles Enter key press when survey type is link", () => {
test("handles Enter key press when survey type is link", () => {
render(<WelcomeCard {...defaultProps} />);
fireEvent.keyDown(document, { key: "Enter" });
@@ -76,14 +76,14 @@ describe("WelcomeCard", () => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith({ welcomeCard: "clicked" }, {});
});
it("does not show response count when count <= 3", () => {
test("does not show response count when count <= 3", () => {
const { container } = render(<WelcomeCard {...defaultProps} responseCount={3} />);
const responseText = container.querySelector(".fb-text-xs");
expect(responseText).not.toHaveTextContent(/3 people responded/);
});
it("shows company logo when fileUrl is provided", () => {
test("shows company logo when fileUrl is provided", () => {
const propsWithLogo = {
...defaultProps,
fileUrl: "https://example.com/logo.png",
@@ -96,7 +96,7 @@ describe("WelcomeCard", () => {
expect(logo).toHaveAttribute("src", "https://example.com/logo.png");
});
it("calculates time to complete correctly for different survey lengths", () => {
test("calculates time to complete correctly for different survey lengths", () => {
// Test short survey (2 questions)
const { container } = render(<WelcomeCard {...defaultProps} />);
const timeDisplay = container.querySelector(".fb-text-subheading");
@@ -121,7 +121,7 @@ describe("WelcomeCard", () => {
expect(longTimeDisplay).toHaveTextContent(/Takes 6\+ minutes/);
});
it("shows both time and response count when both flags are true", () => {
test("shows both time and response count when both flags are true", () => {
const { container } = render(
<WelcomeCard
{...defaultProps}
@@ -141,7 +141,7 @@ describe("WelcomeCard", () => {
expect(textDisplay).toHaveTextContent(/Takes.*10 people responded/);
});
it("handles missing optional props gracefully", () => {
test("handles missing optional props gracefully", () => {
const minimalProps = {
...defaultProps,
headline: undefined,
@@ -157,7 +157,7 @@ describe("WelcomeCard", () => {
expect(container.querySelector("button")).toBeInTheDocument();
});
it("handles Enter key press correctly based on survey type and isCurrent", () => {
test("handles Enter key press correctly based on survey type and isCurrent", () => {
const mockOnSubmit = vi.fn();
// Test when survey is not link type
const { rerender, unmount } = render(
@@ -177,7 +177,7 @@ describe("WelcomeCard", () => {
unmount();
});
it("prevents default on Enter key in button", () => {
test("prevents default on Enter key in button", () => {
const { container } = render(<WelcomeCard {...defaultProps} />);
const button = container.querySelector("button");
const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true });
@@ -188,7 +188,7 @@ describe("WelcomeCard", () => {
expect(event.preventDefault).toHaveBeenCalled();
});
it("properly cleans up event listeners on unmount", () => {
test("properly cleans up event listeners on unmount", () => {
const { unmount } = render(<WelcomeCard {...defaultProps} />);
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
@@ -198,7 +198,7 @@ describe("WelcomeCard", () => {
removeEventListenerSpy.mockRestore();
});
it("handles response counts at boundary conditions", () => {
test("handles response counts at boundary conditions", () => {
// Test with exactly 3 responses (boundary)
const { container: container3 } = render(<WelcomeCard {...defaultProps} responseCount={3} />);
expect(container3.querySelector(".fb-text-xs")).not.toHaveTextContent(/3 people responded/);
@@ -208,7 +208,7 @@ describe("WelcomeCard", () => {
expect(container4.querySelector(".fb-text-xs")).toHaveTextContent(/4 people responded/);
});
it("handles time calculation edge cases", () => {
test("handles time calculation edge cases", () => {
// Test with no questions
const emptyQuestionsSurvey = {
...mockSurvey,
@@ -231,7 +231,7 @@ describe("WelcomeCard", () => {
expect(boundaryContainer.querySelector(".fb-text-subheading")).toHaveTextContent(/Takes 6 minutes/);
});
it("correctly processes localized content", () => {
test("correctly processes localized content", () => {
const localizedProps = {
...defaultProps,
headline: { default: "Welcome", es: "Bienvenido" },
@@ -247,7 +247,7 @@ describe("WelcomeCard", () => {
expect(container.querySelector("button")).toHaveTextContent("Comenzar");
});
it("handles variable replacement in content", () => {
test("handles variable replacement in content", () => {
const propsWithVariables = {
...defaultProps,
headline: { default: "Welcome #recall:name/fallback:Guest#" },

View File

@@ -65,6 +65,7 @@ vi.mock("@/lib/utils", () => ({
}
return choices.map((choice: { id: string }) => choice.id);
}),
isRTL: vi.fn((text) => text.includes("rtl")),
}));
describe("MultipleChoiceMultiQuestion", () => {

View File

@@ -6,7 +6,7 @@ import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { cn, getShuffledChoicesIds, isRTL } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -140,6 +140,12 @@ export function MultipleChoiceMultiQuestion({
: question.required;
};
const otherOptionDir = useMemo(() => {
const placeholder = getLocalizedValue(question.otherOptionPlaceholder, languageCode);
if (!otherValue) return isRTL(placeholder) ? "rtl" : "ltr";
return "auto";
}, [languageCode, question.otherOptionPlaceholder, otherValue]);
return (
<form
key={question.id}
@@ -267,7 +273,7 @@ export function MultipleChoiceMultiQuestion({
{otherSelected ? (
<input
ref={otherSpecify}
dir="auto"
dir={otherOptionDir}
id={`${otherOption.id}-label`}
maxLength={250}
name={question.id}
@@ -279,7 +285,9 @@ export function MultipleChoiceMultiQuestion({
}}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={
getLocalizedValue(question.otherOptionPlaceholder, languageCode) ?? "Please specify"
getLocalizedValue(question.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(question.otherOptionPlaceholder, languageCode)
: "Please specify"
}
required={question.required}
aria-labelledby={`${otherOption.id}-label`}

View File

@@ -62,6 +62,7 @@ vi.mock("@/lib/ttc", () => ({
vi.mock("@/lib/utils", () => ({
cn: vi.fn((...args) => args.filter(Boolean).join(" ")),
getShuffledChoicesIds: vi.fn((choices) => choices.map((choice: any) => choice.id)),
isRTL: vi.fn((text) => text.includes("rtl")),
}));
// Test data

View File

@@ -6,7 +6,7 @@ import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { cn, getShuffledChoicesIds, isRTL } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -100,6 +100,12 @@ export function MultipleChoiceSingleQuestion({
}
}, [otherSelected]);
const otherOptionDir = useMemo(() => {
const placeholder = getLocalizedValue(question.otherOptionPlaceholder, languageCode);
if (!value) return isRTL(placeholder) ? "rtl" : "ltr";
return "auto";
}, [languageCode, question.otherOptionPlaceholder, value]);
return (
<form
key={question.id}
@@ -196,7 +202,7 @@ export function MultipleChoiceSingleQuestion({
document.getElementById(otherOption.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm">
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
<input
tabIndex={-1}
dir="auto"
@@ -212,10 +218,7 @@ export function MultipleChoiceSingleQuestion({
}}
checked={otherSelected}
/>
<span
id={`${otherOption.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(otherOption.label, languageCode)}
</span>
</span>
@@ -223,7 +226,7 @@ export function MultipleChoiceSingleQuestion({
<input
ref={otherSpecify}
id={`${otherOption.id}-label`}
dir="auto"
dir={otherOptionDir}
name={question.id}
pattern=".*\S+.*"
value={value}

View File

@@ -6,8 +6,9 @@ import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { isRTL } from "@/lib/utils";
import { type RefObject } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -80,6 +81,12 @@ export function OpenTextQuestion({
onSubmit({ [question.id]: value }, updatedTtc);
};
const dir = useMemo(() => {
const placeholder = getLocalizedValue(question.placeholder, languageCode);
if (!value) return isRTL(placeholder) ? "rtl" : "ltr";
return "auto";
}, [value, languageCode, question.placeholder]);
return (
<form key={question.id} onSubmit={handleOnSubmit} className="fb-w-full">
<ScrollableContainer>
@@ -105,7 +112,7 @@ export function OpenTextQuestion({
name={question.id}
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir="auto"
dir={dir}
step="any"
required={question.required}
value={value ? value : ""}
@@ -135,7 +142,7 @@ export function OpenTextQuestion({
aria-label="textarea"
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir="auto"
dir={dir}
required={question.required}
value={value}
onInput={(e) => {

View File

@@ -199,7 +199,7 @@ export function RankingQuestion({
)}>
{(idx + 1).toString()}
</span>
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm">
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm fb-text-start" dir="auto">
{getLocalizedValue(item.label, languageCode)}
</div>
</button>

View File

@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "preact/hooks";
import { useEffect, useRef } from "preact/hooks";
import { type TPlacement } from "@formbricks/types/common";
interface SurveyContainerProps {
@@ -21,16 +21,10 @@ export function SurveyContainer({
clickOutside,
isOpen = true,
}: SurveyContainerProps) {
const [show, setShow] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const isCenter = placement === "center";
const isModal = mode === "modal";
useEffect(() => {
setShow(isOpen);
}, [isOpen]);
useEffect(() => {
if (!isModal) return;
if (!isCenter) return;
@@ -38,7 +32,7 @@ export function SurveyContainer({
const handleClickOutside = (e: MouseEvent) => {
if (
clickOutside &&
show &&
isOpen &&
modalRef.current &&
!(modalRef.current as HTMLElement).contains(e.target as Node) &&
onClose
@@ -50,7 +44,7 @@ export function SurveyContainer({
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [show, clickOutside, onClose, isCenter, isModal]);
}, [clickOutside, onClose, isCenter, isModal, isOpen]);
const getPlacementStyle = (placement: TPlacement): string => {
switch (placement) {
@@ -69,7 +63,7 @@ export function SurveyContainer({
}
};
if (!show) return null;
if (!isOpen) return null;
if (!isModal) {
return (
@@ -98,7 +92,7 @@ export function SurveyContainer({
ref={modalRef}
className={cn(
getPlacementStyle(placement),
show ? "fb-opacity-100" : "fb-opacity-0",
isOpen ? "fb-opacity-100" : "fb-opacity-0",
"fb-rounded-custom fb-pointer-events-auto fb-absolute fb-bottom-0 fb-h-fit fb-w-full fb-overflow-visible fb-bg-white fb-shadow-lg fb-transition-all fb-duration-500 fb-ease-in-out sm:fb-m-4 sm:fb-max-w-sm"
)}>
<div>{children}</div>

View File

@@ -168,3 +168,12 @@ export const getDefaultLanguageCode = (survey: TJsEnvironmentStateSurvey): strin
// Function to convert file extension to its MIME type
export const getMimeType = (extension: TAllowedFileExtension): string => mimeTypes[extension];
/**
* Returns true if the string contains any RTL character.
* @param text The input string to test
*/
export function isRTL(text: string): boolean {
const rtlCharRegex = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
return rtlCharRegex.test(text);
}