Compare commits

..

1 Commits

Author SHA1 Message Date
Matti Nannt
c93c35edfd ci(e2e): disable rate limiting and set CI environment variable
- Disable rate limiting for E2E tests to prevent test failures caused by rate limits
- Set CI environment variable for Playwright to optimize test execution in CI environment
- Apply to both Azure and local E2E test runs
2025-10-04 08:26:17 +02:00
62 changed files with 1309 additions and 1695 deletions

View File

@@ -1,179 +0,0 @@
---
description: Apply these quality standards before finalizing code changes to ensure DRY principles, React best practices, TypeScript conventions, and maintainable code.
globs:
alwaysApply: false
---
# Review & Refine
Before finalizing any code changes, review your implementation against these quality standards:
## Core Principles
### DRY (Don't Repeat Yourself)
- Extract duplicated logic into reusable functions or hooks
- If the same code appears in multiple places, consolidate it
- Create helper functions at appropriate scope (component-level, module-level, or utility files)
- Avoid copy-pasting code blocks
### Code Reduction
- Remove unnecessary code, comments, and abstractions
- Prefer built-in solutions over custom implementations
- Consolidate similar logic
- Remove dead code and unused imports
- Question if every line of code is truly needed
## React Best Practices
### Component Design
- Keep components focused on a single responsibility
- Extract complex logic into custom hooks
- Prefer composition over prop drilling
- Use children props and render props when appropriate
- Keep component files under 300 lines when possible
### Hooks Usage
- Follow Rules of Hooks (only call at top level, only in React functions)
- Extract complex `useEffect` logic into custom hooks
- Use `useMemo` and `useCallback` only when you have a measured performance issue
- Declare dependencies arrays correctly - don't ignore exhaustive-deps warnings
- Keep `useEffect` focused on a single concern
### State Management
- Colocate state as close as possible to where it's used
- Lift state only when necessary
- Use `useReducer` for complex state logic with multiple sub-values
- Avoid derived state - compute values during render instead
- Don't store values in state that can be computed from props
### Event Handlers
- Name event handlers with `handle` prefix (e.g., `handleClick`, `handleSubmit`)
- Extract complex event handler logic into separate functions
- Avoid inline arrow functions in JSX when they contain complex logic
## TypeScript Best Practices
### Type Safety
- Prefer type inference over explicit types when possible
- Use `const` assertions for literal types
- Avoid `any` - use `unknown` if type is truly unknown
- Use discriminated unions for complex conditional logic
- Leverage type guards and narrowing
### Interface & Type Usage
- Use existing types from `@formbricks/types` - don't recreate them
- Prefer `interface` for object shapes that might be extended
- Prefer `type` for unions, intersections, and mapped types
- Define types close to where they're used unless they're shared
- Export types from index files for shared types
### Type Assertions
- Avoid type assertions (`as`) when possible
- Use type guards instead of assertions
- Only assert when you have more information than TypeScript
## Code Organization
### Separation of Concerns
- Separate business logic from UI rendering
- Extract API calls into separate functions or modules
- Keep data transformation separate from component logic
- Use custom hooks for stateful logic that doesn't render UI
### Function Clarity
- Functions should do one thing well
- Name functions clearly and descriptively
- Keep functions small (aim for under 20 lines)
- Extract complex conditionals into named boolean variables or functions
- Avoid deep nesting (max 3 levels)
### File Structure
- Group related functions together
- Order declarations logically (types → hooks → helpers → component)
- Keep imports organized (external → internal → relative)
- Consider splitting large files by concern
## Additional Quality Checks
### Performance
- Don't optimize prematurely - measure first
- Avoid creating new objects/arrays/functions in render unnecessarily
- Use keys properly in lists (stable, unique identifiers)
- Lazy load heavy components when appropriate
### Accessibility
- Use semantic HTML elements
- Include ARIA labels where needed
- Ensure keyboard navigation works
- Check color contrast and focus states
### Error Handling
- Handle error states in components
- Provide user feedback for failed operations
- Use error boundaries for component errors
- Log errors appropriately (avoid swallowing errors silently)
### Naming Conventions
- Use descriptive names (avoid abbreviations unless very common)
- Boolean variables/props should sound like yes/no questions (`isLoading`, `hasError`, `canEdit`)
- Arrays should be plural (`users`, `choices`, `items`)
- Event handlers: `handleX` in components, `onX` for props
- Constants in UPPER_SNAKE_CASE only for true constants
### Code Readability
- Prefer early returns to reduce nesting
- Use destructuring to make code clearer
- Break complex expressions into named variables
- Add comments only when code can't be made self-explanatory
- Use whitespace to group related code
### Testing Considerations
- Write code that's easy to test (pure functions, clear inputs/outputs)
- Avoid hard-to-mock dependencies when possible
- Keep side effects at the edges of your code
## Review Checklist
Before submitting your changes, ask yourself:
1. **DRY**: Is there any duplicated logic I can extract?
2. **Clarity**: Would another developer understand this code easily?
3. **Simplicity**: Is this the simplest solution that works?
4. **Types**: Am I using TypeScript effectively?
5. **React**: Am I following React idioms and best practices?
6. **Performance**: Are there obvious performance issues?
7. **Separation**: Are concerns properly separated?
8. **Testing**: Is this code testable?
9. **Maintenance**: Will this be easy to change in 6 months?
10. **Deletion**: Can I remove any code and still accomplish the goal?
## When to Apply This Rule
Apply this rule:
- After implementing a feature but before marking it complete
- When you notice your code feels "messy" or complex
- Before requesting code review
- When you see yourself copy-pasting code
- After receiving feedback about code quality
Don't let perfect be the enemy of good, but always strive for:
**Simple, readable, maintainable code that does one thing well.**

View File

@@ -254,4 +254,4 @@ jobs:
- name: Output App Logs
if: failure()
run: cat app.log
run: cat app.log

View File

@@ -1,14 +1,8 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -18,6 +12,13 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
interface LandingSidebarProps {
user: TUser;
@@ -65,8 +66,10 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p title={organization?.name} className="truncate text-sm text-slate-500">
{organization?.name}
<p
title={capitalizeFirstLetter(organization?.name)}
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />

View File

@@ -1,10 +1,10 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { H4, Small } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
interface ButtonInfo {
text: string;
@@ -41,7 +41,7 @@ export const SettingsCard = ({
id={title}>
<div className="flex justify-between border-b border-slate-200 px-4 pb-4">
<div>
<H4 className="font-medium tracking-normal">{title}</H4>
<H4 className="font-medium capitalize tracking-normal">{title}</H4>
<div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && (

View File

@@ -1,3 +1,9 @@
import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -6,12 +12,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/types/js";
import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
@@ -285,7 +285,7 @@ describe("getEnvironmentState", () => {
expect(cache.withCache).toHaveBeenCalledWith(
expect.any(Function),
"fb:env:test-environment-id:state",
60 * 1000 // 1 minutes in milliseconds
5 * 60 * 1000 // 5 minutes in milliseconds
);
});

View File

@@ -1,8 +1,4 @@
import "server-only";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
@@ -10,6 +6,10 @@ import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { getEnvironmentStateData } from "./data";
/**
@@ -80,6 +80,6 @@ export const getEnvironmentState = async (
return { data };
},
createCacheKey.environment.state(environmentId),
60 * 1000 // 1 minutes in milliseconds
5 * 60 * 1000 // 5 minutes in milliseconds
);
};

View File

@@ -1,7 +1,36 @@
import { describe, expect, test } from "vitest";
import { isCapitalized, sanitizeString, startsWithVowel, truncate, truncateText } from "./strings";
import {
capitalizeFirstLetter,
isCapitalized,
sanitizeString,
startsWithVowel,
truncate,
truncateText,
} from "./strings";
describe("String Utilities", () => {
describe("capitalizeFirstLetter", () => {
test("capitalizes the first letter of a string", () => {
expect(capitalizeFirstLetter("hello")).toBe("Hello");
});
test("returns empty string if input is null", () => {
expect(capitalizeFirstLetter(null)).toBe("");
});
test("returns empty string if input is empty string", () => {
expect(capitalizeFirstLetter("")).toBe("");
});
test("doesn't change already capitalized string", () => {
expect(capitalizeFirstLetter("Hello")).toBe("Hello");
});
test("handles single character string", () => {
expect(capitalizeFirstLetter("a")).toBe("A");
});
});
describe("truncate", () => {
test("returns the string as is if length is less than the specified length", () => {
expect(truncate("hello", 10)).toBe("hello");

View File

@@ -1,3 +1,10 @@
export const capitalizeFirstLetter = (string: string | null = "") => {
if (string === null) {
return "";
}
return string.charAt(0).toUpperCase() + string.slice(1);
};
// write a function that takes a string and truncates it to the specified length
export const truncate = (str: string, length: number) => {
if (!str) return "";

View File

@@ -1301,8 +1301,8 @@
"contains": "Contains",
"continue_to_settings": "Continue to Settings",
"control_which_file_types_can_be_uploaded": "Control which file types can be uploaded.",
"convert_to_multiple_choice": "Convert to Multi-select",
"convert_to_single_choice": "Convert to Single-select",
"convert_to_multiple_choice": "Convert to Multiple Choice",
"convert_to_single_choice": "Convert to Single Choice",
"country": "Country",
"create_group": "Create group",
"create_your_own_survey": "Create your own survey",

View File

@@ -1301,8 +1301,8 @@
"contains": "Contém",
"continue_to_settings": "Continuar para Definições",
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de ficheiros podem ser carregados.",
"convert_to_multiple_choice": "Converter para Seleção Múltipla",
"convert_to_single_choice": "Converter para Seleção Única",
"convert_to_multiple_choice": "Converter para Escolha Múltipla",
"convert_to_single_choice": "Converter para Escolha Única",
"country": "País",
"create_group": "Criar grupo",
"create_your_own_survey": "Crie o seu próprio inquérito",

View File

@@ -1301,8 +1301,8 @@
"contains": "Conține",
"continue_to_settings": "Continuă către Setări",
"control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.",
"convert_to_multiple_choice": "Convertiți la selectare multiplă",
"convert_to_single_choice": "Convertiți la selectare unică",
"convert_to_multiple_choice": "Convertiți la alegere multiplă",
"convert_to_single_choice": "Convertiți la alegere unică",
"country": "Țară",
"create_group": "Creează grup",
"create_your_own_survey": "Creează-ți propriul chestionar",

View File

@@ -1301,8 +1301,8 @@
"contains": "包含",
"continue_to_settings": "继续 到 设置",
"control_which_file_types_can_be_uploaded": "控制 可以 上传的 文件 类型",
"convert_to_multiple_choice": "转换为 多选",
"convert_to_single_choice": "转换为 单选",
"convert_to_multiple_choice": "转换为多选",
"convert_to_single_choice": "转换为单选",
"country": "国家",
"create_group": "创建 群组",
"create_your_own_survey": "创建 你 的 调查",

View File

@@ -230,7 +230,7 @@ describe("RenderResponse", () => {
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("value");
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Value");
});
test("renders ResponseBadges for 'Consent' question (number)", () => {
@@ -258,7 +258,7 @@ describe("RenderResponse", () => {
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("click");
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Click");
});
test("renders ResponseBadges for 'MultipleChoiceSingle' question (string)", () => {

View File

@@ -1,3 +1,16 @@
import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
import { PictureSelectionResponse } from "@/modules/ui/components/picture-selection-response";
import { RankingResponse } from "@/modules/ui/components/ranking-response";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
import React from "react";
import { TResponseDataValue } from "@formbricks/types/responses";
@@ -9,18 +22,6 @@ import {
TSurveyQuestionTypeEnum,
TSurveyRatingQuestion,
} from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
import { PictureSelectionResponse } from "@/modules/ui/components/picture-selection-response";
import { RankingResponse } from "@/modules/ui/components/ranking-response";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
interface RenderResponseProps {
responseData: TResponseDataValue;
@@ -103,7 +104,9 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
const rowValueInSelectedLanguage = getLocalizedValue(row.label, languagCode);
if (!responseData[rowValueInSelectedLanguage]) return null;
return (
<p key={rowValueInSelectedLanguage} className="ph-no-capture my-1 font-normal text-slate-700">
<p
key={rowValueInSelectedLanguage}
className="ph-no-capture my-1 font-normal capitalize text-slate-700">
{rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
</p>
);
@@ -123,7 +126,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: responseData.toString() }]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<PhoneIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
@@ -135,7 +138,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: responseData.toString() }]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<CheckCheckIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
@@ -147,7 +150,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: responseData.toString() }]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<MousePointerClickIcon className="h-4 w-4 text-slate-500" />}
showId={showId}

View File

@@ -1,10 +1,7 @@
import { ZContactLinkParams } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import {
ZContactLinkParams,
ZContactLinkQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
operationId: "getPersonalizedSurveyLink",
@@ -12,7 +9,6 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
description: "Retrieves a personalized link for a specific survey.",
requestParams: {
path: ZContactLinkParams,
query: ZContactLinkQuery,
},
tags: ["Management API - Surveys - Contact Links"],
responses: {
@@ -24,10 +20,6 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
z.object({
data: z.object({
surveyUrl: z.string().url(),
expiresAt: z
.string()
.nullable()
.describe("The date and time the link expires, null if no expiration"),
}),
})
),

View File

@@ -8,9 +8,7 @@ import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contac
import {
TContactLinkParams,
ZContactLinkParams,
ZContactLinkQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -21,10 +19,9 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
externalParams: props.params,
schemas: {
params: ZContactLinkParams,
query: ZContactLinkQuery,
},
handler: async ({ authentication, parsedInput }) => {
const { params, query } = parsedInput;
const { params } = parsedInput;
if (!params) {
return handleApiError(request, {
@@ -95,27 +92,12 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
});
}
// Calculate expiration date based on expirationDays
let expiresAt: string | null = null;
if (query?.expirationDays) {
expiresAt = calculateExpirationDate(query.expirationDays);
}
const surveyUrlResult = await getContactSurveyLink(
params.contactId,
params.surveyId,
query?.expirationDays || undefined
);
const surveyUrlResult = await getContactSurveyLink(params.contactId, params.surveyId, 7);
if (!surveyUrlResult.ok) {
return handleApiError(request, surveyUrlResult.error);
}
return responses.successResponse({
data: {
surveyUrl: surveyUrlResult.data,
expiresAt,
},
});
return responses.successResponse({ data: { surveyUrl: surveyUrlResult.data } });
},
});

View File

@@ -20,15 +20,4 @@ export const ZContactLinkParams = z.object({
}),
});
export const ZContactLinkQuery = z.object({
expirationDays: z.coerce
.number()
.int()
.min(1)
.max(365)
.optional()
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
});
export type TContactLinkParams = z.infer<typeof ZContactLinkParams>;
export type TContactLinkQuery = z.infer<typeof ZContactLinkQuery>;

View File

@@ -1,51 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { calculateExpirationDate } from "./utils";
describe("calculateExpirationDate", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("calculates expiration date for positive days", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(7);
const expectedDate = new Date("2024-01-22T12:00:00.000Z");
expect(result).toBe(expectedDate.toISOString());
});
test("handles zero expiration days", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(0);
expect(result).toBe(baseDate.toISOString());
});
test("handles negative expiration days", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(-5);
const expectedDate = new Date("2024-01-10T12:00:00.000Z");
expect(result).toBe(expectedDate.toISOString());
});
test("returns valid ISO string format", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(10);
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
expect(result).toMatch(isoRegex);
});
});

View File

@@ -1,5 +0,0 @@
export const calculateExpirationDate = (expirationDays: number) => {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + expirationDays);
return expirationDate.toISOString();
};

View File

@@ -1,9 +1,7 @@
import { logger } from "@formbricks/logger";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
import { getContactsInSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact";
import {
ZContactLinksBySegmentParams,
@@ -13,6 +11,7 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
export const GET = async (
request: Request,
@@ -77,7 +76,9 @@ export const GET = async (
// Calculate expiration date based on expirationDays
let expiresAt: string | null = null;
if (query?.expirationDays) {
expiresAt = calculateExpirationDate(query.expirationDays);
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + query.expirationDays);
expiresAt = expirationDate.toISOString();
}
// Generate survey links for each contact

View File

@@ -1,13 +1,14 @@
"use client";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { isSubscriptionCancelledAction, manageSubscriptionAction, upgradePlanAction } from "../actions";
import { getCloudPricingData } from "../api/lib/constants";
import { BillingSlider } from "./billing-slider";
@@ -140,7 +141,7 @@ export const PricingTable = ({
<div className="flex w-full">
<h2 className="mb-3 mr-2 inline-flex w-full text-2xl font-bold text-slate-700">
{t("environments.settings.billing.current_plan")}:{" "}
<span className="capitalize">{organization.billing.plan}</span>
{capitalizeFirstLetter(organization.billing.plan)}
{cancellingOn && (
<Badge
className="mx-2"
@@ -174,7 +175,7 @@ export const PricingTable = ({
)}
</div>
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 shadow-sm dark:bg-slate-800">
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 capitalize shadow-sm dark:bg-slate-800">
<div
className={cn(
"relative mx-8 mb-8 flex flex-col gap-4",

View File

@@ -1,4 +1,5 @@
import { getResponsesByContactId } from "@/lib/response/service";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -58,7 +59,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
.map(([key, attributeData]) => {
return (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{key.toString()}</dt>
<dt className="text-sm font-medium text-slate-500">{capitalizeFirstLetter(key.toString())}</dt>
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
</div>
);

View File

@@ -1,3 +1,6 @@
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { createCacheKey } from "@formbricks/cache";
@@ -6,9 +9,6 @@ import { logger } from "@formbricks/logger";
import { ZId, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TBaseFilter } from "@formbricks/types/segment";
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
export const getSegments = reactCache(
async (environmentId: string) =>
@@ -34,7 +34,7 @@ export const getSegments = reactCache(
}
},
createCacheKey.environment.segments(environmentId),
60 * 1000 // 1 minutes in milliseconds
5 * 60 * 1000 // 5 minutes in milliseconds
)
);

View File

@@ -1,5 +1,34 @@
"use client";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { isCapitalized } from "@/lib/utils/strings";
import {
convertOperatorToText,
convertOperatorToTitle,
toggleFilterConnector,
updateContactAttributeKeyInFilter,
updateDeviceTypeInFilter,
updateFilterValue,
updateOperatorInFilter,
updatePersonIdentifierInFilter,
updateSegmentIdInFilter,
} from "@/modules/ee/contacts/segments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import {
ArrowDownIcon,
@@ -35,35 +64,6 @@ import {
DEVICE_OPERATORS,
PERSON_OPERATORS,
} from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { isCapitalized } from "@/lib/utils/strings";
import {
convertOperatorToText,
convertOperatorToTitle,
toggleFilterConnector,
updateContactAttributeKeyInFilter,
updateDeviceTypeInFilter,
updateFilterValue,
updateOperatorInFilter,
updatePersonIdentifierInFilter,
updateSegmentIdInFilter,
} from "@/modules/ee/contacts/segments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { AddFilterModal } from "./add-filter-modal";
interface TSegmentFilterProps {
@@ -314,7 +314,7 @@ function AttributeSegmentFilter({
}}
value={attrKeyValue}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<SelectValue>
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
@@ -496,7 +496,7 @@ function PersonSegmentFilter({
}}
value={personIdentifier}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<SelectValue>
<div className="flex items-center gap-1 lowercase">
@@ -647,7 +647,7 @@ function SegmentSegmentFilter({
}}
value={currentSegment?.id}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<div className="flex items-center gap-1">
<Users2Icon className="h-4 w-4 text-sm" />

View File

@@ -25,15 +25,6 @@ vi.mock("../actions", () => ({
updateInviteAction: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: (role: string) => ({
isOwner: role === "owner",
isManager: role === "manager",
isMember: role === "member",
isBilling: role === "billing",
}),
}));
describe("EditMembershipRole Component", () => {
const mockRouter = {
refresh: vi.fn(),
@@ -62,21 +53,15 @@ describe("EditMembershipRole Component", () => {
describe("Rendering", () => {
test("renders a dropdown when user is owner", () => {
render(<EditMembershipRole {...defaultProps} isUserManagementDisabledFromUi={false} />);
render(<EditMembershipRole {...defaultProps} />);
const button = screen.queryByRole("button-role");
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent("member");
expect(button).toHaveTextContent("Member");
});
test("renders a badge when user is not owner or manager", () => {
render(
<EditMembershipRole
{...defaultProps}
currentUserRole="member"
isUserManagementDisabledFromUi={false}
/>
);
render(<EditMembershipRole {...defaultProps} currentUserRole="member" />);
const badge = screen.queryByRole("badge-role");
expect(badge).toBeInTheDocument();
@@ -85,42 +70,21 @@ describe("EditMembershipRole Component", () => {
});
test("disables the dropdown when editing own role", () => {
render(
<EditMembershipRole
{...defaultProps}
memberId="user-456"
userId="user-456"
isUserManagementDisabledFromUi={false}
/>
);
render(<EditMembershipRole {...defaultProps} memberId="user-456" userId="user-456" />);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();
});
test("disables the dropdown when the user is the only owner", () => {
render(
<EditMembershipRole
{...defaultProps}
memberRole="owner"
doesOrgHaveMoreThanOneOwner={false}
isUserManagementDisabledFromUi={false}
/>
);
render(<EditMembershipRole {...defaultProps} memberRole="owner" doesOrgHaveMoreThanOneOwner={false} />);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();
});
test("disables the dropdown when a manager tries to edit an owner", () => {
render(
<EditMembershipRole
{...defaultProps}
currentUserRole="manager"
memberRole="owner"
isUserManagementDisabledFromUi={false}
/>
);
render(<EditMembershipRole {...defaultProps} currentUserRole="manager" memberRole="owner" />);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();

View File

@@ -1,12 +1,7 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
@@ -16,6 +11,12 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { updateInviteAction, updateMembershipAction } from "../actions";
interface Role {
@@ -103,7 +104,7 @@ export function EditMembershipRole({
size="sm"
variant="secondary"
role="button-role">
<span className="ml-1 capitalize">{memberRole}</span>
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -127,5 +128,5 @@ export function EditMembershipRole({
);
}
return <Badge size="tiny" type="gray" role="badge-role" text={memberRole} className="capitalize" />;
return <Badge size="tiny" type="gray" role="badge-role" text={capitalizeFirstLetter(memberRole)} />;
}

View File

@@ -1,10 +1,11 @@
"use client";
import { convertDateTimeStringShort } from "@/lib/time";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Label } from "@/modules/ui/components/label";
import { Webhook } from "@prisma/client";
import { TFnType, useTranslate } from "@tolgee/react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { convertDateTimeStringShort } from "@/lib/time";
import { Label } from "@/modules/ui/components/label";
interface ActivityTabProps {
webhook: Webhook;
@@ -49,8 +50,8 @@ export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
<Label className="text-slate-500">
{t("environments.integrations.webhooks.created_by_third_party")}
</Label>
<p className="text-sm capitalize text-slate-900">
{webhook.source === "user" ? "No" : webhook.source}
<p className="text-sm text-slate-900">
{webhook.source === "user" ? "No" : capitalizeFirstLetter(webhook.source)}
</p>
</div>

View File

@@ -1,11 +1,12 @@
"use client";
import { timeSince } from "@/lib/time";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Badge } from "@/modules/ui/components/badge";
import { Webhook } from "@prisma/client";
import { TFnType, useTranslate } from "@tolgee/react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Badge } from "@/modules/ui/components/badge";
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
if (webhook.surveyIds.length === 0) {
@@ -81,7 +82,7 @@ export const WebhookRowData = ({
</div>
</div>
<div className="col-span-1 my-auto text-center text-sm text-slate-800">
<Badge type="gray" size="tiny" text={webhook.source || t("common.user")} className="capitalize" />
<Badge type="gray" size="tiny" text={capitalizeFirstLetter(webhook.source) || t("common.user")} />
</div>
<div className="col-span-4 my-auto text-center text-sm text-slate-800">
{renderSelectedSurveysText(webhook, surveys)}

View File

@@ -1,11 +1,11 @@
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { createActionClassAction } from "@/modules/survey/editor/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { createActionClassAction } from "@/modules/survey/editor/actions";
import { ActionActivityTab } from "./ActionActivityTab";
// Mock dependencies
@@ -51,6 +51,10 @@ vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: (error: any) => `Formatted error: ${error?.message || "Unknown error"}`,
}));
vi.mock("@/lib/utils/strings", () => ({
capitalizeFirstLetter: (str: string) => str.charAt(0).toUpperCase() + str.slice(1),
}));
vi.mock("@/modules/survey/editor/actions", () => ({
createActionClassAction: vi.fn(),
}));
@@ -205,7 +209,7 @@ describe("ActionActivityTab", () => {
expect(screen.getByText(`formatted-${mockActionClass.createdAt.toString()}`)).toBeInTheDocument(); // Created on
expect(screen.getByText(`formatted-${mockActionClass.updatedAt.toString()}`)).toBeInTheDocument(); // Last updated
expect(screen.getByText("NoCodeIcon")).toBeInTheDocument(); // Type icon
expect(screen.getByText("noCode")).toBeInTheDocument(); // Type text (now lowercase, capitalized via CSS)
expect(screen.getByText("NoCode")).toBeInTheDocument(); // Type text
expect(screen.getByText("Development")).toBeInTheDocument(); // Environment
expect(screen.getByText("Copy to Production")).toBeInTheDocument(); // Copy button text
});

View File

@@ -1,12 +1,8 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { convertDateTimeStringShort } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ACTION_TYPE_ICON_LOOKUP } from "@/modules/projects/settings/(setup)/app-connection/utils";
import { createActionClassAction } from "@/modules/survey/editor/actions";
@@ -14,6 +10,11 @@ import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component";
import { Label } from "@/modules/ui/components/label";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
interface ActivityTabProps {
actionClass: TActionClass;
@@ -151,7 +152,7 @@ export const ActionActivityTab = ({
<Label className="block text-xs font-normal text-slate-500">Type</Label>
<div className="mt-1 flex items-center">
<div className="mr-1.5 h-4 w-4 text-slate-600">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
<p className="text-sm capitalize text-slate-700">{actionClass.type}</p>
<p className="text-sm text-slate-700">{capitalizeFirstLetter(actionClass.type)}</p>
</div>
</div>
<div className="">

View File

@@ -1,19 +1,5 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { debounce } from "lodash";
import { ImagePlusIcon, TrashIcon } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -24,6 +10,20 @@ import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { debounce } from "lodash";
import { ImagePlusIcon, TrashIcon } from "lucide-react";
import { RefObject, useCallback, useMemo, useRef, useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
determineImageUploaderVisibility,
getChoiceLabel,
@@ -50,6 +50,7 @@ interface QuestionFormInputProps {
label: string;
maxLength?: number;
placeholder?: string;
ref?: RefObject<HTMLInputElement | null>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
className?: string;
locale: TUserLocale;

View File

@@ -1,270 +1,335 @@
import { createI18nString } from "@/lib/i18n/utils";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
TSurvey,
TSurveyLanguage,
TSurveyMatrixQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { MatrixQuestionForm } from "./matrix-question-form";
// Mock cuid2 to track CUID generation
let cuidIndex = 0;
vi.mock("@paralleldrive/cuid2", () => ({
default: {
createId: vi.fn(() => `cuid${cuidIndex++}`),
},
}));
// Mock window.matchMedia - required for useAutoAnimate
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock @formkit/auto-animate - simplify implementation
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
},
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: () => {},
transform: null,
transition: null,
// Mock findOptionUsedInLogic
vi.mock("@/modules/survey/editor/lib/utils", () => ({
findOptionUsedInLogic: vi.fn(),
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
ENCRYPTION_KEY: "test",
ENTERPRISE_LICENSE_KEY: "test",
GITHUB_ID: "test",
GITHUB_SECRET: "test",
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
WEBAPP_URL: "mock-webapp-url",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
IS_PRODUCTION: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
}));
// Mock tolgee
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
verticalListSortingStrategy: () => {},
}));
// Keep QuestionFormInput simple and forward keydown
// Mock QuestionFormInput component
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: ({ id, value, onKeyDown }: { id: string; value: any; onKeyDown?: any }) => (
<input
data-testid={`qfi-${id}`}
value={value?.en || value?.de || value?.default || ""}
onChange={() => {}}
onKeyDown={onKeyDown}
/>
),
QuestionFormInput: vi.fn(({ id, updateMatrixLabel, value, updateQuestion, onKeyDown }) => (
<div data-testid={`question-input-${id}`}>
<input
data-testid={`input-${id}`}
onChange={(e) => {
if (updateMatrixLabel) {
const type = id.startsWith("row") ? "row" : "column";
const index = parseInt(id.split("-")[1]);
updateMatrixLabel(index, type, { default: e.target.value });
} else if (updateQuestion) {
updateQuestion(0, { [id]: { default: e.target.value } });
}
}}
value={value?.default || ""}
onKeyDown={onKeyDown}
/>
</div>
)),
}));
describe("MatrixQuestionForm - handleKeyDown", () => {
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
// Mock ShuffleOptionSelect component
vi.mock("@/modules/ui/components/shuffle-option-select", () => ({
ShuffleOptionSelect: vi.fn(() => <div data-testid="shuffle-option-select" />),
}));
// Mock TooltipRenderer component
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: vi.fn(({ children }) => (
<div data-testid="tooltip-renderer">
{children}
<button>Delete</button>
</div>
)),
}));
// Mock validation
vi.mock("../lib/validation", () => ({
isLabelValidForAllLanguages: vi.fn().mockReturnValue(true),
}));
// Mock survey languages
const mockSurveyLanguages: TSurveyLanguage[] = [
{
default: true,
enabled: true,
language: {
id: "en",
code: "en",
alias: "English",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
},
},
];
// Mock matrix question
const mockMatrixQuestion: TSurveyMatrixQuestion = {
id: "matrix-1",
type: TSurveyQuestionTypeEnum.Matrix,
headline: createI18nString("Matrix Question", ["en"]),
subheader: createI18nString("Please rate the following", ["en"]),
required: false,
logic: [],
rows: [
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
{ id: "row-3", label: createI18nString("Row 3", ["en"]) },
],
columns: [
{ id: "col-1", label: createI18nString("Column 1", ["en"]) },
{ id: "col-2", label: createI18nString("Column 2", ["en"]) },
{ id: "col-3", label: createI18nString("Column 3", ["en"]) },
],
shuffleOption: "none",
};
// Mock survey
const mockSurvey: TSurvey = {
id: "survey-1",
name: "Test Survey",
questions: [mockMatrixQuestion],
languages: mockSurveyLanguages,
} as unknown as TSurvey;
const mockUpdateQuestion = vi.fn();
const defaultProps = {
localSurvey: mockSurvey,
question: mockMatrixQuestion,
questionIdx: 0,
updateQuestion: mockUpdateQuestion,
selectedLanguageCode: "en",
setSelectedLanguageCode: vi.fn(),
isInvalid: false,
locale: "en-US" as TUserLocale,
isStorageConfigured: true,
};
describe("MatrixQuestionForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
cuidIndex = 0;
});
test("renders the matrix question form with rows and columns", () => {
render(<MatrixQuestionForm {...defaultProps} isStorageConfigured={true} />);
expect(screen.getByTestId("question-input-headline")).toBeInTheDocument();
// Check for rows and columns
expect(screen.getByTestId("question-input-row-0")).toBeInTheDocument();
expect(screen.getByTestId("question-input-row-1")).toBeInTheDocument();
expect(screen.getByTestId("question-input-column-0")).toBeInTheDocument();
expect(screen.getByTestId("question-input-column-1")).toBeInTheDocument();
// Check for shuffle options
expect(screen.getByTestId("shuffle-option-select")).toBeInTheDocument();
});
test("adds description when button is clicked", async () => {
const user = userEvent.setup();
const propsWithoutSubheader = {
...defaultProps,
question: {
...mockMatrixQuestion,
subheader: undefined,
},
};
const { getByText } = render(
<MatrixQuestionForm {...propsWithoutSubheader} isStorageConfigured={true} />
);
const addDescriptionButton = getByText("environments.surveys.edit.add_description");
await user.click(addDescriptionButton);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
subheader: expect.any(Object),
});
});
afterEach(() => {
cleanup();
test("renders subheader input when subheader is defined", () => {
render(<MatrixQuestionForm {...defaultProps} />);
expect(screen.getByTestId("question-input-subheader")).toBeInTheDocument();
});
const makeSurvey = (languages: Array<Pick<TSurveyLanguage, "language" | "default">>): TSurvey =>
({
id: "s1",
name: "Survey",
type: "link",
languages: languages as unknown as TSurveyLanguage[],
questions: [] as any,
endings: [] as any,
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env1",
}) as unknown as TSurvey;
test("deletes a row when delete button is clicked", async () => {
const user = userEvent.setup();
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(-1);
const langDefault: TSurveyLanguage = {
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage;
const deleteButtons = await findAllByTestId("tooltip-renderer");
// First delete button is for the first column
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
const baseQuestion = (): TSurveyMatrixQuestion => ({
id: "q1",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
required: false,
rows: [
{ id: "r1", label: { default: "Row 1" } },
{ id: "r2", label: { default: "" } },
],
columns: [
{ id: "c1", label: { default: "Col 1" } },
{ id: "c2", label: { default: "" } },
],
shuffleOption: "none",
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
rows: [mockMatrixQuestion.rows[1], mockMatrixQuestion.rows[2]],
});
});
test("Enter on last row adds a new row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
test("doesn't delete a row if it would result in less than 2 rows", async () => {
const user = userEvent.setup();
const propsWithMinRows = {
...defaultProps,
question: {
...mockMatrixQuestion,
rows: [
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
],
},
};
const updateQuestion = vi.fn();
const { findAllByTestId } = render(<MatrixQuestionForm {...propsWithMinRows} />);
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
// Try to delete rows until there are only 2 left
const deleteButtons = await findAllByTestId("tooltip-renderer");
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
const lastRowInput = screen.getByTestId("qfi-row-1");
await userEvent.type(lastRowInput, "{enter}");
// Try to delete another row, which should fail
vi.mocked(mockUpdateQuestion).mockClear();
await user.click(deleteButtons[1].querySelector("button") as HTMLButtonElement);
expect(updateQuestion).toHaveBeenCalledTimes(1);
const [, payload] = updateQuestion.mock.calls[0];
expect(payload.rows.length).toBe(3);
expect(payload.rows[2]).toEqual(
expect.objectContaining({ id: expect.any(String), label: expect.objectContaining({ default: "" }) })
);
// The mockUpdateQuestion should not be called again
expect(mockUpdateQuestion).not.toHaveBeenCalled();
});
test("Enter on non-last row focuses next row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
test("handles row input changes", async () => {
const user = userEvent.setup();
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
const updateQuestion = vi.fn();
const rowInput = getByTestId("input-row-0");
await user.clear(rowInput);
await user.type(rowInput, "New Row Label");
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const firstRowInput = screen.getByTestId("qfi-row-0");
await userEvent.type(firstRowInput, "{enter}");
expect(updateQuestion).not.toHaveBeenCalled();
expect(mockUpdateQuestion).toHaveBeenCalled();
});
test("Enter on last column adds a new column", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
test("handles column input changes", async () => {
const user = userEvent.setup();
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
const updateQuestion = vi.fn();
const columnInput = getByTestId("input-column-0");
await user.clear(columnInput);
await user.type(columnInput, "New Column Label");
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const lastColInput = screen.getByTestId("qfi-column-1");
await userEvent.type(lastColInput, "{enter}");
expect(updateQuestion).toHaveBeenCalledTimes(1);
const [, payload] = updateQuestion.mock.calls[0];
expect(payload.columns.length).toBe(3);
expect(payload.columns[2]).toEqual(
expect.objectContaining({ id: expect.any(String), label: expect.objectContaining({ default: "" }) })
);
expect(mockUpdateQuestion).toHaveBeenCalled();
});
test("Enter on non-last column focuses next column", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
test("prevents deletion of a row used in logic", async () => {
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this row is used in logic
const updateQuestion = vi.fn();
const user = userEvent.setup();
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const deleteButtons = await findAllByTestId("tooltip-renderer");
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
const firstColInput = screen.getByTestId("qfi-column-0");
await userEvent.type(firstColInput, "{enter}");
expect(updateQuestion).not.toHaveBeenCalled();
expect(mockUpdateQuestion).not.toHaveBeenCalled();
});
test("Arrow Down on row focuses next row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
test("prevents deletion of a column used in logic", async () => {
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this column is used in logic
const updateQuestion = vi.fn();
const user = userEvent.setup();
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
// Column delete buttons are after row delete buttons
const deleteButtons = await findAllByTestId("tooltip-renderer");
// Click the first column delete button (index 2)
await user.click(deleteButtons[2].querySelector("button") as HTMLButtonElement);
const firstRowInput = screen.getByTestId("qfi-row-0");
await userEvent.type(firstRowInput, "{arrowdown}");
expect(updateQuestion).not.toHaveBeenCalled();
});
test("Arrow Up on row focuses previous row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
const updateQuestion = vi.fn();
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const secondRowInput = screen.getByTestId("qfi-row-1");
await userEvent.type(secondRowInput, "{arrowup}");
expect(updateQuestion).not.toHaveBeenCalled();
expect(mockUpdateQuestion).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,12 @@
"use client";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { MatrixSortableItem } from "@/modules/survey/editor/components/matrix-sortable-item";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
import { DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
@@ -10,13 +17,6 @@ import { type JSX, useCallback } from "react";
import toast from "react-hot-toast";
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { MatrixSortableItem } from "@/modules/survey/editor/components/matrix-sortable-item";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
import { isLabelValidForAllLanguages } from "../lib/validation";
interface MatrixQuestionFormProps {
@@ -45,24 +45,17 @@ export const MatrixQuestionForm = ({
const languageCodes = extractLanguageCodes(localSurvey.languages);
const { t } = useTranslate();
const focusItem = (targetIdx: number, type: "row" | "column") => {
const input = document.querySelector(`input[id="${type}-${targetIdx}"]`) as HTMLInputElement;
if (input) input.focus();
};
// Function to add a new Label input field
const handleAddLabel = (type: "row" | "column") => {
if (type === "row") {
const updatedRows = [...question.rows, { id: createId(), label: createI18nString("", languageCodes) }];
updateQuestion(questionIdx, { rows: updatedRows });
setTimeout(() => focusItem(updatedRows.length - 1, type), 0);
} else {
const updatedColumns = [
...question.columns,
{ id: createId(), label: createI18nString("", languageCodes) },
];
updateQuestion(questionIdx, { columns: updatedColumns });
setTimeout(() => focusItem(updatedColumns.length - 1, type), 0);
}
};
@@ -119,30 +112,10 @@ export const MatrixQuestionForm = ({
}
};
const handleKeyDown = (e: React.KeyboardEvent, type: "row" | "column", currentIndex: number) => {
const items = type === "row" ? question.rows : question.columns;
const handleKeyDown = (e: React.KeyboardEvent, type: "row" | "column") => {
if (e.key === "Enter") {
e.preventDefault();
if (currentIndex === items.length - 1) {
handleAddLabel(type);
} else {
focusItem(currentIndex + 1, type);
}
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (currentIndex + 1 < items.length) {
focusItem(currentIndex + 1, type);
}
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (currentIndex > 0) {
focusItem(currentIndex - 1, type);
}
handleAddLabel(type);
}
};
@@ -257,7 +230,7 @@ export const MatrixQuestionForm = ({
questionIdx={questionIdx}
updateMatrixLabel={updateMatrixLabel}
onDelete={(index) => handleDeleteLabel("row", index)}
onKeyDown={(e) => handleKeyDown(e, "row", index)}
onKeyDown={(e) => handleKeyDown(e, "row")}
canDelete={question.rows.length > 2}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
@@ -303,7 +276,7 @@ export const MatrixQuestionForm = ({
questionIdx={questionIdx}
updateMatrixLabel={updateMatrixLabel}
onDelete={(index) => handleDeleteLabel("column", index)}
onKeyDown={(e) => handleKeyDown(e, "column", index)}
onKeyDown={(e) => handleKeyDown(e, "column")}
canDelete={question.columns.length > 2}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}

View File

@@ -1,5 +1,8 @@
"use client";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTranslate } from "@tolgee/react";
@@ -12,9 +15,6 @@ import {
TSurveyMatrixQuestionChoice,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface MatrixSortableItemProps {
choice: TSurveyMatrixQuestionChoice;

View File

@@ -72,6 +72,7 @@ describe("MultipleChoiceQuestionForm", () => {
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
lastQuestion={false}
isStorageConfigured={true}
/>
);

View File

@@ -1,5 +1,12 @@
"use client";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
@@ -16,19 +23,13 @@ import {
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
interface MultipleChoiceQuestionFormProps {
localSurvey: TSurvey;
question: TSurveyMultipleChoiceQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMultipleChoiceQuestion>) => void;
lastQuestion: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
@@ -247,27 +248,28 @@ export const MultipleChoiceQuestionForm = ({
}}>
<SortableContext items={question.choices} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2" ref={parent}>
{question.choices?.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
</div>
</SortableContext>
</DndContext>

View File

@@ -1,21 +1,5 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Project } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
import { useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyQuestion,
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { recallToHeadline } from "@/lib/utils/recall";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
@@ -40,6 +24,22 @@ import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/su
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Project } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
import { useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyQuestion,
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface QuestionCardProps {
localSurvey: TSurvey;
@@ -301,6 +301,7 @@ export const QuestionCard = ({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
@@ -313,6 +314,7 @@ export const QuestionCard = ({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
@@ -451,6 +453,7 @@ export const QuestionCard = ({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}

View File

@@ -7,13 +7,7 @@ import { QuestionOptionChoice } from "./question-option-choice";
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: (props: any) => (
<input
data-testid="question-form-input"
className={props.className}
onKeyDown={props.onKeyDown}
value={props.value?.default || props.value?.en || props.value?.de || ""}
onChange={() => {}}
/>
<div data-testid="question-form-input" className={props.className}></div>
),
}));
@@ -76,81 +70,6 @@ describe("QuestionOptionChoice", () => {
expect(addButton).toBeDefined();
});
test("pressing Enter on last choice adds a new choice", async () => {
const addChoice = vi.fn();
const choice = { id: "choice2", label: { default: "Choice 2" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [{ id: "choice1", label: { default: "Choice 1" } }, choice],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={1}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={addChoice}
isInvalid={false}
localSurvey={{ languages: [{ language: { code: "default" }, default: true }] } as any}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[{ language: { code: "default" } as any, enabled: true, default: true } as any]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
isStorageConfigured={true}
/>
);
const input = screen.getByTestId("question-form-input");
await userEvent.type(input, "{enter}");
expect(addChoice).toHaveBeenCalledWith(1);
});
test("pressing Enter on non-last choice focuses next choice", async () => {
const addChoice = vi.fn();
const choice = { id: "choice1", label: { default: "Choice 1" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [choice, { id: "choice2", label: { default: "Choice 2" } }],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={0}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={addChoice}
isInvalid={false}
localSurvey={{ languages: [{ language: { code: "default" }, default: true }] } as any}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[{ language: { code: "default" } as any, enabled: true, default: true } as any]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
isStorageConfigured={true}
/>
);
const input = screen.getByTestId("question-form-input");
await userEvent.type(input, "{enter}");
// Should not add a new choice (not the last one)
expect(addChoice).not.toHaveBeenCalled();
});
test("should call deleteChoice when the 'Delete choice' button is clicked for a standard choice", async () => {
const choice = { id: "choice1", label: { default: "Choice 1" } };
const question = {

View File

@@ -1,5 +1,10 @@
"use client";
import { cn } from "@/lib/cn";
import { createI18nString } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTranslate } from "@tolgee/react";
@@ -13,11 +18,6 @@ import {
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { createI18nString } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { isLabelValidForAllLanguages } from "../lib/validation";
interface ChoiceProps {
@@ -72,17 +72,6 @@ export const QuestionOptionChoice = ({
transform: CSS.Translate.toString(transform),
};
const focusChoiceInput = (targetIdx: number) => {
const input = document.querySelector(`input[id="choice-${targetIdx}"]`) as HTMLInputElement;
input?.focus();
};
const addChoiceAndFocus = (idx: number) => {
addChoice(idx);
// Wait for DOM update before focusing the new input
setTimeout(() => focusChoiceInput(idx + 1), 0);
};
return (
<div className="flex w-full items-center gap-2" ref={setNodeRef} style={style}>
{/* drag handle */}
@@ -112,32 +101,6 @@ export const QuestionOptionChoice = ({
className={`${choice.id === "other" ? "border border-dashed" : ""} mt-0`}
locale={locale}
isStorageConfigured={isStorageConfigured}
onKeyDown={(e) => {
if (e.key === "Enter" && choice.id !== "other") {
e.preventDefault();
const lastChoiceIdx = question.choices.findLastIndex((c) => c.id !== "other");
if (choiceIdx === lastChoiceIdx) {
addChoiceAndFocus(choiceIdx);
} else {
focusChoiceInput(choiceIdx + 1);
}
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (choiceIdx + 1 < question.choices.length) {
focusChoiceInput(choiceIdx + 1);
}
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (choiceIdx > 0) {
focusChoiceInput(choiceIdx - 1);
}
}
}}
/>
{choice.id === "other" && (
<QuestionFormInput
@@ -147,8 +110,9 @@ export const QuestionOptionChoice = ({
label={""}
questionIdx={questionIdx}
value={
question.otherOptionPlaceholder ??
createI18nString(t("environments.surveys.edit.please_specify"), surveyLanguageCodes)
question.otherOptionPlaceholder
? question.otherOptionPlaceholder
: createI18nString(t("environments.surveys.edit.please_specify"), surveyLanguageCodes)
}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
@@ -163,7 +127,7 @@ export const QuestionOptionChoice = ({
)}
</div>
<div className="flex gap-2">
{question.choices?.length > 2 && (
{question.choices && question.choices.length > 2 && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.delete_choice")}>
<Button
variant="secondary"
@@ -185,7 +149,7 @@ export const QuestionOptionChoice = ({
aria-label="Add choice below"
onClick={(e) => {
e.preventDefault();
addChoiceAndFocus(choiceIdx);
addChoice(choiceIdx);
}}>
<PlusIcon />
</Button>

View File

@@ -88,6 +88,7 @@ describe("RankingQuestionForm", () => {
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
isStorageConfigured={true}
/>
);
@@ -134,6 +135,7 @@ describe("RankingQuestionForm", () => {
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
isStorageConfigured={true}
/>
);
@@ -188,6 +190,7 @@ describe("RankingQuestionForm", () => {
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
isStorageConfigured={true}
/>
);

View File

@@ -1,5 +1,11 @@
"use client";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
@@ -9,18 +15,13 @@ import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useRef, useState } from "react";
import { TI18nString, TSurvey, TSurveyRankingQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
interface RankingQuestionFormProps {
localSurvey: TSurvey;
question: TSurveyRankingQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyRankingQuestion>) => void;
lastQuestion: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
@@ -194,27 +195,28 @@ export const RankingQuestionForm = ({
}}>
<SortableContext items={question.choices} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2" ref={parent}>
{question.choices?.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
</div>
</SortableContext>
</DndContext>

View File

@@ -1,14 +1,14 @@
"use client";
import { Column } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { EllipsisVerticalIcon, EyeOffIcon, SettingsIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Column } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { EllipsisVerticalIcon, EyeOffIcon, SettingsIcon } from "lucide-react";
interface ColumnSettingsDropdownProps<T> {
column: Column<T>;
@@ -29,6 +29,7 @@ export const ColumnSettingsDropdown = <T,>({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="capitalize"
onClick={() => {
column.toggleVisibility(false);
}}
@@ -38,6 +39,7 @@ export const ColumnSettingsDropdown = <T,>({
</div>
</DropdownMenuItem>
<DropdownMenuItem
className="capitalize"
onClick={() => setIsTableSettingsModalOpen(true)}
icon={<SettingsIcon className="h-4 w-4" />}>
<div className="flex items-center space-x-2">

View File

@@ -64,7 +64,6 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="contact"
isQuotasAllowed={false}
/>
);
expect(screen.getByText("2 common.contacts common.selected")).toBeInTheDocument();
@@ -83,7 +82,6 @@ describe("SelectedRowSettings", () => {
updateRowList={updateRowList}
deleteAction={deleteAction}
type="response"
isQuotasAllowed={false}
/>
);
expect(screen.queryByText("common.download")).toBeNull();
@@ -97,7 +95,6 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="response"
isQuotasAllowed={false}
/>
);
fireEvent.click(screen.getByText("common.download"));
@@ -118,7 +115,6 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="contact"
isQuotasAllowed={false}
/>
);
// open delete dialog
@@ -140,7 +136,6 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="response"
isQuotasAllowed={false}
/>
);
// open delete menu (trigger button)
@@ -161,7 +156,6 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="response"
isQuotasAllowed={false}
/>
);
// open delete dialog
@@ -183,7 +177,6 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="contact"
isQuotasAllowed={false}
/>
);
// open delete menu (trigger button)

View File

@@ -1,11 +1,6 @@
"use client";
import { Table } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { ArrowDownToLineIcon, Loader2Icon, Trash2Icon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Button } from "@/modules/ui/components/button";
import { DecrementQuotasCheckbox } from "@/modules/ui/components/decrement-quotas-checkbox";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -16,6 +11,12 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { cn } from "@/modules/ui/lib/utils";
import { Table } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { ArrowDownToLineIcon, Loader2Icon, Trash2Icon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { TResponseWithQuotas } from "@formbricks/types/responses";
interface SelectedRowSettingsProps<T> {
table: Table<T>;
@@ -74,16 +75,14 @@ export const SelectedRowSettings = <T,>({
// Update the row list UI
updateRowList(rowsToBeDeleted);
const capitalizedType = type.charAt(0).toUpperCase() + type.slice(1);
toast.success(t("common.table_items_deleted_successfully", { type: capitalizedType }));
toast.success(t("common.table_items_deleted_successfully", { type: capitalizeFirstLetter(type) }));
} catch (error) {
if (error instanceof Error) {
toast.error(error.message);
} else {
const capitalizedType = type.charAt(0).toUpperCase() + type.slice(1);
toast.error(
t("common.an_unknown_error_occurred_while_deleting_table_items", {
type: capitalizedType,
type: capitalizeFirstLetter(type),
})
);
}

View File

@@ -11,7 +11,7 @@ describe("PageHeader", () => {
test("renders page title correctly", () => {
render(<PageHeader pageTitle="Dashboard" />);
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Dashboard")).toHaveClass("text-3xl font-bold text-slate-800");
expect(screen.getByText("Dashboard")).toHaveClass("text-3xl font-bold text-slate-800 capitalize");
});
test("renders with CTA", () => {

View File

@@ -10,7 +10,7 @@ export const PageHeader = ({ cta, pageTitle, children }: PageHeaderProps) => {
return (
<div className="border-b border-slate-200">
<div className="flex items-center justify-between space-x-4 pb-4">
<h1 className={cn("text-3xl font-bold text-slate-800")}>{pageTitle}</h1>
<h1 className={cn("text-3xl font-bold capitalize text-slate-800")}>{pageTitle}</h1>
{cta}
</div>
{children}

View File

@@ -21,7 +21,7 @@
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",
"@boxyhq/saml-jackson": "1.52.2",
"@boxyhq/saml-jackson": "1.45.2",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
@@ -110,7 +110,7 @@
"otplib": "12.0.1",
"papaparse": "5.5.2",
"posthog-js": "1.240.0",
"posthog-node": "5.9.2",
"posthog-node": "4.17.1",
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",

View File

@@ -1,5 +1,5 @@
import { Page, expect } from "@playwright/test";
import { actions } from "@/playwright/utils/mock";
import { Page, expect } from "@playwright/test";
import { test } from "./lib/fixtures";
const createNoCodeClickAction = async ({
@@ -170,14 +170,11 @@ const createCodeAction = async ({
await page.getByRole("button", { name: "Create action", exact: true }).click();
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 15000 });
const successToast = await page.waitForSelector(".formbricks__toast__success");
expect(successToast).toBeTruthy();
// Wait for the action to be fully created and committed to the database
await page.waitForLoadState("networkidle", { timeout: 15000 });
const actionButton = page.getByTitle(name);
await expect(actionButton).toBeVisible({ timeout: 10000 });
await expect(actionButton).toBeVisible();
};
const getActionButtonLocator = (page: Page, actionName: string) => {

View File

@@ -127,64 +127,15 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
test.describe("Timing Attack Prevention - User Enumeration Protection", () => {
test("should not reveal user existence through response timing differences", async ({ request }) => {
// Helper functions for statistical analysis
const calculateMedian = (values: number[]): number => {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
};
const calculateStdDev = (values: number[], mean: number): number => {
const variance = values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / values.length;
return Math.sqrt(variance);
};
logger.info("🔥 Phase 1: Warming up caches, DB connections, and JIT compilation...");
// Warm-up phase: Prime caches, database connections, JIT compilation
const warmupAttempts = 10;
for (let i = 0; i < warmupAttempts; i++) {
await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: `warmup-nonexistent-${i}@example.com`,
password: "warmuppassword",
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: testUser.email,
password: "wrongwarmuppassword",
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
}
logger.info("✅ Warm-up complete. Starting actual measurements with 100 attempts per scenario...");
// Actual measurement phase with increased sample size
const attempts = 100;
// Test multiple attempts to get reliable timing measurements
const attempts = 50;
const nonExistentTimes: number[] = [];
const existingUserTimes: number[] = [];
// Interleave tests to reduce impact of system load variations
// Test non-existent user timing (multiple attempts for statistical reliability)
for (let i = 0; i < attempts; i++) {
// Test non-existent user
const startNonExistent = process.hrtime.bigint();
const responseNonExistent = await request.post("/api/auth/callback/credentials", {
const start = process.hrtime.bigint();
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: `nonexistent-timing-${i}@example.com`,
@@ -197,14 +148,17 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
"Content-Type": "application/x-www-form-urlencoded",
},
});
const endNonExistent = process.hrtime.bigint();
const responseTimeNonExistent = Number(endNonExistent - startNonExistent) / 1000000;
nonExistentTimes.push(responseTimeNonExistent);
expect(responseNonExistent.status()).not.toBe(500);
const end = process.hrtime.bigint();
const responseTime = Number(end - start) / 1000000; // Convert to milliseconds
// Test existing user (interleaved)
const startExisting = process.hrtime.bigint();
const responseExisting = await request.post("/api/auth/callback/credentials", {
nonExistentTimes.push(responseTime);
expect(response.status()).not.toBe(500);
}
// Test existing user with wrong password timing (multiple attempts)
for (let i = 0; i < attempts; i++) {
const start = process.hrtime.bigint();
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: testUser.email,
@@ -217,43 +171,29 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
"Content-Type": "application/x-www-form-urlencoded",
},
});
const endExisting = process.hrtime.bigint();
const responseTimeExisting = Number(endExisting - startExisting) / 1000000;
existingUserTimes.push(responseTimeExisting);
expect(responseExisting.status()).not.toBe(500);
const end = process.hrtime.bigint();
const responseTime = Number(end - start) / 1000000; // Convert to milliseconds
existingUserTimes.push(responseTime);
expect(response.status()).not.toBe(500);
}
// Calculate statistics using median (more robust to outliers)
const medianNonExistent = calculateMedian(nonExistentTimes);
const medianExisting = calculateMedian(existingUserTimes);
// Also calculate means for comparison
// Calculate averages
const avgNonExistent = nonExistentTimes.reduce((a, b) => a + b, 0) / nonExistentTimes.length;
const avgExisting = existingUserTimes.reduce((a, b) => a + b, 0) / existingUserTimes.length;
// Calculate standard deviations
const stdDevNonExistent = calculateStdDev(nonExistentTimes, avgNonExistent);
const stdDevExisting = calculateStdDev(existingUserTimes, avgExisting);
// Calculate the timing difference percentage
const timingDifference = Math.abs(avgExisting - avgNonExistent);
const timingDifferencePercent = (timingDifference / Math.max(avgExisting, avgNonExistent)) * 100;
// Calculate timing difference using MEDIAN (more reliable)
const timingDifference = Math.abs(medianExisting - medianNonExistent);
const timingDifferencePercent = (timingDifference / Math.max(medianExisting, medianNonExistent)) * 100;
// Calculate coefficient of variation (CV) for reliability assessment
// CV = (StdDev / Mean) * 100 - measures relative variability
const cvNonExistent = (stdDevNonExistent / avgNonExistent) * 100;
const cvExisting = (stdDevExisting / avgExisting) * 100;
// Log comprehensive statistics
logger.info("📊 Statistical Analysis:");
logger.info(
`Non-existent user - Mean: ${avgNonExistent.toFixed(2)}ms, Median: ${medianNonExistent.toFixed(2)}ms, StdDev: ${stdDevNonExistent.toFixed(2)}ms (CV: ${cvNonExistent.toFixed(1)}%)`
`Non-existent user avg: ${avgNonExistent.toFixed(2)}ms (${nonExistentTimes.map((t) => t.toFixed(0)).join(", ")})`
);
logger.info(
`Existing user - Mean: ${avgExisting.toFixed(2)}ms, Median: ${medianExisting.toFixed(2)}ms, StdDev: ${stdDevExisting.toFixed(2)}ms (CV: ${cvExisting.toFixed(1)}%)`
`Existing user avg: ${avgExisting.toFixed(2)}ms (${existingUserTimes.map((t) => t.toFixed(0)).join(", ")})`
);
logger.info(
`Timing difference (median-based): ${timingDifference.toFixed(2)}ms (${timingDifferencePercent.toFixed(1)}%)`
`Timing difference: ${timingDifference.toFixed(2)}ms (${timingDifferencePercent.toFixed(1)}%)`
);
// CRITICAL SECURITY TEST: Timing difference should be minimal
@@ -271,34 +211,7 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
}
// Fail the test if timing difference exceeds our security threshold
// Note: This uses MEDIAN-based comparison (more robust to outliers than mean)
expect(timingDifferencePercent).toBeLessThan(20);
// Validate measurement reliability using coefficient of variation (CV)
// CV > 50% indicates high variability and unreliable measurements
const maxAcceptableCV = 50; // 50% is reasonable for network-based tests
if (cvNonExistent > maxAcceptableCV) {
logger.warn(
`⚠️ High variability in non-existent user timing (CV: ${cvNonExistent.toFixed(1)}%). ` +
`Test measurements may be unreliable. Consider increasing warm-up or checking CI environment.`
);
}
if (cvExisting > maxAcceptableCV) {
logger.warn(
`⚠️ High variability in existing user timing (CV: ${cvExisting.toFixed(1)}%). ` +
`Test measurements may be unreliable. Consider increasing warm-up or checking CI environment.`
);
}
// These are soft checks - we warn but don't fail the test for high CV
// This allows for noisy CI environments while still alerting to potential issues
if (cvNonExistent <= maxAcceptableCV && cvExisting <= maxAcceptableCV) {
logger.info(
`✅ Measurement reliability good: CV ${cvNonExistent.toFixed(1)}% and ${cvExisting.toFixed(1)}% (threshold: ${maxAcceptableCV}%)`
);
}
expect(timingDifferencePercent).toBeLessThan(20); // Fail at our actual security threshold
});
test("should return consistent status codes regardless of user existence", async ({ request }) => {

View File

@@ -72,7 +72,6 @@ export const createUsersFixture = (page: Page, workerInfo: TestInfo): UsersFixtu
name: uname,
email: userEmail,
password: hashedPassword,
emailVerified: new Date(),
locale: "en-US",
memberships: {
create: {

View File

@@ -5,7 +5,7 @@ export async function loginAndGetApiKey(page: Page, users: UsersFixture) {
const user = await users.create();
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/, { timeout: 30000 });
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
const environmentId =
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
@@ -13,9 +13,9 @@ export async function loginAndGetApiKey(page: Page, users: UsersFixture) {
throw new Error("Unable to parse environmentId from URL");
})();
await page.goto(`/environments/${environmentId}/settings/api-keys`, { waitUntil: "domcontentloaded" });
await page.goto(`/environments/${environmentId}/settings/api-keys`);
await page.getByRole("button", { name: "Add API Key" }).waitFor({ state: "visible", timeout: 15000 });
await page.getByRole("button", { name: "Add API Key" }).isVisible();
await page.getByRole("button", { name: "Add API Key" }).click();
await page.getByPlaceholder("e.g. GitHub, PostHog, Slack").fill("E2E Test API Key");
await page.getByRole("button", { name: "+ Add permission" }).click();
@@ -26,18 +26,7 @@ export async function loginAndGetApiKey(page: Page, users: UsersFixture) {
await page.getByTestId("organization-access-accessControl-read").click();
await page.getByTestId("organization-access-accessControl-write").click();
await page.getByRole("button", { name: "Add API Key" }).click();
// Wait for the API key creation to complete and appear in the list
// Use longer timeouts for cloud environments with high concurrency and network latency
// Wait for network idle to ensure the API key is fully committed to the database
await page.waitForLoadState("networkidle", { timeout: 30000 });
await page.waitForSelector(".copyApiKeyIcon", { state: "visible", timeout: 30000 });
// Add a delay to ensure the API key is fully committed to the database
// This is especially important with high concurrency in cloud environments
await page.waitForTimeout(2000);
await page.locator(".copyApiKeyIcon").first().click();
await page.locator(".copyApiKeyIcon").click();
const apiKey = await page.evaluate("navigator.clipboard.readText()");

View File

@@ -1,5 +1,5 @@
import { expect } from "@playwright/test";
import { surveys } from "@/playwright/utils/mock";
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
import { createSurvey, createSurveyWithLogic, uploadFileForFileUploadQuestion } from "./utils/helper";
@@ -28,13 +28,10 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(page.locator("#howToSendCardOption-link")).toBeVisible();
await page.locator("#howToSendCardOption-link").click();
// Wait for any auto-save to complete before publishing
await page.waitForTimeout(2000);
await page.getByRole("button", { name: "Publish" }).click();
// Get URL - increase timeout for slower local environments
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/, { timeout: 60000 });
// Get URL
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
await page.getByLabel("Copy survey link to clipboard").click();
url = await page.evaluate("navigator.clipboard.readText()");
});
@@ -294,7 +291,7 @@ test.describe("Multi Language Survey Create", async () => {
.filter({ hasText: /^Add questionAdd a new question to your survey$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
await page.getByRole("button", { name: "Multi-Select" }).click();
await page.getByLabel("Question*").fill(surveys.createAndSubmit.multiSelectQuestion.question);
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.multiSelectQuestion.options[1]);
@@ -449,7 +446,7 @@ test.describe("Multi Language Survey Create", async () => {
await page.getByPlaceholder("Back").fill(surveys.germanCreate.back);
// Fill Multi select question in german
await page.getByRole("main").getByRole("heading", { name: "Multi-Select" }).click();
await page.getByRole("main").getByText("Multi-Select").click();
await page.getByPlaceholder("Your question here. Recall").click();
await page
@@ -628,13 +625,10 @@ test.describe("Multi Language Survey Create", async () => {
await expect(page.locator("#howToSendCardOption-link")).toBeVisible();
await page.locator("#howToSendCardOption-link").click();
// Wait for any auto-save to complete before publishing
await page.waitForTimeout(2000);
await page.getByRole("button", { name: "Publish" }).click();
await page.waitForTimeout(2000);
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/, { timeout: 60000 });
await page.waitForTimeout(5000);
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
await page.getByLabel("Select Language").click();
await page.getByText("German").click();
await page.getByLabel("Copy survey link to clipboard").click();

View File

@@ -1,9 +1,9 @@
import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock";
import { expect } from "@playwright/test";
import { readFileSync, writeFileSync } from "fs";
import { Page } from "playwright";
import { logger } from "@formbricks/logger";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock";
export const signUpAndLogin = async (
page: Page,
@@ -203,7 +203,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
await page.getByRole("button", { name: "Multi-Select" }).click();
await page.getByLabel("Question*").fill(params.multiSelectQuestion.question);
await page.getByRole("button", { name: "Add description", exact: true }).click();
await page.locator('input[name="subheader"]').fill(params.multiSelectQuestion.description);
@@ -416,7 +416,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
await page.getByRole("button", { name: "Multi-Select" }).click();
await page.getByLabel("Question*").fill(params.multiSelectQuestion.question);
await page.getByRole("button", { name: "Add description" }).click();
await page.locator('input[name="subheader"]').fill(params.multiSelectQuestion.description);

View File

@@ -2017,17 +2017,6 @@ paths:
type: string
description: The ID of the contact
required: true
- in: query
name: expirationDays
description: Number of days until the generated JWT expires. If not provided,
there is no expiration.
schema:
type:
- number
- undefined
minimum: 1
description: Number of days until the generated JWT expires. If not provided,
there is no expiration.
responses:
"200":
description: Personalized survey link retrieved successfully.
@@ -2042,11 +2031,6 @@ paths:
surveyUrl:
type: string
format: uri
expiresAt:
type: string
format: date-time
nullable: true
description: The date and time the link expires, null if no expiration
required:
- surveyUrl
/surveys/{surveyId}/contact-links/segments/{segmentId}:

View File

@@ -143,7 +143,7 @@ No manual intervention is required for the database migration.
**4. Verify Your Upgrade**
- Access your Formbricks instance at the same URL as before
- Test file uploads to ensure S3/MinIO integration works correctly. Check the [File Upload Troubleshooting](/self-hosting/configuration/file-uploads#troubleshooting) section if you face any issues.
- Test file uploads to ensure S3/MinIO integration works correctly
- Verify that existing surveys and data are intact
- Check that previously uploaded files are accessible

View File

@@ -230,38 +230,9 @@ services:
## Security Considerations
### IAM User Permissions
### S3 Bucket Permissions
When using AWS S3 or S3-compatible storage providers, ensure that the IAM user associated with your `S3_ACCESS_KEY` and `S3_SECRET_KEY` credentials has the necessary permissions to interact with your bucket. Without proper permissions, file uploads and retrievals will fail.
The following IAM policy grants the minimum required permissions for Formbricks to function correctly. This policy is also used in the bundled MinIO integration:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"],
"Resource": ["arn:aws:s3:::your-bucket-name/*"]
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::your-bucket-name"]
}
]
}
```
<Note>
Replace `your-bucket-name` with your actual S3 bucket name. The first statement grants object-level
operations (upload, retrieve, and delete files), while the second statement allows listing bucket contents.
</Note>
### S3 Bucket Policy
In addition to IAM user permissions, configure your S3 bucket with a least-privileged bucket policy to ensure security:
Configure your S3 bucket with a least-privileged policy:
1. **Scoped Public Read Access**: Only allow public read access to specific prefixes where needed
2. **Restricted Write Access**: Only your Formbricks instance should be able to upload files
@@ -293,10 +264,6 @@ Example least-privileged S3 bucket policy:
}
```
<Note>
Replace `your-bucket-name` with your actual bucket name and `arn:aws:iam::123456789012:user/formbricks-service` with the ARN of your IAM user. This policy allows public read access only to specific paths while restricting write access to your Formbricks service user.
</Note>
### MinIO Security
When using bundled MinIO:
@@ -317,9 +284,6 @@ When using bundled MinIO:
2. Verify bucket exists and is accessible
3. Ensure bucket permissions allow uploads from your server
4. Check network connectivity to S3 endpoint
5. We use S3 presigned URLs for uploads. Make sure your CORS policy allows presigned URL uploads; otherwise, uploads will fail.
Some providers (e.g., Hetzners object storage) [require a specific CORS configuration](https://github.com/formbricks/formbricks/discussions/6641#discussioncomment-14574048).
If youre using the bundled MinIO setup, this is already configured for you.
**Images not displaying in surveys:**

View File

@@ -30,7 +30,7 @@
"test": "turbo run test --no-cache",
"test:coverage": "turbo run test:coverage --no-cache",
"test:e2e": "playwright test",
"test-e2e:azure": "pnpm test:e2e -c playwright.service.config.ts --workers=10",
"test-e2e:azure": "pnpm test:e2e -c playwright.service.config.ts --workers=20",
"prepare": "husky install",
"storybook": "turbo run storybook",
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate",
@@ -76,9 +76,6 @@
"pnpm": {
"patchedDependencies": {
"next-auth@4.24.11": "patches/next-auth@4.24.11.patch"
},
"overrides": {
"tar-fs": ">=2.1.4"
}
}
}

View File

@@ -272,117 +272,4 @@ describe("MultipleChoiceSingleQuestion", () => {
const backButton = screen.getByTestId("back-button");
expect(backButton).toHaveAttribute("tabIndex", "0");
});
test("allows deselecting a choice when question is not required", async () => {
const user = userEvent.setup();
const optionalQuestion = { ...mockQuestion, required: false };
render(<MultipleChoiceSingleQuestion {...defaultProps} question={optionalQuestion} value="Choice 1" />);
const choice1Radio = screen.getByLabelText("Choice 1");
await user.click(choice1Radio);
expect(mockOnChange).toHaveBeenCalledWith({ q1: undefined });
});
test("does not deselect a choice when question is required", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} value="Choice 1" />);
const choice1Radio = screen.getByLabelText("Choice 1");
await user.click(choice1Radio);
expect(mockOnChange).not.toHaveBeenCalledWith({ q1: undefined });
expect(mockOnChange).toHaveBeenCalledWith({ q1: "Choice 1" });
});
test("allows deselecting 'Other' option when question is not required", async () => {
const user = userEvent.setup();
const optionalQuestion = { ...mockQuestion, required: false };
render(<MultipleChoiceSingleQuestion {...defaultProps} question={optionalQuestion} value="Some text" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
expect(mockOnChange).toHaveBeenCalledWith({ q1: undefined });
});
test("does not deselect 'Other' option when question is required", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} value="Some text" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
expect(mockOnChange).not.toHaveBeenCalledWith({ q1: undefined });
});
test("clears otherSelected when selecting a regular choice after 'Other' was selected", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} value="" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
expect(screen.getByPlaceholderText("Please specify")).toBeInTheDocument();
mockOnChange.mockClear();
const choice1Radio = screen.getByLabelText("Choice 1");
await user.click(choice1Radio);
expect(mockOnChange).toHaveBeenCalledWith({ q1: "Choice 1" });
});
test("handles spacebar key press on 'Other' option label when not selected", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} />);
const otherLabel = screen.getByLabelText("Other").closest("label");
if (otherLabel) {
await user.type(otherLabel, " ");
}
expect(mockOnChange).toHaveBeenCalledWith({ q1: "" });
});
test("does not trigger click when spacebar is pressed on 'Other' option label and otherSelected is true", async () => {
const user = userEvent.setup();
render(<MultipleChoiceSingleQuestion {...defaultProps} value="" />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
mockOnChange.mockClear();
const otherLabel = screen.getByRole("radio", { name: "Other" }).closest("label");
if (otherLabel) {
await user.type(otherLabel, " ");
}
expect(mockOnChange).not.toHaveBeenCalled();
});
test("displays custom other option placeholder when provided", async () => {
const user = userEvent.setup();
const questionWithCustomPlaceholder = {
...mockQuestion,
otherOptionPlaceholder: { default: "Custom placeholder text" },
};
render(<MultipleChoiceSingleQuestion {...defaultProps} question={questionWithCustomPlaceholder} />);
const otherRadio = screen.getByRole("radio", { name: "Other" });
await user.click(otherRadio);
expect(screen.getByPlaceholderText("Custom placeholder text")).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,3 @@
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";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
@@ -10,6 +7,9 @@ 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 { 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";
interface MultipleChoiceSingleProps {
question: TSurveyMultipleChoiceQuestion;
@@ -164,14 +164,9 @@ export function MultipleChoiceSingleQuestion({
dir={dir}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onClick={() => {
const choiceValue = getLocalizedValue(choice.label, languageCode);
if (!question.required && value === choiceValue) {
onChange({ [question.id]: undefined });
} else {
setOtherSelected(false);
onChange({ [question.id]: choiceValue });
}
onChange={() => {
setOtherSelected(false);
onChange({ [question.id]: getLocalizedValue(choice.label, languageCode) });
}}
checked={value === getLocalizedValue(choice.label, languageCode)}
required={question.required ? idx === 0 : undefined}
@@ -214,11 +209,10 @@ export function MultipleChoiceSingleQuestion({
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onClick={() => {
if (otherSelected && !question.required) {
if (otherSelected) {
onChange({ [question.id]: undefined });
setOtherSelected(false);
} else if (!otherSelected) {
setOtherSelected(true);
} else {
setOtherSelected(!otherSelected);
onChange({ [question.id]: "" });
}
}}

View File

@@ -14,11 +14,11 @@ export default defineConfig({
/* Run tests in files in parallel */
fullyParallel: true,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
retries: 0,
/* Timeout for each test */
timeout: 120000,
/* Fail the test run after the first failure */
maxFailures: process.env.CI ? undefined : 1, // Allow more failures in CI to avoid cascading shutdowns
maxFailures: 1, // Stop execution after the first failed test
/* Opt out of parallel tests on CI. */
// workers: os.cpus().length,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */

View File

@@ -7,7 +7,7 @@ export default defineConfig(
config,
getServiceConfig(config, {
exposeNetwork: "<loopback>",
timeout: 120000, // Increased timeout for cloud environment with network latency
timeout: 33000,
os: ServiceOS.LINUX,
useCloudHostedBrowsers: true, // Set to false if you want to only use reporting and not cloud hosted browsers
}),
@@ -18,7 +18,5 @@ export default defineConfig(
If you are using more reporters, please update your configuration accordingly.
*/
reporter: [["list"], ["@azure/microsoft-playwright-testing/reporter"]],
retries: 2, // Always retry in cloud environment due to potential network/timing issues
maxFailures: undefined, // Don't stop on first failure to avoid cascading shutdowns with high parallelism
}
);

1062
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff