fix: multiple close function calls because of timeouts (#5886)

This commit is contained in:
Anshuman Pandey
2025-05-27 12:50:35 +05:30
committed by GitHub
parent 45d74f9ba0
commit c53f030b24
17 changed files with 217 additions and 107 deletions

View File

@@ -9,7 +9,6 @@
"continue_with_saml": "Login mit SAML SSO",
"email-change": {
"confirm_password_description": "Bitte bestätige dein Passwort, bevor du deine E-Mail-Adresse änderst",
"email_already_exists": "Diese E-Mail wird bereits verwendet",
"email_change_success": "E-Mail erfolgreich geändert",
"email_change_success_description": "Du hast deine E-Mail-Adresse erfolgreich geändert. Bitte logge dich mit deiner neuen E-Mail-Adresse ein.",
"email_verification_failed": "E-Mail-Bestätigung fehlgeschlagen",
@@ -1158,7 +1157,6 @@
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
"invalid_file_type": "Ungültiger Dateityp. Nur JPEG-, PNG- und WEBP-Dateien sind erlaubt.",
"lost_access": "Zugriff verloren",
"new_email_update_success": "Deine Anfrage zur Änderung der E-Mail wurde erhalten.",
"or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
"organization_identification": "Hilf deiner Organisation, Dich auf Formbricks zu identifizieren",
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",

View File

@@ -9,7 +9,6 @@
"continue_with_saml": "Continue with SAML SSO",
"email-change": {
"confirm_password_description": "Please confirm your password before changing your email address",
"email_already_exists": "This email is already in use",
"email_change_success": "Email changed successfully",
"email_change_success_description": "You have successfully changed your email address. Please log in with your new email address.",
"email_verification_failed": "Email verification failed",
@@ -1158,7 +1157,6 @@
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
"invalid_file_type": "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.",
"lost_access": "Lost access",
"new_email_update_success": "Your email change request was received.",
"or_enter_the_following_code_manually": "Or enter the following code manually:",
"organization_identification": "Assist your organization in identifying you on Formbricks",
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",

View File

@@ -9,7 +9,6 @@
"continue_with_saml": "Continuer avec SAML SSO",
"email-change": {
"confirm_password_description": "Veuillez confirmer votre mot de passe avant de changer votre adresse e-mail",
"email_already_exists": "Cet e-mail est déjà utilisé",
"email_change_success": "E-mail changé avec succès",
"email_change_success_description": "Vous avez changé votre adresse e-mail avec succès. Veuillez vous connecter avec votre nouvelle adresse e-mail.",
"email_verification_failed": "Échec de la vérification de l'email",
@@ -1158,7 +1157,6 @@
"file_size_must_be_less_than_10mb": "La taille du fichier doit être inférieure à 10 Mo.",
"invalid_file_type": "Type de fichier invalide. Seuls les fichiers JPEG, PNG et WEBP sont autorisés.",
"lost_access": "Accès perdu",
"new_email_update_success": "Votre demande de changement d'email a été reçue.",
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
"organization_identification": "Aidez votre organisation à vous identifier sur Formbricks",
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",

View File

@@ -9,7 +9,6 @@
"continue_with_saml": "Continuar com SAML SSO",
"email-change": {
"confirm_password_description": "Por favor, confirme sua senha antes de mudar seu endereço de e-mail",
"email_already_exists": "Este e-mail já está em uso",
"email_change_success": "E-mail alterado com sucesso",
"email_change_success_description": "Você alterou seu endereço de e-mail com sucesso. Por favor, faça login com seu novo endereço de e-mail.",
"email_verification_failed": "Falha na verificação do e-mail",
@@ -1158,7 +1157,6 @@
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
"invalid_file_type": "Tipo de arquivo inválido. Só são permitidos arquivos JPEG, PNG e WEBP.",
"lost_access": "Perdi o acesso",
"new_email_update_success": "Sua solicitação de alteração de e-mail foi recebida.",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organization_identification": "Ajude sua organização a te identificar no Formbricks",
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",

View File

@@ -9,7 +9,6 @@
"continue_with_saml": "Continuar com SAML SSO",
"email-change": {
"confirm_password_description": "Por favor, confirme a sua palavra-passe antes de alterar o seu endereço de email",
"email_already_exists": "Este email já está a ser utilizado",
"email_change_success": "Email alterado com sucesso",
"email_change_success_description": "Alterou com sucesso o seu endereço de email. Por favor, inicie sessão com o seu novo endereço de email.",
"email_verification_failed": "Falha na verificação do email",
@@ -1158,7 +1157,6 @@
"file_size_must_be_less_than_10mb": "O tamanho do ficheiro deve ser inferior a 10MB.",
"invalid_file_type": "Tipo de ficheiro inválido. Apenas são permitidos ficheiros JPEG, PNG e WEBP.",
"lost_access": "Perdeu o acesso",
"new_email_update_success": "O seu pedido de alteração de email foi recebido.",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organization_identification": "Ajude a sua organização a identificá-lo no Formbricks",
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",

View File

@@ -9,7 +9,6 @@
"continue_with_saml": "使用 SAML SSO 繼續",
"email-change": {
"confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼",
"email_already_exists": "此電子郵件地址已被使用",
"email_change_success": "電子郵件已成功更改",
"email_change_success_description": "您已成功更改電子郵件地址。請使用您的新電子郵件地址登入。",
"email_verification_failed": "電子郵件驗證失敗",
@@ -1158,7 +1157,6 @@
"file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。",
"invalid_file_type": "無效的檔案類型。僅允許 JPEG、PNG 和 WEBP 檔案。",
"lost_access": "無法存取",
"new_email_update_success": "您的 email 更改請求已收到。",
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
"organization_identification": "協助您的組織在 Formbricks 上識別您",
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { FileInput } from "./file-input";
// Mock auto-animate hook to prevent React useState errors in Preact tests
@@ -37,7 +37,7 @@ describe("FileInput", () => {
vi.clearAllMocks();
});
it("uploads valid file and calls callbacks", async () => {
test("uploads valid file and calls callbacks", async () => {
render(
<FileInput
surveyId="survey1"
@@ -62,7 +62,7 @@ describe("FileInput", () => {
});
});
it("alerts on invalid file type", async () => {
test("alerts on invalid file type", async () => {
render(
<FileInput
surveyId="survey1"
@@ -82,7 +82,7 @@ describe("FileInput", () => {
expect(onUploadCallback).not.toHaveBeenCalled();
});
it("alerts when multiple files not allowed", () => {
test("alerts when multiple files not allowed", () => {
render(
<FileInput
surveyId="survey1"
@@ -100,7 +100,7 @@ describe("FileInput", () => {
expect(onFileUpload).not.toHaveBeenCalled();
});
it("renders existing fileUrls and handles delete", () => {
test("renders existing fileUrls and handles delete", () => {
const initialUrls = ["fileA.txt", "fileB.txt"];
render(
<FileInput
@@ -121,7 +121,7 @@ describe("FileInput", () => {
expect(onUploadCallback).toHaveBeenCalledWith(["fileB.txt"]);
});
it("alerts when duplicate files selected", () => {
test("alerts when duplicate files selected", () => {
render(
<FileInput
surveyId="survey1"
@@ -140,7 +140,7 @@ describe("FileInput", () => {
);
});
it("handles native file upload event", async () => {
test("handles native file upload event", async () => {
// Import the actual constant to ensure we're using the right event name
const FILE_PICK_EVENT = "formbricks:onFilePick";
const nativeFile = { name: "native.txt", type: "text/plain", base64: btoa("native content") };
@@ -174,7 +174,7 @@ describe("FileInput", () => {
});
});
it("tests file size validation", async () => {
test("tests file size validation", async () => {
// Instead of testing the alert directly, test that large files don't get uploaded
const largeFile = createFile("large.txt", 2 * 1024 * 1024, "text/plain"); // 2MB file
const smallFile = createFile("small.txt", 500, "text/plain"); // 500B file
@@ -215,7 +215,7 @@ describe("FileInput", () => {
expect(onFileUpload).not.toHaveBeenCalled();
});
it("does not upload when no valid files are selected", async () => {
test("does not upload when no valid files are selected", async () => {
render(
<FileInput
surveyId="survey1"
@@ -235,7 +235,7 @@ describe("FileInput", () => {
expect(onFileUpload).not.toHaveBeenCalled();
});
it("does not upload duplicates", async () => {
test("does not upload duplicates", async () => {
render(
<FileInput
surveyId="survey1"
@@ -257,7 +257,7 @@ describe("FileInput", () => {
expect(onFileUpload).not.toHaveBeenCalled();
});
it("handles native file upload with size limits", async () => {
test("handles native file upload with size limits", async () => {
// Import the actual constant to ensure we're using the right event name
const FILE_PICK_EVENT = "formbricks:onFilePick";
@@ -297,7 +297,7 @@ describe("FileInput", () => {
);
});
it("handles case when no files remain after filtering", async () => {
test("handles case when no files remain after filtering", async () => {
// Import the actual constant
const FILE_PICK_EVENT = "formbricks:onFilePick";
@@ -331,7 +331,7 @@ describe("FileInput", () => {
expect(onUploadCallback).not.toHaveBeenCalled();
});
it("deletes a file", () => {
test("deletes a file", () => {
const initialUrls = ["fileA.txt", "fileB.txt"];
render(
<FileInput
@@ -352,7 +352,7 @@ describe("FileInput", () => {
expect(onUploadCallback).toHaveBeenCalledWith(["fileB.txt"]);
});
it("handles drag and drop", async () => {
test("handles drag and drop", async () => {
render(
<FileInput
surveyId="survey1"
@@ -389,7 +389,7 @@ describe("FileInput", () => {
});
});
it("handles file upload errors", async () => {
test("handles file upload errors", async () => {
// Mock the toBase64 function to fail by making onFileUpload throw an error
// during the Promise.all for uploadPromises
onFileUpload.mockImplementationOnce(() => {
@@ -419,7 +419,7 @@ describe("FileInput", () => {
});
});
it("enforces file limit", () => {
test("enforces file limit", () => {
render(
<FileInput
surveyId="survey1"

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { LanguageSwitch } from "./language-switch";
@@ -59,7 +59,7 @@ describe("LanguageSwitch", () => {
cleanup();
});
it("toggles dropdown and lists only enabled languages", () => {
test("toggles dropdown and lists only enabled languages", () => {
render(
<LanguageSwitch
surveyLanguages={surveyLanguages}
@@ -83,7 +83,7 @@ describe("LanguageSwitch", () => {
expect(screen.queryByText("fr")).toBeNull();
});
it("calls setSelectedLanguageCode and setFirstRender correctly", () => {
test("calls setSelectedLanguageCode and setFirstRender correctly", () => {
render(
<LanguageSwitch
surveyLanguages={surveyLanguages}

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ProgressBar } from "./progress-bar";
// Mock Progress component to capture progress prop
@@ -24,12 +24,12 @@ describe("ProgressBar", () => {
endings: [{ id: "end1" }],
};
it("renders 0 for start", () => {
test("renders 0 for start", () => {
render(<ProgressBar survey={baseSurvey} questionId="start" />);
expect(screen.getByTestId("progress")).toHaveTextContent("0");
});
it("renders correct progress for questions", () => {
test("renders correct progress for questions", () => {
// totalCards = questions.length + 1 = 3
render(<ProgressBar survey={baseSurvey} questionId="q1" />);
expect(screen.getByTestId("progress")).toHaveTextContent("0");
@@ -41,7 +41,7 @@ describe("ProgressBar", () => {
expect(screen.getByTestId("progress")).toHaveTextContent((1 / 3).toString());
});
it("renders 1 for ending card", () => {
test("renders 1 for ending card", () => {
render(<ProgressBar survey={baseSurvey} questionId="end1" />);
expect(screen.getByTestId("progress")).toHaveTextContent("1");
});

View File

@@ -1,13 +1,13 @@
import { convertToEmbedUrl } from "@/lib/video-upload";
import { cleanup, render, screen } from "@testing-library/preact";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, test } from "vitest";
import { QuestionMedia } from "./question-media";
describe("QuestionMedia", () => {
afterEach(() => {
cleanup();
});
it("renders image correctly", () => {
test("renders image correctly", () => {
const imgUrl = "https://example.com/test.jpg";
const altText = "Test Image";
render(<QuestionMedia imgUrl={imgUrl} altText={altText} />);
@@ -17,7 +17,7 @@ describe("QuestionMedia", () => {
expect(img.getAttribute("src")).toBe(imgUrl);
});
it("renders YouTube video correctly", () => {
test("renders YouTube video correctly", () => {
const videoUrl = "https://www.youtube.com/watch?v=test123";
render(<QuestionMedia videoUrl={videoUrl} />);
@@ -26,7 +26,7 @@ describe("QuestionMedia", () => {
expect(iframe.getAttribute("src")).toBe(videoUrl + "?controls=0");
});
it("renders Vimeo video correctly", () => {
test("renders Vimeo video correctly", () => {
const videoUrl = "https://vimeo.com/test123";
render(<QuestionMedia videoUrl={videoUrl} />);
@@ -38,7 +38,7 @@ describe("QuestionMedia", () => {
);
});
it("renders Loom video correctly", () => {
test("renders Loom video correctly", () => {
const videoUrl = "https://www.loom.com/share/test123";
render(<QuestionMedia videoUrl={videoUrl} />);
@@ -49,14 +49,14 @@ describe("QuestionMedia", () => {
);
});
it("renders loading state initially", () => {
test("renders loading state initially", () => {
const { container } = render(<QuestionMedia imgUrl="https://example.com/test.jpg" />);
const loadingElement = container.querySelector(".fb-animate-pulse");
expect(loadingElement).toBeTruthy();
});
it("renders expand button with correct link", () => {
test("renders expand button with correct link", () => {
const imgUrl = "https://example.com/test.jpg";
render(<QuestionMedia imgUrl={imgUrl} />);
@@ -67,7 +67,7 @@ describe("QuestionMedia", () => {
expect(expandLink.getAttribute("rel")).toBe("noreferrer");
});
it("handles loading completion", async () => {
test("handles loading completion", async () => {
const imgUrl = "https://example.com/test.jpg";
const { container } = render(<QuestionMedia imgUrl={imgUrl} />);
@@ -81,14 +81,14 @@ describe("QuestionMedia", () => {
expect(loadingElements.length).toBe(0);
});
it("renders nothing when no media URLs are provided", () => {
test("renders nothing when no media URLs are provided", () => {
const { container } = render(<QuestionMedia />);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("iframe")).toBeNull();
});
it("uses default alt text when not provided", () => {
test("uses default alt text when not provided", () => {
const imgUrl = "https://example.com/test.jpg";
render(<QuestionMedia imgUrl={imgUrl} />);
@@ -96,7 +96,7 @@ describe("QuestionMedia", () => {
expect(img).toBeTruthy();
});
it("handles video loading state", async () => {
test("handles video loading state", async () => {
const videoUrl = "https://www.youtube.com/watch?v=test123";
const { container } = render(<QuestionMedia videoUrl={videoUrl} />);
@@ -115,7 +115,7 @@ describe("QuestionMedia", () => {
expect(loadingElements.length).toBe(0);
});
it("renders expand button with correct video link", () => {
test("renders expand button with correct video link", () => {
const videoUrl = "https://www.youtube.com/watch?v=test123";
render(<QuestionMedia videoUrl={videoUrl} />);
@@ -126,7 +126,7 @@ describe("QuestionMedia", () => {
expect(expandLink.getAttribute("rel")).toBe("noreferrer");
});
it("handles regular video URL without parameters", () => {
test("handles regular video URL without parameters", () => {
const videoUrl = "https://example.com/video.mp4";
render(<QuestionMedia videoUrl={videoUrl} />);

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { RenderSurvey } from "./render-survey";
// Stub SurveyContainer to render children and capture props
@@ -31,7 +31,7 @@ describe("RenderSurvey", () => {
vi.useRealTimers();
});
it("renders with default props and handles close", () => {
test("renders with default props and handles close", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
@@ -63,7 +63,7 @@ describe("RenderSurvey", () => {
expect(onClose).toHaveBeenCalled();
});
it("onFinished skips close if redirectToUrl", () => {
test("onFinished skips close if redirectToUrl", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "redirectToUrl" }] } as any;
@@ -88,7 +88,7 @@ describe("RenderSurvey", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("onFinished closes after delay for non-redirect endings", () => {
test("onFinished closes after delay for non-redirect endings", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
@@ -108,14 +108,14 @@ describe("RenderSurvey", () => {
const props = surveySpy.mock.calls[0][0];
props.onFinished();
// after first delay (survey finish), close schedules another delay
// wait for the onFinished timeout (3s) then the close timeout (1s)
vi.advanceTimersByTime(3000);
expect(onClose).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(onClose).toHaveBeenCalled();
});
it("onFinished does not auto-close when inline mode", () => {
test("onFinished does not auto-close when inline mode", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [] } as any;
@@ -139,4 +139,103 @@ describe("RenderSurvey", () => {
vi.advanceTimersByTime(5000);
expect(onClose).not.toHaveBeenCalled();
});
test("close clears any pending onFinished timeout", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
const { unmount } = render(
(
<RenderSurvey
survey={survey}
onClose={onClose}
onFinished={onFinished}
styling={{}}
isBrandingEnabled={false}
languageCode="en"
/>
) as any
);
const props = surveySpy.mock.calls[0][0];
// schedule the onFinished-based close
props.onFinished();
// immediately manually close, which should clear that pending timeout
props.onClose();
// manual close schedules onClose in 1s
vi.advanceTimersByTime(1000);
expect(onClose).toHaveBeenCalledTimes(1);
// advance past the original onFinished timeout (3s) + its would-be close delay
vi.advanceTimersByTime(4000);
// still only the one manual-close call
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
test("double close only schedules one onClose", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
render(
(
<RenderSurvey
survey={survey}
onClose={onClose}
onFinished={onFinished}
styling={{}}
isBrandingEnabled={false}
languageCode="en"
/>
) as any
);
const props = surveySpy.mock.calls[0][0];
// first close schedules user onClose at t=1000
props.onClose();
vi.advanceTimersByTime(500);
// before the first fires, call close again and clear it
props.onClose();
// advance to t=1000: first one would have fired if not cleared
vi.advanceTimersByTime(500);
expect(onClose).not.toHaveBeenCalled();
// advance to t=1500: only the second close should now fire
vi.advanceTimersByTime(500);
expect(onClose).toHaveBeenCalledTimes(1);
});
test("cleanup on unmount clears pending timers (useEffect)", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
const { unmount } = render(
(
<RenderSurvey
survey={survey}
onClose={onClose}
onFinished={onFinished}
styling={{}}
isBrandingEnabled={false}
languageCode="en"
/>
) as any
);
const props = surveySpy.mock.calls[0][0];
// schedule both timeouts
props.onFinished();
props.onClose();
// unmount should clear both pending timeouts
unmount();
// advance well past all delays
vi.advanceTimersByTime(10000);
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -1,20 +1,49 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { SurveyContainer } from "../wrappers/survey-container";
import { Survey } from "./survey";
export function RenderSurvey(props: SurveyContainerProps) {
const [isOpen, setIsOpen] = useState(true);
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const close = () => {
if (onFinishedTimeoutRef.current) {
clearTimeout(onFinishedTimeoutRef.current);
onFinishedTimeoutRef.current = null;
}
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
setIsOpen(false);
setTimeout(() => {
closeTimeoutRef.current = setTimeout(() => {
if (props.onClose) {
props.onClose();
}
}, 1000); // wait for animation to finish}
}, 1000);
};
useEffect(() => {
return () => {
if (onFinishedTimeoutRef.current) {
clearTimeout(onFinishedTimeoutRef.current);
}
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
if (!isOpen) {
return null;
}
return (
<SurveyContainer
mode={props.mode ?? "modal"}
@@ -32,7 +61,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
props.onFinished?.();
if (props.mode !== "inline") {
setTimeout(
onFinishedTimeoutRef.current = setTimeout(
() => {
const firstEnabledEnding = props.survey.endings?.[0];
if (firstEnabledEnding?.type !== "redirectToUrl") {

View File

@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { type TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { ResponseErrorComponent } from "./response-error-component";
@@ -37,7 +37,7 @@ describe("ResponseErrorComponent", () => {
q2: "Answer 2",
};
it("renders error message and retry button", () => {
test("renders error message and retry button", () => {
render(
<ResponseErrorComponent questions={mockQuestions} responseData={mockResponseData} onRetry={() => {}} />
);
@@ -47,7 +47,7 @@ describe("ResponseErrorComponent", () => {
expect(screen.getByText("Retry")).toBeDefined();
});
it("displays questions and responses correctly", () => {
test("displays questions and responses correctly", () => {
render(
<ResponseErrorComponent questions={mockQuestions} responseData={mockResponseData} onRetry={() => {}} />
);
@@ -63,7 +63,7 @@ describe("ResponseErrorComponent", () => {
expect(answers[1].textContent).toBe("Answer 2");
});
it("calls onRetry when retry button is clicked", () => {
test("calls onRetry when retry button is clicked", () => {
const mockOnRetry = vi.fn();
render(
<ResponseErrorComponent
@@ -79,7 +79,7 @@ describe("ResponseErrorComponent", () => {
expect(mockOnRetry).toHaveBeenCalledTimes(1);
});
it("handles missing responses gracefully", () => {
test("handles missing responses gracefully", () => {
const partialResponseData = {
q1: "Answer 1",
};

View File

@@ -1,5 +1,5 @@
import { render } from "@testing-library/preact";
import { describe, expect, it } from "vitest";
import { describe, expect, test } from "vitest";
import {
ConfusedFace,
FrowningFace,
@@ -34,7 +34,7 @@ describe("Smiley Components", () => {
components.forEach(({ name, Component }) => {
describe(name, () => {
it("renders with default props", () => {
test("renders with default props", () => {
const { container } = render(<Component />);
const svg = container.querySelector("svg");
expect(svg).to.exist;
@@ -53,7 +53,7 @@ describe("Smiley Components", () => {
expect(paths.length).to.be.greaterThan(0);
});
it("applies custom props correctly", () => {
test("applies custom props correctly", () => {
const { container } = render(
<Component {...testProps} style={{ stroke: "red", strokeWidth: 3, fill: "blue" }} />
);
@@ -65,7 +65,7 @@ describe("Smiley Components", () => {
expect(circle?.getAttribute("style")).to.include("fill: blue");
});
it("maintains accessibility", () => {
test("maintains accessibility", () => {
const { container } = render(<Component aria-label={`${name} emoji`} data-testid="smiley-svg" />);
const svg = container.querySelector("[data-testid='smiley-svg']");
expect(svg).to.exist;

View File

@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
import { JSX } from "preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { Survey } from "./survey";
@@ -243,7 +243,7 @@ describe("Survey", () => {
vi.clearAllMocks();
});
it("renders the survey with welcome card initially", () => {
test("renders the survey with welcome card initially", () => {
render(
<Survey
survey={mockSurvey}
@@ -272,7 +272,7 @@ describe("Survey", () => {
expect(onDisplayMock).toHaveBeenCalled();
});
it("handles question submission and navigation", async () => {
test("handles question submission and navigation", async () => {
// For this test, we'll use startAtQuestionId to force rendering the question card
render(
<Survey
@@ -317,7 +317,7 @@ describe("Survey", () => {
});
});
it("renders branding when enabled", () => {
test("renders branding when enabled", () => {
render(
<Survey
survey={mockSurvey}
@@ -345,7 +345,7 @@ describe("Survey", () => {
expect(screen.getByTestId("formbricks-branding")).toBeInTheDocument();
});
it("renders progress bar by default", () => {
test("renders progress bar by default", () => {
render(
<Survey
survey={mockSurvey}
@@ -373,7 +373,7 @@ describe("Survey", () => {
expect(screen.getByTestId("progress-bar")).toBeInTheDocument();
});
it("hides progress bar when hideProgressBar is true", () => {
test("hides progress bar when hideProgressBar is true", () => {
render(
<Survey
survey={mockSurvey}
@@ -402,7 +402,7 @@ describe("Survey", () => {
expect(screen.queryByTestId("progress-bar")).not.toBeInTheDocument();
});
it("handles file uploads in preview mode", async () => {
test("handles file uploads in preview mode", async () => {
// The createDisplay function in the Survey component calls onDisplayCreated
// We need to make sure it resolves before checking if onDisplayCreated was called
@@ -444,7 +444,7 @@ describe("Survey", () => {
expect(onFileUploadMock).toBeDefined();
});
it("calls onResponseCreated in preview mode", async () => {
test("calls onResponseCreated in preview mode", async () => {
// This test verifies that onResponseCreated is called in preview mode
// when a question is submitted in preview mode
@@ -489,7 +489,7 @@ describe("Survey", () => {
expect(onResponseCreatedMock).toHaveBeenCalled();
});
it("adds response to queue with correct user and contact IDs", async () => {
test("adds response to queue with correct user and contact IDs", async () => {
// This test is focused on the functionality in lines 445-472 of survey.tsx
// We will verify that the 'add' method of the ResponseQueue (mockRQAdd) is called.
// No need to import ResponseQueue or get mock instances dynamically here.
@@ -541,7 +541,7 @@ describe("Survey", () => {
);
});
it("makes questions required based on logic actions", async () => {
test("makes questions required based on logic actions", async () => {
// This test is focused on the functionality in lines 409-411 of survey.tsx
// We'll customize the performActions mock to return requiredQuestionIds
@@ -609,7 +609,7 @@ describe("Survey", () => {
expect(performActions).toHaveBeenCalled();
});
it("starts at a specific question when startAtQuestionId is provided", () => {
test("starts at a specific question when startAtQuestionId is provided", () => {
render(
<Survey
survey={mockSurvey}

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen } from "@testing-library/preact";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WelcomeCard } from "./welcome-card";
describe("WelcomeCard", () => {
@@ -35,7 +35,7 @@ describe("WelcomeCard", () => {
variablesData: {},
};
it("renders welcome card with basic content", () => {
test("renders welcome card with basic content", () => {
const { container } = render(<WelcomeCard {...defaultProps} />);
expect(container.querySelector(".fb-text-heading")).toHaveTextContent("Welcome to our survey");
@@ -43,7 +43,7 @@ describe("WelcomeCard", () => {
expect(container.querySelector("button")).toHaveTextContent("Start");
});
it("shows time to complete when timeToFinish is true", () => {
test("shows time to complete when timeToFinish is true", () => {
const { container } = render(<WelcomeCard {...defaultProps} />);
const timeDisplay = container.querySelector(".fb-text-subheading");
@@ -51,14 +51,14 @@ describe("WelcomeCard", () => {
expect(timeDisplay).toHaveTextContent(/Takes/);
});
it("shows response count when showResponseCount is true and count > 3", () => {
test("shows response count when showResponseCount is true and count > 3", () => {
const { container } = render(<WelcomeCard {...defaultProps} responseCount={10} />);
const responseText = container.querySelector(".fb-text-xs");
expect(responseText).toHaveTextContent(/10 people responded/);
});
it("handles submit button click", () => {
test("handles submit button click", () => {
const { container } = render(<WelcomeCard {...defaultProps} />);
const button = container.querySelector("button");
@@ -68,7 +68,7 @@ describe("WelcomeCard", () => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith({ welcomeCard: "clicked" }, {});
});
it("handles Enter key press when survey type is link", () => {
test("handles Enter key press when survey type is link", () => {
render(<WelcomeCard {...defaultProps} />);
fireEvent.keyDown(document, { key: "Enter" });
@@ -76,14 +76,14 @@ describe("WelcomeCard", () => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith({ welcomeCard: "clicked" }, {});
});
it("does not show response count when count <= 3", () => {
test("does not show response count when count <= 3", () => {
const { container } = render(<WelcomeCard {...defaultProps} responseCount={3} />);
const responseText = container.querySelector(".fb-text-xs");
expect(responseText).not.toHaveTextContent(/3 people responded/);
});
it("shows company logo when fileUrl is provided", () => {
test("shows company logo when fileUrl is provided", () => {
const propsWithLogo = {
...defaultProps,
fileUrl: "https://example.com/logo.png",
@@ -96,7 +96,7 @@ describe("WelcomeCard", () => {
expect(logo).toHaveAttribute("src", "https://example.com/logo.png");
});
it("calculates time to complete correctly for different survey lengths", () => {
test("calculates time to complete correctly for different survey lengths", () => {
// Test short survey (2 questions)
const { container } = render(<WelcomeCard {...defaultProps} />);
const timeDisplay = container.querySelector(".fb-text-subheading");
@@ -121,7 +121,7 @@ describe("WelcomeCard", () => {
expect(longTimeDisplay).toHaveTextContent(/Takes 6\+ minutes/);
});
it("shows both time and response count when both flags are true", () => {
test("shows both time and response count when both flags are true", () => {
const { container } = render(
<WelcomeCard
{...defaultProps}
@@ -141,7 +141,7 @@ describe("WelcomeCard", () => {
expect(textDisplay).toHaveTextContent(/Takes.*10 people responded/);
});
it("handles missing optional props gracefully", () => {
test("handles missing optional props gracefully", () => {
const minimalProps = {
...defaultProps,
headline: undefined,
@@ -157,7 +157,7 @@ describe("WelcomeCard", () => {
expect(container.querySelector("button")).toBeInTheDocument();
});
it("handles Enter key press correctly based on survey type and isCurrent", () => {
test("handles Enter key press correctly based on survey type and isCurrent", () => {
const mockOnSubmit = vi.fn();
// Test when survey is not link type
const { rerender, unmount } = render(
@@ -177,7 +177,7 @@ describe("WelcomeCard", () => {
unmount();
});
it("prevents default on Enter key in button", () => {
test("prevents default on Enter key in button", () => {
const { container } = render(<WelcomeCard {...defaultProps} />);
const button = container.querySelector("button");
const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true });
@@ -188,7 +188,7 @@ describe("WelcomeCard", () => {
expect(event.preventDefault).toHaveBeenCalled();
});
it("properly cleans up event listeners on unmount", () => {
test("properly cleans up event listeners on unmount", () => {
const { unmount } = render(<WelcomeCard {...defaultProps} />);
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
@@ -198,7 +198,7 @@ describe("WelcomeCard", () => {
removeEventListenerSpy.mockRestore();
});
it("handles response counts at boundary conditions", () => {
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/);
@@ -208,7 +208,7 @@ describe("WelcomeCard", () => {
expect(container4.querySelector(".fb-text-xs")).toHaveTextContent(/4 people responded/);
});
it("handles time calculation edge cases", () => {
test("handles time calculation edge cases", () => {
// Test with no questions
const emptyQuestionsSurvey = {
...mockSurvey,
@@ -231,7 +231,7 @@ describe("WelcomeCard", () => {
expect(boundaryContainer.querySelector(".fb-text-subheading")).toHaveTextContent(/Takes 6 minutes/);
});
it("correctly processes localized content", () => {
test("correctly processes localized content", () => {
const localizedProps = {
...defaultProps,
headline: { default: "Welcome", es: "Bienvenido" },
@@ -247,7 +247,7 @@ describe("WelcomeCard", () => {
expect(container.querySelector("button")).toHaveTextContent("Comenzar");
});
it("handles variable replacement in content", () => {
test("handles variable replacement in content", () => {
const propsWithVariables = {
...defaultProps,
headline: { default: "Welcome #recall:name/fallback:Guest#" },

View File

@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "preact/hooks";
import { useEffect, useRef } from "preact/hooks";
import { type TPlacement } from "@formbricks/types/common";
interface SurveyContainerProps {
@@ -21,16 +21,10 @@ export function SurveyContainer({
clickOutside,
isOpen = true,
}: SurveyContainerProps) {
const [show, setShow] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const isCenter = placement === "center";
const isModal = mode === "modal";
useEffect(() => {
setShow(isOpen);
}, [isOpen]);
useEffect(() => {
if (!isModal) return;
if (!isCenter) return;
@@ -38,7 +32,7 @@ export function SurveyContainer({
const handleClickOutside = (e: MouseEvent) => {
if (
clickOutside &&
show &&
isOpen &&
modalRef.current &&
!(modalRef.current as HTMLElement).contains(e.target as Node) &&
onClose
@@ -50,7 +44,7 @@ export function SurveyContainer({
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [show, clickOutside, onClose, isCenter, isModal]);
}, [clickOutside, onClose, isCenter, isModal, isOpen]);
const getPlacementStyle = (placement: TPlacement): string => {
switch (placement) {
@@ -69,7 +63,7 @@ export function SurveyContainer({
}
};
if (!show) return null;
if (!isOpen) return null;
if (!isModal) {
return (
@@ -98,7 +92,7 @@ export function SurveyContainer({
ref={modalRef}
className={cn(
getPlacementStyle(placement),
show ? "fb-opacity-100" : "fb-opacity-0",
isOpen ? "fb-opacity-100" : "fb-opacity-0",
"fb-rounded-custom fb-pointer-events-auto fb-absolute fb-bottom-0 fb-h-fit fb-w-full fb-overflow-visible fb-bg-white fb-shadow-lg fb-transition-all fb-duration-500 fb-ease-in-out sm:fb-m-4 sm:fb-max-w-sm"
)}>
<div>{children}</div>