mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 01:39:54 -06:00
Compare commits
1 Commits
fix/upgrad
...
fix/e2e-ci
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c93c35edfd |
@@ -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.**
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -254,4 +254,4 @@ jobs:
|
||||
|
||||
- name: Output App Logs
|
||||
if: failure()
|
||||
run: cat app.log
|
||||
run: cat app.log
|
||||
@@ -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")} />
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "创建 你 的 调查",
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
})
|
||||
),
|
||||
|
||||
@@ -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 } });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
export const calculateExpirationDate = (expirationDays: number) => {
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setDate(expirationDate.getDate() + expirationDays);
|
||||
return expirationDate.toISOString();
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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="">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -72,6 +72,7 @@ describe("MultipleChoiceQuestionForm", () => {
|
||||
selectedLanguageCode="default"
|
||||
setSelectedLanguageCode={vi.fn()}
|
||||
locale="en-US"
|
||||
lastQuestion={false}
|
||||
isStorageConfigured={true}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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., Hetzner’s object storage) [require a specific CORS configuration](https://github.com/formbricks/formbricks/discussions/6641#discussioncomment-14574048).
|
||||
If you’re using the bundled MinIO setup, this is already configured for you.
|
||||
|
||||
**Images not displaying in surveys:**
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]: "" });
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
1062
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user