fix: tidying up the survey card header (#6341)

Co-authored-by: Jakob Schott <jakob@formbricks.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
This commit is contained in:
Anshuman Pandey
2025-08-08 15:48:56 +05:30
committed by GitHub
parent ec6d88bf11
commit 62166dc4b1
28 changed files with 365 additions and 193 deletions

View File

@@ -69,10 +69,11 @@ async function checkRedisAvailability() {
* - Failure Indicates: TTL or window calculation issues
*
* 5. High Throughput Stress Test
* - Purpose: Test performance under sustained load
* - Method: 200 requests in batches (limit: 50)
* - Purpose: Test performance under sustained load within single time window
* - Method: 200 concurrent requests (limit: 50)
* - Expected: Exactly 50 requests allowed, consistent performance
* - Failure Indicates: Performance degradation or counter corruption
* - Note: Fixed to send all requests concurrently to avoid window boundary race conditions
*
* 6. applyRateLimit Function Test
* - Purpose: Test the higher-level wrapper function
@@ -315,29 +316,27 @@ describe("Rate Limiter Load Tests - Race Conditions", () => {
const config = TEST_CONFIGS.high;
const totalRequests = 200;
const batchSize = 50;
const identifier = "stress-test";
let totalAllowed = 0;
let totalDenied = 0;
// Send requests in batches to simulate real load
for (let i = 0; i < totalRequests; i += batchSize) {
const batchEnd = Math.min(i + batchSize, totalRequests);
const batchPromises = Array.from({ length: batchEnd - i }, () => checkRateLimit(config, identifier));
const batchResults = await Promise.all(batchPromises);
const batchAllowed = batchResults.filter((r) => r.ok && r.data.allowed).length;
const batchDenied = batchResults.filter((r) => r.ok && !r.data.allowed).length;
totalAllowed += batchAllowed;
totalDenied += batchDenied;
// Small delay between batches
await new Promise((resolve) => setTimeout(resolve, 10));
// Clear any existing keys first to ensure clean state
const redis = getRedisClient();
if (redis) {
const existingKeys = await redis.keys(`fb:rate_limit:${config.namespace}:*`);
if (existingKeys.length > 0) {
await redis.del(existingKeys);
}
}
// Send ALL requests concurrently within the same time window
// This eliminates window boundary race conditions that caused intermittent failures
const allPromises = Array.from({ length: totalRequests }, () => checkRateLimit(config, identifier));
console.log(`Sending ${totalRequests} concurrent requests...`);
const results = await Promise.all(allPromises);
const totalAllowed = results.filter((r) => r.ok && r.data.allowed).length;
const totalDenied = results.filter((r) => r.ok && !r.data.allowed).length;
console.log(`Stress test: ${totalAllowed} allowed, ${totalDenied} denied`);
// Should respect the rate limit even under high load

View File

@@ -62,7 +62,7 @@ export function DefaultLanguageSelect({
<SelectContent>
{projectLanguages.map((language) => (
<SelectItem
className="xs:text-base px-0.5 py-1 text-xs text-slate-800 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
className="px-0.5 py-1 text-sm text-slate-800"
key={language.id}
value={language.code}>
{`${getLanguageLabel(language.code, locale)} (${language.code})`}

View File

@@ -215,7 +215,8 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
checked={isMultiLanguageActivated}
disabled={!isMultiLanguageAllowed || projectLanguages.length === 0}
id="multi-lang-toggle"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
handleActivationSwitchLogic();
}}
/>

View File

@@ -239,7 +239,7 @@ export function LogicEditorActions({
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger id={`actions-${idx}-dropdown`}>
<DropdownMenuTrigger id={`actions-${idx}-dropdown`} asChild>
<Button
variant="outline"
className="flex h-10 w-10 items-center justify-center rounded-md bg-white">

View File

@@ -8,8 +8,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { InputCombobox } from "@/modules/ui/components/input-combo-box";
import { TComboboxOption } from "@/modules/ui/components/input-combo-box";
import { InputCombobox, TComboboxOption } from "@/modules/ui/components/input-combo-box";
import {
Select,
SelectContent,
@@ -207,7 +206,7 @@ export function ConditionsEditor({ conditions, config, callbacks, depth = 0 }: C
</div>
<DropdownMenu>
<DropdownMenuTrigger id={`condition-${depth}-${index}-dropdown`}>
<DropdownMenuTrigger id={`condition-${depth}-${index}-dropdown`} asChild>
<Button
variant="outline"
className="flex h-10 w-10 items-center justify-center rounded-md bg-white">

View File

@@ -504,7 +504,7 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
<DropdownMenuContent align="start">
{Object.keys(blockTypeToBlockName).map((key) => {
return (
<DropdownMenuItem key={key}>
<DropdownMenuItem key={key} asChild>
<Button
type="button"
onClick={() => format(key)}

View File

@@ -271,6 +271,7 @@ test.describe("Multi Language Survey Create", async () => {
await page.getByText("Start from scratch").click();
await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.locator("#multi-lang-toggle").click();
await page.getByText("Multiple languages").click();
await page.getByRole("combobox").click();
await page.getByLabel("English (en)").click();
await page.getByRole("button", { name: "Confirm" }).click();

View File

@@ -51,7 +51,7 @@ describe("BackButton", () => {
const { getByRole } = render(<BackButton onClick={() => {}} />);
const button = getByRole("button");
// Check a few class names to ensure styles are applied
expect(button.className).toContain("fb-border-back-button-border");
expect(button.className).toContain("fb-mb-1");
expect(button.className).toContain("fb-text-heading");
expect(button.className).toContain("focus:fb-ring-focus");
});

View File

@@ -13,7 +13,7 @@ export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButto
tabIndex={tabIndex}
type="button"
className={cn(
"fb-border-back-button-border fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-flex fb-items-center fb-border fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
"fb-mb-1 hover:fb-bg-input-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-flex fb-items-center fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
)}
onClick={onClick}>
{backButtonLabel || "Back"}

View File

@@ -53,7 +53,7 @@ export function SubmitButton({
type={type}
tabIndex={tabIndex}
autoFocus={focus}
className="fb-bg-brand fb-border-submit-button-border fb-text-on-brand focus:fb-ring-focus fb-rounded-custom fb-flex fb-items-center fb-border fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
className="fb-bg-brand fb-border-submit-button-border fb-text-on-brand focus:fb-ring-focus fb-rounded-custom fb-flex fb-items-center fb-border fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 fb-mb-1"
onClick={onClick}
disabled={disabled}>
{buttonLabel || (isLastQuestion ? "Finish" : "Next")}

View File

@@ -1,23 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/preact";
import { describe, expect, test } from "vitest";
import { GlobeIcon } from "./globe-icon";
describe("GlobeIcon", () => {
test("renders SVG with correct attributes", () => {
const { container } = render(<GlobeIcon />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg");
expect(svg).toHaveAttribute("viewBox", "0 0 24 24");
expect(svg).toHaveAttribute("fill", "none");
expect(svg).toHaveClass("lucide", "lucide-globe");
});
test("applies additional className", () => {
const { container } = render(<GlobeIcon className="custom-class" />);
const svg = container.querySelector("svg");
expect(svg).toHaveClass("custom-class");
});
});

View File

@@ -1,21 +0,0 @@
interface GlobeIconProps {
className?: string;
}
export function GlobeIcon({ className }: GlobeIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={`lucide lucide-globe ${className ? className.toString() : ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
);
}

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/preact";
import { describe, expect, test } from "vitest";
import { cleanup, render } from "@testing-library/preact";
import { afterEach, describe, expect, test } from "vitest";
import { Headline } from "./headline";
describe("Headline", () => {
@@ -9,20 +9,17 @@ describe("Headline", () => {
questionId: "test-id" as const,
};
afterEach(() => {
cleanup();
});
test("renders headline text correctly", () => {
const { container } = render(<Headline {...defaultProps} />);
const label = container.querySelector("label");
expect(label).toHaveTextContent("Test Question");
expect(label).toHaveAttribute("for", "test-id");
expect(label).toHaveClass(
"fb-text-heading",
"fb-mb-1.5",
"fb-block",
"fb-text-base",
"fb-font-semibold",
"fb-leading-6"
);
expect(label).toHaveClass("fb-text-heading", "fb-mb-[3px]", "fb-flex", "fb-flex-col");
});
test("renders with left alignment by default", () => {
@@ -42,8 +39,8 @@ describe("Headline", () => {
});
test("does not show 'Optional' text when required is true", () => {
const { container } = render(<Headline {...defaultProps} required={true} />);
const optionalText = container.querySelector("span");
const { queryByTestId } = render(<Headline {...defaultProps} required={true} />);
const optionalText = queryByTestId("fb__surveys__headline-optional-text-test");
expect(optionalText).not.toBeInTheDocument();
});
@@ -54,26 +51,10 @@ describe("Headline", () => {
expect(optionalText).toBeInTheDocument();
expect(optionalText).toHaveTextContent("Optional");
expect(optionalText).toHaveClass(
"fb-text-heading",
"fb-mx-2",
"fb-self-start",
"fb-text-sm",
"fb-font-normal",
"fb-leading-7",
"fb-opacity-60"
);
expect(optionalText).toHaveClass("fb-text-xs", "fb-opacity-60", "fb-font-normal");
expect(optionalText).toHaveAttribute("tabIndex", "-1");
});
test("handles empty headline", () => {
const { container } = render(<Headline {...defaultProps} headline={undefined} />);
const label = container.querySelector("label");
expect(label).toBeInTheDocument();
expect(label).toHaveTextContent("");
});
test("sets dir attribute to auto", () => {
const { container } = render(<Headline {...defaultProps} />);
const div = container.querySelector("div");

View File

@@ -1,27 +1,28 @@
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface HeadlineProps {
headline?: string;
headline: string;
questionId: TSurveyQuestionId;
required?: boolean;
alignTextCenter?: boolean;
}
export function Headline({ headline, questionId, required = true, alignTextCenter = false }: HeadlineProps) {
return (
<label
htmlFor={questionId}
className="fb-text-heading fb-mb-1.5 fb-block fb-text-base fb-font-semibold fb-leading-6">
<label htmlFor={questionId} className="fb-text-heading fb-mb-[3px] fb-flex fb-flex-col">
{!required && (
<span
className="fb-text-xs fb-opacity-60 fb-font-normal fb-leading-6 fb-mb-[3px]"
tabIndex={-1}
data-testid="fb__surveys__headline-optional-text-test">
Optional
</span>
)}
<div
className={`fb-flex fb-items-center ${alignTextCenter ? "fb-justify-center" : "fb-justify-between"}`}
dir="auto">
<p>{headline}</p>
{!required && (
<span
className="fb-text-heading fb-mx-2 fb-self-start fb-text-sm fb-font-normal fb-leading-7 fb-opacity-60"
tabIndex={-1}>
Optional
</span>
)}
<p data-testid="fb__surveys__headline-text-test" className="fb-text-base fb-font-semibold">
{headline}
</p>
</div>
</label>
);

View File

@@ -1,5 +1,7 @@
import { GlobeIcon } from "@/components/general/globe-icon";
import { LanguageIcon } from "@/components/icons/language-icon";
import { mixColor } from "@/lib/color";
import { useClickOutside } from "@/lib/use-click-outside-hook";
import { cn } from "@/lib/utils";
import { useRef, useState } from "preact/hooks";
import { getLanguageLabel } from "@formbricks/i18n-utils/src";
import { type TSurveyLanguage } from "@formbricks/types/surveys/types";
@@ -8,12 +10,20 @@ interface LanguageSwitchProps {
surveyLanguages: TSurveyLanguage[];
setSelectedLanguageCode: (languageCode: string) => void;
setFirstRender?: (firstRender: boolean) => void;
hoverColor?: string;
borderRadius?: number;
}
export function LanguageSwitch({
surveyLanguages,
setSelectedLanguageCode,
setFirstRender,
hoverColor,
borderRadius,
}: LanguageSwitchProps) {
const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8);
const [isHovered, setIsHovered] = useState(false);
const [showLanguageDropdown, setShowLanguageDropdown] = useState(false);
const toggleDropdown = () => {
setShowLanguageDropdown((prev) => !prev);
@@ -41,16 +51,26 @@ export function LanguageSwitch({
});
return (
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center fb-pr-1">
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center">
<button
title="Language switch"
type="button"
className="fb-text-heading fb-relative fb-h-6 fb-w-6 fb-rounded-md hover:fb-bg-black/5 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
className={cn(
"fb-text-heading fb-relative fb-h-8 fb-w-8 fb-rounded-md focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 fb-justify-center fb-flex fb-items-center"
)}
style={{
backgroundColor: isHovered ? hoverColorWithOpacity : "transparent",
transition: "background-color 0.2s ease",
borderRadius: `${borderRadius}px`,
}}
onClick={toggleDropdown}
tabIndex={-1}
aria-haspopup="true"
aria-expanded={showLanguageDropdown}>
<GlobeIcon className="fb-text-heading fb-h-6 fb-w-6 fb-p-0.5" />
aria-expanded={showLanguageDropdown}
aria-label="Language switch"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
<LanguageIcon />
</button>
{showLanguageDropdown ? (
<div

View File

@@ -1,6 +1,6 @@
export function RecaptchaBranding() {
return (
<p className="fb-text-signature fb-text-xs fb-text-center">
<p className="fb-text-signature fb-text-xs fb-text-center fb-leading-6 fb-text-balance">
Protected by reCAPTCHA and the Google{" "}
<b>
<a target="_blank" rel="noopener" href="https://policies.google.com/privacy">

View File

@@ -1,5 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SurveyCloseButton } from "./survey-close-button";
@@ -13,18 +13,48 @@ describe("SurveyCloseButton", () => {
const { container } = render(<SurveyCloseButton />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass(
"fb-z-[1001]",
"fb-flex",
"fb-w-fit",
"fb-items-center",
"even:fb-border-l",
"even:fb-pl-1"
);
expect(wrapper).toHaveClass("fb-z-[1001]", "fb-flex", "fb-w-fit", "fb-items-center");
const button = wrapper.querySelector("button");
expect(button).toBeInTheDocument();
expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-6", "fb-w-6", "fb-rounded-md");
expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8");
expect(button).toHaveAttribute("aria-label", "Close survey");
const backgroundColor = button?.style?.backgroundColor;
expect(backgroundColor).toBe("transparent");
});
test("renders close button with correct hover color", () => {
const { container } = render(<SurveyCloseButton hoverColor="#008080" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass("fb-z-[1001]", "fb-flex", "fb-w-fit", "fb-items-center");
const button = wrapper.querySelector("button");
expect(button).toBeInTheDocument();
expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8");
expect(button).toHaveAttribute("aria-label", "Close survey");
// hover over the button
fireEvent.mouseEnter(button as HTMLButtonElement);
const backgroundColor = button?.style?.backgroundColor;
expect(backgroundColor).toBe("rgb(0, 128, 128)");
});
test("renders close button with correct border radius", () => {
const { container } = render(<SurveyCloseButton borderRadius={12} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass("fb-z-[1001]", "fb-flex", "fb-w-fit", "fb-items-center");
const button = wrapper.querySelector("button");
expect(button).toBeInTheDocument();
expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8");
expect(button).toHaveAttribute("aria-label", "Close survey");
expect(button).toHaveStyle({
borderRadius: "12px",
});
});
test("renders SVG icon with correct attributes", () => {
@@ -32,16 +62,16 @@ describe("SurveyCloseButton", () => {
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
expect(svg).toHaveClass("fb-h-6", "fb-w-6", "fb-p-0.5");
expect(svg).toHaveAttribute("viewBox", "0 0 24 24");
expect(svg).toHaveAttribute("stroke", "currentColor");
expect(svg).toHaveAttribute("viewBox", "0 0 16 16");
expect(svg).toHaveAttribute("aria-hidden", "true");
const path = svg?.querySelector("path");
expect(path).toBeInTheDocument();
expect(path).toHaveAttribute("stroke", "currentColor");
expect(path).toHaveAttribute("d", "M12 4L4 12M4 4L12 12");
expect(path).toHaveAttribute("strokeWidth", "1.33");
expect(path).toHaveAttribute("strokeLinecap", "round");
expect(path).toHaveAttribute("strokeLinejoin", "round");
expect(path).toHaveAttribute("d", "M4 4L20 20M4 20L20 4");
});
test("calls onClose when clicked", async () => {

View File

@@ -1,23 +1,35 @@
import { CloseIcon } from "@/components/icons/close-icon";
import { mixColor } from "@/lib/color";
import { cn } from "@/lib/utils";
import { useState } from "preact/hooks";
interface SurveyCloseButtonProps {
onClose?: () => void;
hoverColor?: string;
borderRadius?: number;
}
export function SurveyCloseButton({ onClose }: SurveyCloseButtonProps) {
export function SurveyCloseButton({ onClose, hoverColor, borderRadius }: Readonly<SurveyCloseButtonProps>) {
const [isHovered, setIsHovered] = useState(false);
const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8);
return (
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center even:fb-border-l even:fb-pl-1">
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center">
<button
type="button"
onClick={onClose}
className="fb-text-heading fb-relative fb-h-6 fb-w-6 fb-rounded-md hover:fb-bg-black/5 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
<svg
className="fb-h-6 fb-w-6 fb-p-0.5"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1"
stroke="currentColor"
aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4L20 20M4 20L20 4" />
</svg>
style={{
backgroundColor: isHovered ? hoverColorWithOpacity : "transparent",
transition: "background-color 0.2s ease",
borderRadius: `${borderRadius}px`,
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={cn(
"fb-text-heading fb-relative focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 fb-p-2 fb-h-8 fb-w-8 flex items-center justify-center"
)}
aria-label="Close survey">
<CloseIcon />
</button>
</div>
);

View File

@@ -736,6 +736,9 @@ export function Survey({
}
};
const isLanguageSwitchVisible = getShowLanguageSwitch(offset);
const isCloseButtonVisible = getShowSurveyCloseButton(offset);
return (
<AutoCloseWrapper
survey={localSurvey}
@@ -748,29 +751,55 @@ export function Survey({
"fb-no-scrollbar fb-bg-survey-bg fb-flex fb-h-full fb-w-full fb-flex-col fb-justify-between fb-overflow-hidden fb-transition-all fb-duration-1000 fb-ease-in-out",
offset === 0 || cardArrangement === "simple" ? "fb-opacity-100" : "fb-opacity-0"
)}>
<div className="fb-flex fb-h-6 fb-justify-end fb-pr-2 fb-pt-2">
{getShowLanguageSwitch(offset) && (
<LanguageSwitch
surveyLanguages={localSurvey.languages}
setSelectedLanguageCode={setselectedLanguage}
/>
)}
{getShowSurveyCloseButton(offset) && <SurveyCloseButton onClose={onClose} />}
</div>
<div
ref={contentRef}
className={cn(
loadingElement ? "fb-animate-pulse fb-opacity-60" : "",
fullSizeCards ? "" : "fb-my-auto"
)}>
{content()}
</div>
<div className="fb-gap-y-2 fb-min-h-8 fb-flex fb-flex-col fb-justify-end">
<div className="fb-px-4 fb-space-y-2">
<div className={cn("fb-relative")}>
<div className="fb-flex fb-flex-col fb-w-full fb-items-end">
{showProgressBar ? <ProgressBar survey={localSurvey} questionId={questionId} /> : null}
<div
className={cn(
"fb-relative fb-w-full",
isCloseButtonVisible || isLanguageSwitchVisible ? "fb-h-8" : "fb-h-5"
)}>
<div className="fb-flex fb-items-center fb-justify-end fb-absolute fb-top-0 fb-right-0">
{isLanguageSwitchVisible && (
<LanguageSwitch
surveyLanguages={localSurvey.languages}
setSelectedLanguageCode={setselectedLanguage}
hoverColor={styling.inputColor?.light ?? "#000000"}
borderRadius={styling.roundness ?? 8}
/>
)}
{isLanguageSwitchVisible && isCloseButtonVisible && (
<div aria-hidden="true" className="fb-h-5 fb-w-px fb-bg-slate-200 fb-z-[1001]" />
)}
{isCloseButtonVisible && (
<SurveyCloseButton
onClose={onClose}
hoverColor={styling.inputColor?.light ?? "#000000"}
borderRadius={styling.roundness ?? 8}
/>
)}
</div>
</div>
</div>
<div
ref={contentRef}
className={cn(
loadingElement ? "fb-animate-pulse fb-opacity-60" : "",
fullSizeCards ? "" : "fb-my-auto"
)}>
{content()}
</div>
<div
className={cn(
"fb-flex fb-flex-col fb-justify-center fb-gap-2",
isCloseButtonVisible || isLanguageSwitchVisible ? "fb-p-2" : "fb-p-3"
)}>
{isBrandingEnabled ? <FormbricksBranding /> : null}
{isSpamProtectionEnabled ? <RecaptchaBranding /> : null}
</div>
{showProgressBar ? <ProgressBar survey={localSurvey} questionId={questionId} /> : <div></div>}
</div>
</div>
</AutoCloseWrapper>

View File

@@ -1,5 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen } from "@testing-library/preact";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WelcomeCard } from "./welcome-card";
@@ -7,7 +7,9 @@ describe("WelcomeCard", () => {
afterEach(() => {
vi.clearAllMocks();
document.body.innerHTML = "";
cleanup();
});
const mockSurvey = {
questions: [
{ id: "q1", logic: [] },
@@ -52,9 +54,9 @@ describe("WelcomeCard", () => {
});
test("shows response count when showResponseCount is true and count > 3", () => {
const { container } = render(<WelcomeCard {...defaultProps} responseCount={10} />);
const { queryByTestId } = render(<WelcomeCard {...defaultProps} responseCount={10} />);
const responseText = container.querySelector(".fb-text-xs");
const responseText = queryByTestId("fb__surveys__welcome-card__response-count");
expect(responseText).toHaveTextContent(/10 people responded/);
});
@@ -77,9 +79,9 @@ describe("WelcomeCard", () => {
});
test("does not show response count when count <= 3", () => {
const { container } = render(<WelcomeCard {...defaultProps} responseCount={3} />);
const { queryByTestId } = render(<WelcomeCard {...defaultProps} responseCount={3} />);
const responseText = container.querySelector(".fb-text-xs");
const responseText = queryByTestId("fb__surveys__welcome-card__response-count");
expect(responseText).not.toHaveTextContent(/3 people responded/);
});
@@ -122,7 +124,7 @@ describe("WelcomeCard", () => {
});
test("shows both time and response count when both flags are true", () => {
const { container } = render(
const { queryByTestId } = render(
<WelcomeCard
{...defaultProps}
responseCount={10}
@@ -137,7 +139,7 @@ describe("WelcomeCard", () => {
/>
);
const textDisplay = container.querySelector(".fb-text-xs");
const textDisplay = queryByTestId("fb__surveys__welcome-card__info-text-test");
expect(textDisplay).toHaveTextContent(/Takes.*10 people responded/);
});
@@ -200,12 +202,21 @@ describe("WelcomeCard", () => {
test("handles response counts at boundary conditions", () => {
// Test with exactly 3 responses (boundary)
const { container: container3 } = render(<WelcomeCard {...defaultProps} responseCount={3} />);
expect(container3.querySelector(".fb-text-xs")).not.toHaveTextContent(/3 people responded/);
const { queryByTestId: queryByTestId3, unmount } = render(
<WelcomeCard {...defaultProps} responseCount={3} />
);
expect(queryByTestId3("fb__surveys__welcome-card__response-count")).not.toHaveTextContent(
/3 people responded/
);
// unmount to not have conflicting test ids
unmount();
// Test with 4 responses (just above boundary)
const { container: container4 } = render(<WelcomeCard {...defaultProps} responseCount={4} />);
expect(container4.querySelector(".fb-text-xs")).toHaveTextContent(/4 people responded/);
const { queryByTestId: queryByTestId4 } = render(<WelcomeCard {...defaultProps} responseCount={4} />);
expect(queryByTestId4("fb__surveys__welcome-card__response-count")).toHaveTextContent(
/4 people responded/
);
});
test("handles time calculation edge cases", () => {

View File

@@ -174,16 +174,16 @@ export function WelcomeCard({
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
<UsersIcon />
<p className="fb-pt-1 fb-text-xs">
<span>{`${responseCount.toString()} people responded`}</span>
<span data-testid="fb__surveys__welcome-card__response-count">{`${responseCount.toString()} people responded`}</span>
</p>
</div>
) : null}
{timeToFinish && showResponseCount ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs">
<p className="fb-pt-1 fb-text-xs" data-testid="fb__surveys__welcome-card__info-text-test">
<span> Takes {calculateTimeToComplete()} </span>
<span>
<span data-testid="fb__surveys__welcome-card__response-count">
{responseCount && responseCount > 3 ? `${responseCount.toString()} people responded` : ""}
</span>
</p>

View File

@@ -0,0 +1,35 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/preact";
import { afterEach, describe, expect, test } from "vitest";
import { CloseIcon } from "./close-icon";
describe("CloseIcon", () => {
afterEach(() => {
cleanup();
});
test("renders SVG with correct attributes", () => {
const { container } = render(<CloseIcon />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg");
expect(svg).toHaveAttribute("viewBox", "0 0 16 16");
expect(svg).toHaveAttribute("fill", "none");
expect(svg).toHaveAttribute("aria-hidden", "true");
const path = svg?.querySelector("path");
expect(path).toBeInTheDocument();
expect(path).toHaveAttribute("stroke", "currentColor");
expect(path).toHaveAttribute("d", "M12 4L4 12M4 4L12 12");
expect(path).toHaveAttribute("strokeWidth", "1.33");
expect(path).toHaveAttribute("strokeLinecap", "round");
expect(path).toHaveAttribute("strokeLinejoin", "round");
});
test("applies additional className", () => {
const { container } = render(<CloseIcon className="custom-class" />);
const svg = container.querySelector("svg");
expect(svg).toHaveClass("custom-class");
});
});

View File

@@ -0,0 +1,24 @@
interface CloseIconProps {
className?: string;
}
export const CloseIcon = ({ className }: CloseIconProps) => {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true">
<path
d="M12 4L4 12M4 4L12 12"
stroke="currentColor"
strokeWidth="1.33"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};

View File

@@ -0,0 +1,34 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/preact";
import { afterEach, describe, expect, test } from "vitest";
import { LanguageIcon } from "./language-icon";
describe("LanguageIcon", () => {
afterEach(() => {
cleanup();
});
test("renders SVG with correct attributes", () => {
const { container } = render(<LanguageIcon />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg");
expect(svg).toHaveAttribute("viewBox", "0 0 16 16");
expect(svg).toHaveAttribute("fill", "none");
expect(svg).toHaveAttribute("aria-hidden", "true");
const path = svg?.querySelector("path");
expect(path).toBeInTheDocument();
expect(path).toHaveAttribute("stroke", "currentColor");
expect(path).toHaveAttribute("strokeWidth", "1.33");
expect(path).toHaveAttribute("strokeLinecap", "round");
expect(path).toHaveAttribute("strokeLinejoin", "round");
});
test("applies additional className", () => {
const { container } = render(<LanguageIcon className="custom-class" />);
const svg = container.querySelector("svg");
expect(svg).toHaveClass("custom-class");
});
});

View File

@@ -0,0 +1,31 @@
interface LanguageIconProps {
className?: string;
}
export const LanguageIcon = ({ className }: LanguageIconProps) => {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true">
<g clipPath="url(#clip0_4252_104)">
<path
d="M3.33325 5.33398L7.33325 9.33398M2.66659 9.33398L6.66659 5.33398L7.99992 3.33398M1.33325 3.33398H9.33325M4.66659 1.33398H5.33325M14.6666 14.6673L11.3333 8.00065L7.99992 14.6673M9.33325 12.0007H13.3333"
stroke="currentColor"
strokeWidth="1.33"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_4252_104">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);
};

View File

@@ -228,7 +228,7 @@ describe("AutoCloseWrapper", () => {
});
test("stops countdown on interaction (click)", () => {
const { container } = render(
const { queryByTestId } = render(
<AutoCloseWrapper
survey={mockSurvey}
questionIdx={-1}
@@ -240,7 +240,7 @@ describe("AutoCloseWrapper", () => {
);
// Find the wrapper div that has the click handler (the inner one with event handlers)
const wrapper = container.querySelector(".fb-h-full.fb-w-full:nth-child(2)");
const wrapper = queryByTestId("fb__surveys__auto-close-wrapper-test");
expect(wrapper).toBeTruthy();
// Use fireEvent instead of userEvent for more reliable event triggering
@@ -253,7 +253,7 @@ describe("AutoCloseWrapper", () => {
});
test("stops countdown on interaction (mouseover)", () => {
const { container } = render(
const { queryByTestId } = render(
<AutoCloseWrapper
survey={mockSurvey}
questionIdx={-1}
@@ -265,7 +265,7 @@ describe("AutoCloseWrapper", () => {
);
// Find the wrapper div that has the mouseover handler (the inner one with event handlers)
const wrapper = container.querySelector(".fb-h-full.fb-w-full:nth-child(2)");
const wrapper = queryByTestId("fb__surveys__auto-close-wrapper-test");
expect(wrapper).toBeTruthy();
// Use fireEvent instead of userEvent for more reliable event triggering

View File

@@ -62,13 +62,22 @@ export function AutoCloseWrapper({
}, [survey.autoClose]);
return (
<div className="fb-h-full fb-w-full">
{survey.autoClose && showAutoCloseProgressBar ? (
<AutoCloseProgressBar autoCloseTimeout={survey.autoClose} />
) : null}
<div onClick={stopCountdown} onMouseOver={stopCountdown} className="fb-h-full fb-w-full">
<div className="fb-h-full fb-w-full fb-flex fb-flex-col">
<div // NOSONAR // We can't have a role="button" here as sonarqube registers more issues with this. This is indeed an interactive element.
onClick={stopCountdown}
onMouseOver={stopCountdown} // NOSONAR // We can't check for onFocus because the survey is auto focused after the first question and we don't want to stop the countdown
className="fb-h-full fb-w-full"
data-testid="fb__surveys__auto-close-wrapper-test"
onKeyDown={stopCountdown}
aria-label="Auto close wrapper"
onTouchStart={stopCountdown}>
{children}
</div>
{survey.type === "app" && survey.autoClose && (
<div className="fb-h-2 fb-w-full" aria-hidden={!showAutoCloseProgressBar}>
{showAutoCloseProgressBar && <AutoCloseProgressBar autoCloseTimeout={survey.autoClose} />}
</div>
)}
</div>
);
}

View File

@@ -69,10 +69,9 @@ export const ScrollableContainer = forwardRef<ScrollableContainerHandle, Scrolla
<div
ref={containerRef}
style={{
scrollbarGutter: "stable both-edges",
maxHeight: isSurveyPreview ? "42dvh" : "60dvh",
}}
className={cn("fb-overflow-auto fb-px-4 fb-pb-1 fb-bg-survey-bg")}>
className={cn("fb-overflow-auto fb-px-4 fb-bg-survey-bg")}>
{children}
</div>
{!isAtBottom && (