mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 00:49:42 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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})`}
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
35
packages/surveys/src/components/icons/close-icon.test.tsx
Normal file
35
packages/surveys/src/components/icons/close-icon.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
24
packages/surveys/src/components/icons/close-icon.tsx
Normal file
24
packages/surveys/src/components/icons/close-icon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
packages/surveys/src/components/icons/language-icon.test.tsx
Normal file
34
packages/surveys/src/components/icons/language-icon.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
31
packages/surveys/src/components/icons/language-icon.tsx
Normal file
31
packages/surveys/src/components/icons/language-icon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user