fix: performance issues on survey summary (#5885)

Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
Matti Nannt
2025-05-27 14:07:31 +02:00
committed by GitHub
parent c53f030b24
commit 645fc863aa
36 changed files with 1023 additions and 401 deletions
+61
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
+41
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
+334
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
@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---
+52
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
@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---
+282
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"
@@ -75,7 +75,6 @@ export const getSurveySummaryAction = authenticatedActionClient
},
],
});
return getSurveySummary(parsedInput.surveyId, parsedInput.filterCriteria);
});
@@ -5,7 +5,6 @@ import {
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
@@ -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");
});
});
});
@@ -1,105 +1,30 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
getResponseCountAction,
revalidateSurveyIdPath,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getResponseCountBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { useTranslate } from "@tolgee/react";
import { InboxIcon, PresentationIcon } from "lucide-react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, usePathname } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyAnalysisNavigationProps {
environmentId: string;
survey: TSurvey;
initialTotalResponseCount: number | null;
activeId: string;
}
export const SurveyAnalysisNavigation = ({
environmentId,
survey,
initialTotalResponseCount,
activeId,
}: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslate();
const params = useParams();
const [filteredResponseCount, setFilteredResponseCount] = useState<number | null>(null);
const [totalResponseCount, setTotalResponseCount] = useState<number | null>(initialTotalResponseCount);
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const searchParams = useSearchParams();
const isShareEmbedModalOpen = searchParams.get("share") === "true";
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
const { selectedFilter, dateRange } = useResponseFilter();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
[selectedFilter, dateRange, survey]
);
const latestFiltersRef = useRef(filters);
latestFiltersRef.current = filters;
const getResponseCount = () => {
if (isSharingPage) return getResponseCountBySurveySharingKeyAction({ sharingKey });
return getResponseCountAction({ surveyId: survey.id });
};
const fetchResponseCount = async () => {
const count = await getResponseCount();
const responseCount = count?.data ?? 0;
setTotalResponseCount(responseCount);
};
const getFilteredResponseCount = useCallback(() => {
if (isSharingPage)
return getResponseCountBySurveySharingKeyAction({
sharingKey,
filterCriteria: latestFiltersRef.current,
});
return getResponseCountAction({ surveyId: survey.id, filterCriteria: latestFiltersRef.current });
}, [isSharingPage, sharingKey, survey.id]);
const fetchFilteredResponseCount = useCallback(async () => {
const count = await getFilteredResponseCount();
const responseCount = count?.data ?? 0;
setFilteredResponseCount(responseCount);
}, [getFilteredResponseCount]);
useEffect(() => {
fetchFilteredResponseCount();
}, [filters, isSharingPage, sharingKey, survey.id, fetchFilteredResponseCount]);
useIntervalWhenFocused(
() => {
fetchResponseCount();
fetchFilteredResponseCount();
},
10000,
!isShareEmbedModalOpen,
false
);
const getResponseCountString = () => {
if (totalResponseCount === null) return "";
if (filteredResponseCount === null) return `(${totalResponseCount})`;
const totalCount = Math.max(totalResponseCount, filteredResponseCount);
if (totalCount === filteredResponseCount) return `(${totalCount})`;
return `(${filteredResponseCount} of ${totalCount})`;
};
const navigation = [
{
@@ -114,7 +39,7 @@ export const SurveyAnalysisNavigation = ({
},
{
id: "responses",
label: `${t("common.responses")} ${getResponseCountString()}`,
label: t("common.responses"),
icon: <InboxIcon className="h-5 w-5" />,
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),
@@ -162,7 +162,6 @@ describe("ResponsePage", () => {
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
});
expect(mockGetResponseCountAction).toHaveBeenCalled();
expect(mockGetResponsesAction).toHaveBeenCalled();
});
@@ -179,7 +178,6 @@ describe("ResponsePage", () => {
await waitFor(() => {
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
expect(mockGetResponseCountBySurveySharingKeyAction).toHaveBeenCalled();
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
});
@@ -297,8 +295,7 @@ describe("ResponsePage", () => {
rerender(<ResponsePage {...defaultProps} />);
await waitFor(() => {
// Should fetch count and responses again due to filter change
expect(mockGetResponseCountAction).toHaveBeenCalledTimes(2);
// Should fetch responses again due to filter change
expect(mockGetResponsesAction).toHaveBeenCalledTimes(2);
// Check if it fetches with offset 0 (first page)
expect(mockGetResponsesAction).toHaveBeenLastCalledWith(
@@ -1,18 +1,12 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
getResponseCountAction,
getResponsesAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import {
getResponseCountBySurveySharingKeyAction,
getResponsesBySurveySharingKeyAction,
} from "@/app/share/[sharingKey]/actions";
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -49,7 +43,6 @@ export const ResponsePage = ({
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const [responseCount, setResponseCount] = useState<number | null>(null);
const [responses, setResponses] = useState<TResponse[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
@@ -97,9 +90,6 @@ export const ResponsePage = ({
const deleteResponses = (responseIds: string[]) => {
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
if (responseCount) {
setResponseCount(responseCount - responseIds.length);
}
};
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
@@ -118,29 +108,6 @@ export const ResponsePage = ({
}
}, [searchParams, resetState]);
useEffect(() => {
const handleResponsesCount = async () => {
let responseCount = 0;
if (isSharingPage) {
const responseCountActionResponse = await getResponseCountBySurveySharingKeyAction({
sharingKey,
filterCriteria: filters,
});
responseCount = responseCountActionResponse?.data || 0;
} else {
const responseCountActionResponse = await getResponseCountAction({
surveyId,
filterCriteria: filters,
});
responseCount = responseCountActionResponse?.data || 0;
}
setResponseCount(responseCount);
};
handleResponsesCount();
}, [filters, isSharingPage, sharingKey, surveyId]);
useEffect(() => {
const fetchInitialResponses = async () => {
try {
@@ -1,3 +1,4 @@
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
@@ -61,6 +62,7 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
SESSION_MAX_AGE: 1000,
}));
vi.mock("@/lib/getSurveyUrl", () => ({
@@ -109,6 +111,14 @@ vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
useParams: () => ({
environmentId: "test-env-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
const mockEnvironmentId = "test-env-id";
const mockSurveyId = "test-survey-id";
const mockUserId = "test-user-id";
@@ -180,7 +190,7 @@ describe("ResponsesPage", () => {
test("renders correctly with all data", async () => {
const props = { params: mockParams };
const jsx = await Page(props);
render(jsx);
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
await screen.findByTestId("page-content-wrapper");
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -196,7 +206,6 @@ describe("ResponsesPage", () => {
isReadOnly: false,
user: mockUser,
surveyDomain: mockSurveyDomain,
responseCount: 10,
}),
undefined
);
@@ -206,7 +215,6 @@ describe("ResponsesPage", () => {
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "responses",
initialTotalResponseCount: 10,
}),
undefined
);
@@ -33,7 +33,8 @@ const Page = async (props) => {
const tags = await getTagsByEnvironmentId(params.environmentId);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const surveyDomain = getSurveyDomain();
@@ -49,15 +50,10 @@ const Page = async (props) => {
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
responseCount={totalResponseCount}
responseCount={responseCount}
/>
}>
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}
activeId="responses"
initialTotalResponseCount={totalResponseCount}
/>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
</PageHeader>
<ResponsePage
environment={environment}
@@ -38,18 +38,10 @@ interface SummaryListProps {
responseCount: number | null;
environment: TEnvironment;
survey: TSurvey;
totalResponseCount: number;
locale: TUserLocale;
}
export const SummaryList = ({
summary,
environment,
responseCount,
survey,
totalResponseCount,
locale,
}: SummaryListProps) => {
export const SummaryList = ({ summary, environment, responseCount, survey, locale }: SummaryListProps) => {
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslate();
const setFilter = (
@@ -115,11 +107,7 @@ export const SummaryList = ({
type="response"
environment={environment}
noWidgetRequired={survey.type === "link"}
emptyMessage={
totalResponseCount === 0
? undefined
: t("environments.surveys.summary.no_response_matches_filter")
}
emptyMessage={t("environments.surveys.summary.no_responses_found")}
/>
) : (
summary.map((questionSummary) => {
@@ -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}
/>
</>
@@ -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");
@@ -171,7 +171,7 @@ export const SurveyAnalysisCTA = ({
icon: SquarePenIcon,
tooltip: t("common.edit"),
onClick: () => {
responseCount && responseCount > 0
responseCount > 0
? setIsCautionDialogOpen(true)
: router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`);
},
@@ -758,7 +758,6 @@ describe("getSurveySummary", () => {
expect(summary.dropOff).toBeDefined();
expect(summary.summary).toBeDefined();
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, undefined);
expect(prisma.response.findMany).toHaveBeenCalled(); // Check if getResponsesForSummary was effectively called
expect(getDisplayCountBySurveyId).toHaveBeenCalled();
});
@@ -770,7 +769,6 @@ describe("getSurveySummary", () => {
test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true };
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(2); // Assume 2 finished responses
const finishedResponses = mockResponses
.filter((r) => r.finished)
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
@@ -778,7 +776,6 @@ describe("getSurveySummary", () => {
await getSurveySummary(mockSurveyId, filterCriteria);
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, filterCriteria);
expect(prisma.response.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ surveyId: mockSurveyId }), // buildWhereClause is mocked
@@ -5,7 +5,6 @@ import { displayCache } from "@/lib/display/cache";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { responseCache } from "@/lib/response/cache";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { buildWhereClause } from "@/lib/response/utils";
import { surveyCache } from "@/lib/survey/cache";
import { getSurvey } from "@/lib/survey/service";
@@ -13,6 +12,7 @@ import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -917,22 +917,24 @@ export const getSurveySummary = reactCache(
}
const batchSize = 5000;
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
const pages = Math.ceil(responseCount / batchSize);
// Use cursor-based pagination instead of count + offset to avoid expensive queries
const responses: TSurveySummaryResponse[] = [];
let cursor: string | undefined = undefined;
let hasMore = true;
// Create an array of batch fetch promises
const batchPromises = Array.from({ length: pages }, (_, i) =>
getResponsesForSummary(surveyId, batchSize, i * batchSize, filterCriteria)
);
while (hasMore) {
const batch = await getResponsesForSummary(surveyId, batchSize, 0, filterCriteria, cursor);
responses.push(...batch);
// Fetch all batches in parallel
const batchResults = await Promise.all(batchPromises);
// Combine all batch results
const responses = batchResults.flat();
if (batch.length < batchSize) {
hasMore = false;
} else {
// Use the last response's ID as cursor for next batch
cursor = batch[batch.length - 1].id;
}
}
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
@@ -972,7 +974,8 @@ export const getResponsesForSummary = reactCache(
surveyId: string,
limit: number,
offset: number,
filterCriteria?: TResponseFilterCriteria
filterCriteria?: TResponseFilterCriteria,
cursor?: string
): Promise<TSurveySummaryResponse[]> =>
cache(
async () => {
@@ -980,18 +983,28 @@ export const getResponsesForSummary = reactCache(
[surveyId, ZId],
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()]
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
);
const queryLimit = limit ?? RESPONSES_PER_PAGE;
const survey = await getSurvey(surveyId);
if (!survey) return [];
try {
const whereClause: Prisma.ResponseWhereInput = {
surveyId,
...buildWhereClause(survey, filterCriteria),
};
// Add cursor condition for cursor-based pagination
if (cursor) {
whereClause.id = {
lt: cursor, // Get responses with ID less than cursor (for desc order)
};
}
const responses = await prisma.response.findMany({
where: {
surveyId,
...buildWhereClause(survey, filterCriteria),
},
where: whereClause,
select: {
id: true,
data: true,
@@ -1013,6 +1026,9 @@ export const getResponsesForSummary = reactCache(
{
createdAt: "desc",
},
{
id: "desc", // Secondary sort by ID for consistent pagination
},
],
take: queryLimit,
skip: offset,
@@ -1043,7 +1059,9 @@ export const getResponsesForSummary = reactCache(
throw error;
}
},
[`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
[
`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}-${cursor || ""}`,
],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
}
@@ -1,7 +1,9 @@
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import SurveyPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -38,7 +40,7 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
SESSION_MAX_AGE: 1000,
}));
vi.mock(
@@ -78,6 +80,13 @@ vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary",
() => ({
getSurveySummary: vi.fn(),
})
);
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
@@ -100,6 +109,11 @@ vi.mock("@/tolgee/server", () => ({
vi.mock("next/navigation", () => ({
notFound: vi.fn(),
useParams: () => ({
environmentId: "test-environment-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
const mockEnvironmentId = "test-environment-id";
@@ -172,6 +186,21 @@ const mockSession = {
expires: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
} as any;
const mockSurveySummary = {
meta: {
completedPercentage: 75,
completedResponses: 15,
displayCount: 20,
dropOffPercentage: 25,
dropOffCount: 5,
startsPercentage: 80,
totalResponses: 20,
ttcAverage: 120,
},
dropOff: [],
summary: [],
};
describe("SurveyPage", () => {
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
@@ -183,6 +212,7 @@ describe("SurveyPage", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getSurveyDomain).mockReturnValue("test.domain.com");
vi.mocked(getSurveySummary).mockResolvedValue(mockSurveySummary);
vi.mocked(notFound).mockClear();
});
@@ -193,7 +223,8 @@ describe("SurveyPage", () => {
test("renders correctly with valid data", async () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
render(await SurveyPage({ params }));
const jsx = await SurveyPage({ params });
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -204,7 +235,6 @@ describe("SurveyPage", () => {
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId);
expect(vi.mocked(getResponseCountBySurveyId)).toHaveBeenCalledWith(mockSurveyId);
expect(vi.mocked(getSurveyDomain)).toHaveBeenCalled();
expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual(
@@ -212,7 +242,6 @@ describe("SurveyPage", () => {
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "summary",
initialTotalResponseCount: 10,
})
);
@@ -222,18 +251,17 @@ describe("SurveyPage", () => {
survey: mockSurvey,
surveyId: mockSurveyId,
webAppUrl: WEBAPP_URL,
user: mockUser,
totalResponseCount: 10,
documentsPerPage: DOCUMENTS_PER_PAGE,
isReadOnly: false,
locale: mockUser.locale ?? DEFAULT_LOCALE,
initialSurveySummary: mockSurveySummary,
})
);
});
test("calls notFound if surveyId is not present in params", async () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: undefined }) as any;
render(await SurveyPage({ params }));
const jsx = await SurveyPage({ params });
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
expect(vi.mocked(notFound)).toHaveBeenCalled();
});
@@ -243,7 +271,7 @@ describe("SurveyPage", () => {
try {
// We need to await the component itself because it's an async component
const SurveyPageComponent = await SurveyPage({ params });
render(SurveyPageComponent);
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
} catch (e: any) {
expect(e.message).toBe("common.survey_not_found");
}
@@ -256,7 +284,7 @@ describe("SurveyPage", () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
try {
const SurveyPageComponent = await SurveyPage({ params });
render(SurveyPageComponent);
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
} catch (e: any) {
expect(e.message).toBe("common.user_not_found");
}
@@ -1,9 +1,9 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -37,10 +37,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
throw new Error(t("common.user_not_found"));
}
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
// I took this out cause it's cloud only right?
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
const surveyDomain = getSurveyDomain();
@@ -55,26 +53,19 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
responseCount={totalResponseCount}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
/>
}>
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}
activeId="summary"
initialTotalResponseCount={totalResponseCount}
/>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
</PageHeader>
<SummaryPage
environment={environment}
survey={survey}
surveyId={params.surveyId}
webAppUrl={WEBAPP_URL}
user={user}
totalResponseCount={totalResponseCount}
documentsPerPage={DOCUMENTS_PER_PAGE}
isReadOnly={isReadOnly}
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
/>
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
@@ -3,7 +3,6 @@ import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -46,19 +45,13 @@ const Page = async (props: ResponsesPageProps) => {
throw new Error(t("common.project_not_found"));
}
const totalResponseCount = await getResponseCountBySurveyId(surveyId);
const locale = await findMatchingLocale();
return (
<div className="flex w-full justify-center">
<PageContentWrapper className="w-full">
<PageHeader pageTitle={survey.name}>
<SurveyAnalysisNavigation
survey={survey}
environmentId={environment.id}
activeId="responses"
initialTotalResponseCount={totalResponseCount}
/>
<SurveyAnalysisNavigation survey={survey} environmentId={environment.id} activeId="responses" />
</PageHeader>
<ResponsePage
environment={environment}
@@ -1,9 +1,9 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -47,27 +47,23 @@ const Page = async (props: SummaryPageProps) => {
throw new Error(t("common.project_not_found"));
}
const totalResponseCount = await getResponseCountBySurveyId(surveyId);
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
return (
<div className="flex w-full justify-center">
<PageContentWrapper className="w-full">
<PageHeader pageTitle={survey.name}>
<SurveyAnalysisNavigation
survey={survey}
environmentId={environment.id}
activeId="summary"
initialTotalResponseCount={totalResponseCount}
/>
<SurveyAnalysisNavigation survey={survey} environmentId={environment.id} activeId="summary" />
</PageHeader>
<SummaryPage
environment={environment}
survey={survey}
surveyId={survey.id}
webAppUrl={WEBAPP_URL}
totalResponseCount={totalResponseCount}
isReadOnly={true}
locale={DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
/>
</PageContentWrapper>
</div>
+2 -2
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({
-2
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;
+44 -23
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: {
+2 -2
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);
});
@@ -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;
+1 -1
View File
@@ -1769,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",
+1 -1
View File
@@ -1769,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",
+1 -1
View File
@@ -1769,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",
+1 -1
View File
@@ -1769,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",
+1 -1
View File
@@ -1769,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",
+1 -1
View File
@@ -1769,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": "整體",
+1 -1
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();