Compare commits

...

47 Commits

Author SHA1 Message Date
Dhruwang
d8d302dffe fix survey auto close 2025-05-27 09:34:35 +05:30
Jakob Schott
e9c45daf71 updated tests 2025-05-26 18:40:46 +02:00
Johannes
259f219eea Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-26 23:28:46 +07:00
Dhruwang
828ce86764 fixed extra card visible issue 2025-05-23 15:51:54 +05:30
Dhruwang
f4fe8674fd tweaks 2025-05-23 15:43:48 +05:30
Dhruwang
25e4575172 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-23 11:40:38 +05:30
Jakob Schott
acb6db4939 Adapted test to accomedate that checkboxes use labels instead of text now 2025-05-22 10:00:59 +02:00
Jakob Schott
19221a65d7 Changed tests for NPS and Rating question, to accomedate new required preset in editor 2025-05-22 08:46:01 +02:00
Jakob Schott
a65cd855dc Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-21 19:01:40 +02:00
Jakob Schott
dabd5d361f removed unnecessary interactions form tests, to accomedate default required value 2025-05-21 19:01:32 +02:00
Jakob Schott
dc47586813 Removed icon component and used svg to not fail tests 2025-05-21 18:14:10 +02:00
Jakob Schott
be1a6f01c3 Adapted tests to accomedate new editor (not-requierd preselected for surveys) 2025-05-21 17:07:37 +02:00
Jakob Schott
8dbad6f5ef Fixed build errors 2025-05-21 15:37:24 +02:00
Jakob Schott
f75e5bc7b4 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-21 15:25:56 +02:00
Jakob Schott
9930f9af03 restructured image in pictureselection, to make image download link visible. changed icon 2025-05-21 15:25:18 +02:00
Jakob Schott
3ff20d04e0 Added y-margin to endingcard for in-app surveys to preserve height. ScrollableContainer accepts CSS classes 2025-05-21 13:57:03 +02:00
Jakob Schott
8bac86638c Fixed height for welcome card. moved timetofinish and responseCount in scrollable container. removed unnecessary margin for Submitbutton 2025-05-21 13:28:10 +02:00
Johannes
100b15ac88 tweaks 2025-05-21 12:33:25 +07:00
Jakob Schott
ddb1de95c4 minor bug in tests, accomedating new component structure 2025-05-20 16:41:38 +02:00
Jakob Schott
913a6b5135 adapted failing tests on Rating and NPS to accomedate for new handling of required questions 2025-05-20 12:42:08 +02:00
Jakob Schott
8b56786be5 Updated test to reflect code changes 2025-05-19 23:20:18 +02:00
Jakob Schott
a8a8cf6c88 Updated tests 2025-05-19 22:44:33 +02:00
Jakob Schott
dc0cc5e526 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-19 22:13:15 +02:00
Jakob Schott
00e0307c81 Several fixed too smoothen animations when changing cards 2025-05-19 22:02:36 +02:00
Jakob Schott
84d4c59532 fix for in-product survey card height 2025-05-19 20:43:02 +02:00
Jakob Schott
fba455c47f fixed left over card that was visible in product by setting its opacity to 0 2025-05-19 15:00:30 +02:00
Johannes
fd777ca227 blind tweak 2025-05-17 14:44:46 +07:00
Johannes
512e9fb0a7 add autofocus to date question 2025-05-17 14:42:31 +07:00
Johannes
d9be37a134 fix survey package build 2025-05-17 14:40:04 +07:00
Johannes
187e509b41 Update authOptions.ts 2025-05-17 00:26:06 -07:00
Johannes
9499e6265b Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-17 14:24:14 +07:00
Johannes
3564faa638 tweaks 2025-05-17 14:23:47 +07:00
Johannes
5356ce4ed2 make questions default optional, sonarqube less verbose 2025-05-17 12:13:58 +07:00
Johannes
03ddf3d09a finish link survey tweaks 2025-05-17 12:02:05 +07:00
Jakob Schott
31496ee092 adapted test to include additional card in the stack 2025-05-15 15:43:02 +02:00
Jakob Schott
0f2b5e1709 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-15 15:36:24 +02:00
Jakob Schott
b5a0b165ed Fixed mobile view for link survey 2025-05-15 15:36:08 +02:00
Jakob Schott
25f8b2d07f added constraints based on viewport height 2025-05-15 14:35:12 +02:00
Jakob Schott
0b88e58dcb working and smooth animation 2025-05-15 13:55:49 +02:00
Jakob Schott
89985d4f4f Reduced height of surveys for previews and handled overflow, if survey height exceeds preview height 2025-05-15 09:24:06 +02:00
Jakob Schott
39e5518f2c updated test files 2025-05-14 22:19:15 +02:00
Jakob Schott
1954c5ca61 Fix for mobile views padding 2025-05-14 22:04:40 +02:00
Jakob Schott
b7679aa336 CSSProperties to handle sizing based on survey type 2025-05-14 21:49:11 +02:00
Johannes
9e7c1d5245 fix height and reduce motion distance 2025-05-14 21:41:55 +07:00
Johannes
4f9f064e76 Update packages/surveys/src/components/wrappers/scrollable-container.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-14 06:44:28 -07:00
Jakob Schott
6a9833dbeb smoothend animations for top aligned link-surveys 2025-05-14 10:53:27 +02:00
Johannes
94070774ad tweaks survey height 2025-05-13 10:59:19 +07:00
48 changed files with 447 additions and 313 deletions

View File

@@ -41,7 +41,7 @@ describe("Survey Builder", () => {
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
shuffleOption: "none",
required: true,
required: false,
});
expect(question.choices.length).toBe(3);
expect(question.id).toBeDefined();
@@ -141,7 +141,7 @@ describe("Survey Builder", () => {
inputType: "text",
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
charLimit: {
enabled: false,
},
@@ -204,7 +204,7 @@ describe("Survey Builder", () => {
range: 5,
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
@@ -265,7 +265,7 @@ describe("Survey Builder", () => {
headline: { default: "NPS Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
@@ -324,7 +324,7 @@ describe("Survey Builder", () => {
label: { default: "I agree to terms" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
});
expect(question.id).toBeDefined();
});
@@ -377,7 +377,7 @@ describe("Survey Builder", () => {
headline: { default: "CTA Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
buttonExternal: false,
});
expect(question.id).toBeDefined();

View File

@@ -66,7 +66,7 @@ export const buildMultipleChoiceQuestion = ({
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
shuffleOption: shuffleOption || "none",
required: required ?? true,
required: required ?? false,
logic,
};
};
@@ -105,7 +105,7 @@ export const buildOpenTextQuestion = ({
headline: { default: headline },
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
required: required ?? true,
required: required ?? false,
longAnswer,
logic,
charLimit: {
@@ -153,7 +153,7 @@ export const buildRatingQuestion = ({
range,
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
required: required ?? true,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? { default: lowerLabel } : undefined,
upperLabel: upperLabel ? { default: upperLabel } : undefined,
@@ -194,7 +194,7 @@ export const buildNPSQuestion = ({
headline: { default: headline },
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
required: required ?? true,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? { default: lowerLabel } : undefined,
upperLabel: upperLabel ? { default: upperLabel } : undefined,
@@ -230,7 +230,7 @@ export const buildConsentQuestion = ({
headline: { default: headline },
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
required: required ?? true,
required: required ?? false,
label: { default: label },
logic,
};
@@ -269,7 +269,7 @@ export const buildCTAQuestion = ({
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
dismissButtonLabel: dismissButtonLabel ? { default: dismissButtonLabel } : undefined,
required: required ?? true,
required: required ?? false,
buttonExternal,
buttonUrl,
logic,

View File

@@ -105,7 +105,10 @@ export const env = createEnv({
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
SESSION_MAX_AGE: z.string().transform((val) => parseInt(val)).optional(),
SESSION_MAX_AGE: z
.string()
.transform((val) => parseInt(val))
.optional(),
},
/*

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

@@ -34,7 +34,7 @@ export const EditPublicSurveyAlertDialog = ({
label: secondaryButtonText,
onClick: secondaryButtonAction,
disabled: isLoading,
variant: "outline",
variant: "secondary",
});
}
if (primaryButtonAction) {

View File

@@ -178,7 +178,7 @@ export const FileUploadQuestionForm = ({
</Button>
)}
</div>
<div className="mb-8 mt-6 space-y-6">
<div className="mt-6 space-y-6">
<AdvancedOptionToggle
isChecked={question.allowMultipleFiles}
onToggle={() => updateQuestion(questionIdx, { allowMultipleFiles: !question.allowMultipleFiles })}

View File

@@ -341,7 +341,7 @@ export const getCXQuestionNameMap = (t: TFnType) =>
) as Record<TSurveyQuestionTypeEnum, string>;
export const universalQuestionPresets = {
required: true,
required: false,
};
export const getQuestionDefaults = (id: string, project: any, t: TFnType) => {

View File

@@ -78,7 +78,7 @@ export const LinkSurveyWrapper = ({
surveyType={surveyType}
styling={styling}
onBackgroundLoaded={handleBackgroundLoaded}>
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
<div className="flex max-h-dvh min-h-dvh items-start justify-center overflow-clip pt-[16dvh]">
{!styling.isLogoHidden && project.logo?.url && <ClientLogo projectLogo={project.logo} />}
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
{isPreview && (

View File

@@ -166,6 +166,7 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
return (
<div
ref={ContentRef}
id="mobile-preview"
className={`relative h-[90%] max-h-[42rem] w-[22rem] overflow-hidden rounded-[3rem] border-[6px] border-slate-400 ${getFilterStyle()}`}>
{/* below element is use to create notch for the mobile device mockup */}
<div className="absolute left-1/2 right-1/2 top-2 z-20 h-4 w-1/3 -translate-x-1/2 transform rounded-full bg-slate-400"></div>
@@ -175,10 +176,10 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
);
} else if (isEditorView) {
return (
<div ref={ContentRef} className="overflow-hiddem flex flex-grow flex-col rounded-b-lg">
<div ref={ContentRef} className="flex flex-grow flex-col overflow-hidden rounded-b-lg">
<div className="relative flex w-full flex-grow flex-col items-center justify-center p-4 py-6">
{renderBackground()}
<div className="flex h-full w-full items-center justify-center">{children}</div>
<div className="flex h-full w-full items-start justify-center pt-[10dvh]">{children}</div>
</div>
</div>
);

View File

@@ -289,7 +289,7 @@ export const PreviewSurvey = ({
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
)}
</div>
<div className="z-10 w-full max-w-md rounded-lg border border-transparent">
<div className="z-10 w-full rounded-lg border border-transparent">
<SurveyInline
isPreviewMode={true}
survey={{ ...survey, type: "link" }}

View File

@@ -103,7 +103,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
page.locator("#questionCard-3").getByText(surveys.createAndSubmit.ratingQuestion.highLabel)
).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).not.toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
await page.locator("path").nth(3).click();
@@ -115,7 +115,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(
page.locator("#questionCard-4").getByText(surveys.createAndSubmit.npsQuestion.highLabel)
).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
for (let i = 0; i < 11; i++) {
@@ -135,7 +135,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(page.getByText(surveys.createAndSubmit.consentQuestion.checkboxLabel)).toBeVisible();
await expect(page.locator("#questionCard-6").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-6").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByText(surveys.createAndSubmit.consentQuestion.checkboxLabel).check();
await page.getByLabel(surveys.createAndSubmit.consentQuestion.checkboxLabel).check();
await page.locator("#questionCard-6").getByRole("button", { name: "Next" }).click();
// Picture Select Question
@@ -760,7 +760,7 @@ test.describe("Testing Survey with advanced logic", async () => {
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.highLabel)
).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("group", { name: "Choices" }).locator("path").nth(3).click();
@@ -772,7 +772,7 @@ test.describe("Testing Survey with advanced logic", async () => {
await expect(
page.locator("#questionCard-5").getByText(surveys.createWithLogicAndSubmit.npsQuestion.highLabel)
).toBeVisible();
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Next" })).not.toBeVisible();
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Back" })).toBeVisible();
for (let i = 0; i < 11; i++) {
@@ -831,7 +831,7 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByText(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
await page.getByLabel(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
// File Upload Question

View File

@@ -418,7 +418,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
await page.getByLabel("Required").click();
// Multi Select Question
await page
@@ -463,8 +462,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
},
]);
await page.getByLabel("Required").click();
// Rating Question
await page
.locator("div")
@@ -510,7 +507,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.getByRole("button", { name: "Add option" }).click();
await page.getByPlaceholder("Option 5").click();
await page.getByPlaceholder("Option 5").fill(params.ranking.choices[4]);
await page.getByLabel("Required").click();
// Matrix Question
await page
@@ -549,7 +545,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.getByRole("button", { name: "Statement (Call to Action)" }).click();
await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question);
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
await page.getByLabel("Required").click();
// Consent Question
await page
@@ -578,7 +573,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.click();
await page.getByRole("button", { name: "Date" }).click();
await page.getByLabel("Question*").fill(params.date.question);
await page.getByLabel("Required").click();
// Cal Question
await page
@@ -588,7 +582,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.click();
await page.getByRole("button", { name: "Schedule a meeting" }).click();
await page.getByLabel("Question*").fill(params.cal.question);
await page.getByLabel("Required").click();
// Fill Address Question
await page
@@ -633,8 +626,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.getByRole("option", { name: "secret" }).click();
await page.locator("#action-2-operator").click();
await page.getByRole("option", { name: "Assign =" }).click();
await page.getByRole("textbox", { name: "Value" }).click();
await page.getByRole("textbox", { name: "Value" }).fill("This ");
await page.locator("#action-2-value-input").click();
await page.locator("#action-2-value-input").fill("1");
// Single Select Question
await page.getByRole("heading", { name: params.singleSelectQuestion.question }).click();

View File

@@ -258,7 +258,9 @@ export const setup = async (
});
const surveyNames = filteredSurveys.map((s) => s.name);
logger.debug(`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`);
logger.debug(
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
);
} catch {
logger.debug("Error during sync. Please try again.");
}
@@ -314,9 +316,11 @@ export const setup = async (
environment: environmentState,
filteredSurveys,
});
const surveyNames = filteredSurveys.map((s) => s.name);
logger.debug(`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`);
logger.debug(
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
);
} catch (e) {
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
}

View File

@@ -109,8 +109,10 @@ export function EndingCard({
// eslint-disable-next-line react-hooks/exhaustive-deps -- we only want to run this effect when isCurrent changes
}, [isCurrent]);
const marginPreservingHeight = survey.type === "app" ? "fb-my-[37px]" : "";
return (
<ScrollableContainer>
<ScrollableContainer className={marginPreservingHeight}>
<div className="fb-text-center">
{isResponseSendingFinished ? (
<>
@@ -136,7 +138,7 @@ export function EndingCard({
questionId="EndingCard"
/>
{endingCard.buttonLabel ? (
<div className="fb-mt-6 fb-flex fb-w-full fb-flex-col fb-items-center fb-justify-center fb-space-y-4">
<div className="fb-mt-4 fb-flex fb-w-full fb-flex-col fb-items-center fb-justify-center fb-space-y-4">
<SubmitButton
buttonLabel={replaceRecallInfo(
getLocalizedValue(endingCard.buttonLabel, languageCode),

View File

@@ -342,7 +342,7 @@ export function FileInput({
{showUploader ? (
<button
type="button"
className="focus:fb-outline-brand fb-flex fb-flex-col fb-items-center fb-justify-center fb-py-6 hover:fb-cursor-pointer w-full"
className="focus:fb-outline-brand fb-flex fb-flex-col fb-items-center fb-justify-center fb-py-10 hover:fb-cursor-pointer w-full"
aria-label="Upload files by clicking or dragging them here"
onClick={() => document.getElementById(uniqueHtmlFor)?.click()}>
<svg

View File

@@ -1,3 +1,4 @@
import { cn } from "@/lib/utils";
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
import { useState } from "preact/hooks";
@@ -26,7 +27,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
const [isLoading, setIsLoading] = useState(true);
return (
<div className="fb-group/image fb-relative fb-mb-4 fb-block fb-min-h-40 fb-rounded-md">
<div className="fb-group/image fb-relative fb-mb-6 fb-block fb-min-h-40 fb-rounded-md">
{isLoading ? (
<div className="fb-absolute fb-inset-auto fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
) : null}
@@ -35,7 +36,10 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
key={imgUrl}
src={imgUrl}
alt={altText}
className="fb-rounded-custom"
className={cn(
"fb-rounded-custom fb-max-h-[40dvh] fb-mx-auto fb-object-contain",
isLoading ? "fb-opacity-0" : ""
)}
onLoad={() => {
setIsLoading(false);
}}
@@ -48,7 +52,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
src={videoUrlWithParams}
title="Question Video"
frameBorder="0"
className="fb-rounded-custom fb-aspect-video fb-w-full"
className={cn("fb-rounded-custom fb-aspect-video fb-w-full", isLoading ? "fb-opacity-0" : "")}
onLoad={() => {
setIsLoading(false);
}}

View File

@@ -1,6 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Ensure screen is imported
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Use test consistently
import { RenderSurvey } from "./render-survey";
// Stub SurveyContainer to render children and capture props
@@ -21,17 +23,33 @@ vi.mock("./survey", () => ({
},
}));
// Mock ResizeObserver
let resizeCallback: Function | undefined;
const ResizeObserverMock = vi.fn((callback) => {
resizeCallback = callback;
return {
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
};
});
global.ResizeObserver = ResizeObserverMock as any;
describe("RenderSurvey", () => {
beforeEach(() => {
surveySpy.mockClear();
vi.useFakeTimers();
// Reset styles on documentElement before each test
document.documentElement.style.removeProperty("--fb-survey-card-max-height");
document.documentElement.style.removeProperty("--fb-survey-card-min-height");
resizeCallback = undefined; // Reset callback for each test
});
afterEach(() => {
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 +81,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 +106,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;
@@ -115,7 +133,7 @@ describe("RenderSurvey", () => {
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 +157,49 @@ describe("RenderSurvey", () => {
vi.advanceTimersByTime(5000);
expect(onClose).not.toHaveBeenCalled();
});
// New tests for surveyTypeStyles
test("should apply correct styles for link surveys", () => {
const propsForLinkSurvey = {
survey: { type: "link", endings: [] },
styling: {},
isBrandingEnabled: false,
languageCode: "en",
onClose: vi.fn(),
onFinished: vi.fn(),
placement: "bottomRight",
mode: "modal",
} as any;
render(<RenderSurvey {...propsForLinkSurvey} />);
// Manually trigger the ResizeObserver callback if it was captured
if (resizeCallback) {
resizeCallback();
}
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-max-height")).toBe("56dvh");
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-min-height")).toBe("0");
});
test("should apply correct styles for app (non-link) surveys", () => {
const propsForAppSurvey = {
survey: { type: "app", endings: [] },
styling: {},
isBrandingEnabled: false,
languageCode: "en",
onClose: vi.fn(),
onFinished: vi.fn(),
placement: "bottomRight",
mode: "modal",
} as any;
render(<RenderSurvey {...propsForAppSurvey} />);
// Manually trigger the ResizeObserver callback if it was captured
if (resizeCallback) {
resizeCallback();
}
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-max-height")).toBe("40dvh");
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-min-height")).toBe("40dvh");
});
});

View File

@@ -1,11 +1,35 @@
import { useState } from "react";
import { useEffect, 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) {
export function RenderSurvey(props: Readonly<SurveyContainerProps>) {
const [isOpen, setIsOpen] = useState(true);
// Check viewport width on mount and resize
useEffect(() => {
const root = document.documentElement;
const resizeObserver = new ResizeObserver(() => {
const isDesktop = window.innerWidth > 768;
if (props.survey.type === "link") {
root.style.setProperty("--fb-survey-card-max-height", isDesktop ? "56dvh" : "60dvh");
root.style.setProperty("--fb-survey-card-min-height", isDesktop ? "0" : "42dvh");
} else {
root.style.setProperty("--fb-survey-card-max-height", "40dvh");
root.style.setProperty("--fb-survey-card-min-height", "40dvh");
}
});
resizeObserver.observe(document.body);
return () => {
resizeObserver.disconnect();
root.style.removeProperty("--fb-survey-card-max-height");
root.style.removeProperty("--fb-survey-card-min-height");
};
}, [props.survey.type]);
const close = () => {
setIsOpen(false);
setTimeout(() => {

View File

@@ -139,7 +139,7 @@ export function WelcomeCard({
{fileUrl ? (
<img
src={fileUrl}
className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-rounded-lg fb-object-contain"
className="fb-mb-8 fb-max-h-80 fb-w-1/4 fb-rounded-lg fb-object-contain"
alt="Company Logo"
/>
) : null}
@@ -156,9 +156,36 @@ export function WelcomeCard({
htmlString={replaceRecallInfo(getLocalizedValue(html, languageCode), responseData, variablesData)}
questionId="welcomeCard"
/>
{timeToFinish && !showResponseCount ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs">
<span> Takes {calculateTimeToComplete()} </span>
</p>
</div>
) : null}
{showResponseCount && !timeToFinish && responseCount && responseCount > 3 ? (
<div className="fb-items-center fb-text-subheading fb-my-4 b-flex">
<UsersIcon />
<p className="fb-pt-1 fb-text-xs">
<span>{`${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">
<span> Takes {calculateTimeToComplete()} </span>
<span>
{responseCount && responseCount > 3 ? `${responseCount.toString()} people responded` : ""}
</span>
</p>
</div>
) : null}
</div>
</ScrollableContainer>
<div className="fb-mx-6 fb-mt-4 fb-flex fb-gap-4 fb-py-4">
<div className="fb-px-6 fb-py-4 fb-flex fb-gap-4">
<SubmitButton
buttonLabel={getLocalizedValue(buttonLabel, languageCode)}
isLastQuestion={false}
@@ -173,33 +200,6 @@ export function WelcomeCard({
}}
/>
</div>
{timeToFinish && !showResponseCount ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs">
<span> Takes {calculateTimeToComplete()} </span>
</p>
</div>
) : null}
{showResponseCount && !timeToFinish && responseCount && responseCount > 3 ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
<UsersIcon />
<p className="fb-pt-1 fb-text-xs">
<span>{`${responseCount.toString()} people responded`}</span>
</p>
</div>
) : null}
{timeToFinish && showResponseCount ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs">
<span> Takes {calculateTimeToComplete()} </span>
<span>
{responseCount && responseCount > 3 ? `${responseCount.toString()} people responded` : ""}
</span>
</p>
</div>
) : null}
</div>
);
}

View File

@@ -43,7 +43,7 @@ export function AddressQuestion({
currentQuestionId,
autoFocusEnabled,
isBackButtonHidden,
}: AddressQuestionProps) {
}: Readonly<AddressQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
@@ -136,7 +136,7 @@ export function AddressQuestion({
questionId={question.id}
/>
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
<div className="fb-mt-4 fb-w-full fb-grid fb-grid-cols-1 fb-gap-3">
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {

View File

@@ -40,7 +40,7 @@ export function CalQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: CalQuestionProps) {
}: Readonly<CalQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const [errorMessage, setErrorMessage] = useState("");

View File

@@ -40,7 +40,7 @@ export function ConsentQuestion({
currentQuestionId,
autoFocusEnabled,
isBackButtonHidden,
}: ConsentQuestionProps) {
}: Readonly<ConsentQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;
@@ -48,8 +48,7 @@ export function ConsentQuestion({
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
const consentRef = useCallback(
(currentElement: HTMLLabelElement | null) => {
// will focus on current element when the question ID matches the current question
(currentElement: HTMLButtonElement | null) => {
if (question.id && currentElement && autoFocusEnabled && question.id === currentQuestionId) {
currentElement.focus();
}
@@ -80,14 +79,14 @@ export function ConsentQuestion({
htmlString={getLocalizedValue(question.html, languageCode) || ""}
questionId={question.id}
/>
<div className="fb-bg-survey-bg fb-sticky -fb-bottom-2 fb-z-10 fb-w-full fb-px-1 fb-py-1">
<label
<div className="fb-bg-survey-bg fb-sticky -fb-bottom-2 fb-z-10 fb-w-full fb-py-2">
<button
type="button"
ref={consentRef}
dir="auto"
tabIndex={isCurrent ? 0 : -1}
id={`${question.id}-label`}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(question.id)?.click();
@@ -95,28 +94,30 @@ export function ConsentQuestion({
}
}}
className="fb-border-border fb-bg-input-bg fb-text-heading hover:fb-bg-input-bg-selected focus:fb-bg-input-bg-selected focus:fb-ring-brand fb-rounded-custom fb-relative fb-z-10 fb-my-2 fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-border fb-p-4 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
<input
tabIndex={-1}
type="checkbox"
id={question.id}
name={question.id}
value={getLocalizedValue(question.label, languageCode)}
onChange={(e) => {
if (e.target instanceof HTMLInputElement && e.target.checked) {
onChange({ [question.id]: "accepted" });
} else {
onChange({ [question.id]: "" });
}
}}
checked={value === "accepted"}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${question.id}-label`}
required={question.required}
/>
<span id={`${question.id}-label`} className="fb-ml-3 fb-mr-3 fb-font-medium">
{getLocalizedValue(question.label, languageCode)}
</span>
</label>
<label className="fb-flex fb-w-full fb-cursor-pointer fb-items-center">
<input
tabIndex={-1}
type="checkbox"
id={question.id}
name={question.id}
value={getLocalizedValue(question.label, languageCode)}
onChange={(e) => {
if (e.target instanceof HTMLInputElement && e.target.checked) {
onChange({ [question.id]: "accepted" });
} else {
onChange({ [question.id]: "" });
}
}}
checked={value === "accepted"}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${question.id}-label`}
required={question.required}
/>
<span id={`${question.id}-label`} className="fb-ml-3 fb-mr-3 fb-font-medium">
{getLocalizedValue(question.label, languageCode)}
</span>
</label>
</button>
</div>
</div>
</ScrollableContainer>

View File

@@ -43,7 +43,7 @@ export function ContactInfoQuestion({
currentQuestionId,
autoFocusEnabled,
isBackButtonHidden,
}: ContactInfoQuestionProps) {
}: Readonly<ContactInfoQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
@@ -131,7 +131,7 @@ export function ContactInfoQuestion({
questionId={question.id}
/>
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
<div className="fb-mt-4 fb-w-full fb-grid fb-grid-cols-1 fb-gap-3">
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {

View File

@@ -41,7 +41,7 @@ export function CTAQuestion({
currentQuestionId,
isBackButtonHidden,
onOpenExternalURL,
}: CTAQuestionProps) {
}: Readonly<CTAQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;

View File

@@ -8,9 +8,8 @@ import { getMonthName, getOrdinalDate } from "@/lib/date-time";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useEffect, useMemo, useState } from "preact/hooks";
import DatePicker from "react-date-picker";
import { DatePickerProps } from "react-date-picker";
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
import DatePicker, { DatePickerProps } from "react-date-picker";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyDateQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import "../../styles/date-picker.css";
@@ -23,13 +22,12 @@ interface DateQuestionProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
autoFocus?: boolean;
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentQuestionId: TSurveyQuestionId;
isBackButtonHidden: boolean;
autoFocusEnabled?: boolean;
}
function CalendarIcon() {
@@ -94,7 +92,8 @@ export function DateQuestion({
ttc,
currentQuestionId,
isBackButtonHidden,
}: DateQuestionProps) {
autoFocusEnabled,
}: Readonly<DateQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState("");
const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -104,6 +103,15 @@ export function DateQuestion({
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value ? new Date(value) : undefined);
const [hideInvalid, setHideInvalid] = useState(!selectedDate);
const datePickerRef = useCallback(
(currentElement: HTMLButtonElement | null) => {
if (currentElement && autoFocusEnabled && isCurrent) {
currentElement.focus();
}
},
[autoFocusEnabled, isCurrent]
);
useEffect(() => {
if (datePickerOpen) {
if (!selectedDate) setSelectedDate(new Date());
@@ -170,6 +178,7 @@ export function DateQuestion({
<div className="fb-relative">
{!datePickerOpen && (
<button
ref={datePickerRef}
onClick={() => {
setDatePickerOpen(true);
}}

View File

@@ -14,21 +14,21 @@ import { FileInput } from "../general/file-input";
import { Subheader } from "../general/subheader";
interface FileUploadQuestionProps {
readonly question: TSurveyFileUploadQuestion;
readonly value: string[];
readonly onChange: (responseData: TResponseData) => void;
readonly onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
readonly onBack: () => void;
readonly onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
readonly isFirstQuestion: boolean;
readonly isLastQuestion: boolean;
readonly surveyId: string;
readonly languageCode: string;
readonly ttc: TResponseTtc;
readonly setTtc: (ttc: TResponseTtc) => void;
readonly autoFocusEnabled: boolean;
readonly currentQuestionId: TSurveyQuestionId;
readonly isBackButtonHidden: boolean;
question: TSurveyFileUploadQuestion;
value: string[];
onChange: (responseData: TResponseData) => void;
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
onBack: () => void;
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
isFirstQuestion: boolean;
isLastQuestion: boolean;
surveyId: string;
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentQuestionId: TSurveyQuestionId;
isBackButtonHidden: boolean;
}
export function FileUploadQuestion({
@@ -46,7 +46,7 @@ export function FileUploadQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: FileUploadQuestionProps) {
}: Readonly<FileUploadQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);

View File

@@ -40,7 +40,7 @@ export function MatrixQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: MatrixQuestionProps) {
}: Readonly<MatrixQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);

View File

@@ -41,7 +41,7 @@ export function MultipleChoiceMultiQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: MultipleChoiceMultiProps) {
}: Readonly<MultipleChoiceMultiProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);

View File

@@ -41,7 +41,7 @@ export function MultipleChoiceSingleQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: MultipleChoiceSingleProps) {
}: Readonly<MultipleChoiceSingleProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [otherSelected, setOtherSelected] = useState(false);
const otherSpecify = useRef<HTMLInputElement | null>(null);

View File

@@ -167,19 +167,6 @@ describe("NPSQuestion", () => {
expect(getUpdatedTtc).toHaveBeenCalled();
});
test("updates hover state when mouse moves over options", () => {
render(<NPSQuestion {...mockProps} />);
const option = screen.getByText("5").closest("label");
expect(option).toBeInTheDocument();
fireEvent.mouseOver(option!);
expect(option).toHaveClass("fb-bg-accent-bg");
fireEvent.mouseLeave(option!);
expect(option).not.toHaveClass("fb-bg-accent-bg");
});
test("supports keyboard navigation", () => {
render(<NPSQuestion {...mockProps} />);

View File

@@ -40,7 +40,7 @@ export function NPSQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: NPSQuestionProps) {
}: Readonly<NPSQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [hoveredNumber, setHoveredNumber] = useState(-1);
const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -96,17 +96,15 @@ export function NPSQuestion({
<div className="fb-flex">
{Array.from({ length: 11 }, (_, i) => i).map((number, idx) => {
return (
<label
<button
type="button"
key={number}
tabIndex={isCurrent ? 0 : -1}
onMouseOver={() => {
setHoveredNumber(number);
}}
onMouseLeave={() => {
setHoveredNumber(-1);
}}
onMouseOver={() => setHoveredNumber(number)}
onMouseLeave={() => setHoveredNumber(-1)}
onFocus={() => setHoveredNumber(number)}
onBlur={() => setHoveredNumber(-1)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
@@ -120,29 +118,29 @@ export function NPSQuestion({
"fb-text-heading first:fb-rounded-l-custom last:fb-rounded-r-custom focus:fb-border-brand fb-relative fb-h-10 fb-flex-1 fb-cursor-pointer fb-overflow-hidden fb-border-b fb-border-l fb-border-t fb-text-center fb-text-sm last:fb-border-r focus:fb-border-2 focus:fb-outline-none",
question.isColorCodingEnabled
? "fb-h-[46px] fb-leading-[3.5em]"
: "fb-h fb-leading-10",
: "fb-h-[41px] fb-leading-10",
hoveredNumber === number ? "fb-bg-accent-bg" : ""
)}>
{question.isColorCodingEnabled ? (
<div
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getNPSOptionColor(idx)}`}
<label className="fb-w-full fb-h-full fb-flex fb-items-center fb-justify-center">
{question.isColorCodingEnabled ? (
<div
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getNPSOptionColor(idx)}`}
/>
) : null}
<input
type="radio"
id={number.toString()}
name="nps"
value={number}
checked={value === number}
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => handleClick(number)}
required={question.required}
tabIndex={-1}
/>
) : null}
<input
type="radio"
id={number.toString()}
name="nps"
value={number}
checked={value === number}
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleClick(number);
}}
required={question.required}
tabIndex={-1}
/>
{number}
</label>
{number}
</label>
</button>
);
})}
</div>

View File

@@ -122,7 +122,7 @@ describe("OpenTextQuestion", () => {
test("renders textarea for long answers", () => {
render(<OpenTextQuestion {...defaultProps} question={{ ...defaultQuestion, longAnswer: true }} />);
expect(screen.getByRole("textbox")).toHaveAttribute("rows", "3");
expect(screen.getByRole("textbox")).toHaveAttribute("rows", "5");
});
test("displays character limit when configured", () => {

View File

@@ -20,7 +20,6 @@ interface OpenTextQuestionProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
autoFocus?: boolean;
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
@@ -43,7 +42,7 @@ export function OpenTextQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: OpenTextQuestionProps) {
}: Readonly<OpenTextQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [currentLength, setCurrentLength] = useState(value.length || 0);
const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -103,7 +102,7 @@ export function OpenTextQuestion({
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
<div className="fb-mt-4 fb-text-md">
{question.longAnswer === false ? (
<input
ref={inputRef as RefObject<HTMLInputElement>}
@@ -120,7 +119,7 @@ export function OpenTextQuestion({
onInput={(e) => {
handleInputChange(e.currentTarget.value);
}}
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0"
pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"}
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
@@ -135,7 +134,7 @@ export function OpenTextQuestion({
) : (
<textarea
ref={inputRef as RefObject<HTMLTextAreaElement>}
rows={3}
rows={5}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={question.id}
tabIndex={isCurrent ? 0 : -1}
@@ -148,7 +147,7 @@ export function OpenTextQuestion({
onInput={(e) => {
handleInputChange(e.currentTarget.value);
}}
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm"
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0"
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
maxLength={question.inputType === "text" ? question.charLimit?.max : undefined}

View File

@@ -171,7 +171,7 @@ describe("PictureSelectionQuestion", () => {
render(<PictureSelectionQuestion {...mockProps} />);
const images = screen.getAllByRole("img");
const label = images[0].closest("label");
const label = images[0].closest("button");
fireEvent.keyDown(label!, { key: " " });

View File

@@ -41,8 +41,15 @@ export function PictureSelectionQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: PictureSelectionProps) {
}: Readonly<PictureSelectionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>(() => {
const initialLoadingState: Record<string, boolean> = {};
question.choices.forEach((choice) => {
initialLoadingState[choice.id] = true;
});
return initialLoadingState;
});
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
@@ -115,35 +122,72 @@ export function PictureSelectionQuestion({
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-2 fb-gap-4">
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-1 sm:fb-grid-cols-2 fb-gap-4">
{questionChoices.map((choice) => (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
htmlFor={choice.id}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(choice.id)?.click();
document.getElementById(choice.id)?.focus();
}
}}
onClick={() => {
handleChange(choice.id);
}}
className={cn(
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus:fb-outline-none fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] focus:fb-border-brand focus:fb-border-4 group/image",
Array.isArray(value) && value.includes(choice.id)
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
: ""
)}>
<img
src={choice.imageUrl}
id={choice.id}
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
className="fb-h-full fb-w-full fb-object-cover"
/>
<div className="fb-relative" key={choice.id}>
<button
type="button"
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(choice.id)?.click();
document.getElementById(choice.id)?.focus();
}
}}
onClick={() => {
handleChange(choice.id);
}}
className={cn(
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus-visible:fb-outline-none focus-visible:fb-ring-2 focus-visible:fb-ring-brand focus-visible:fb-ring-offset-2 fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] group/image",
Array.isArray(value) && value.includes(choice.id)
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
: ""
)}>
{loadingImages[choice.id] && (
<div className="fb-absolute fb-inset-0 fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
)}
<img
src={choice.imageUrl}
id={choice.id}
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
className={cn(
"fb-h-full fb-w-full fb-object-cover",
loadingImages[choice.id] ? "fb-opacity-0" : ""
)}
onLoad={() => {
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
}}
/>
{question.allowMulti ? (
<input
id={`${choice.id}-checked`}
name={`${choice.id}-checkbox`}
type="checkbox"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
) : (
<input
id={`${choice.id}-radio`}
name={`${choice.id}-radio`}
type="radio"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
)}
</button>
<a
tabIndex={-1}
href={choice.imageUrl}
@@ -153,52 +197,25 @@ export function PictureSelectionQuestion({
onClick={(e) => {
e.stopPropagation();
}}
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
className="fb-absolute fb-bottom-4 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-expand">
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
className="lucide lucide-image-down-icon lucide-image-down">
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
<path d="m14 19 3 3v-5.5" />
<path d="m17 22 3-3" />
<circle cx="9" cy="9" r="2" />
</svg>
</a>
{question.allowMulti ? (
<input
id={`${choice.id}-checked`}
name={`${choice.id}-checkbox`}
type="checkbox"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
) : (
<input
id={`${choice.id}-radio`}
name={`${choice.id}-radio`}
type="radio"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
)}
</label>
</div>
))}
</div>
</fieldset>

View File

@@ -46,7 +46,7 @@ export function RankingQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: RankingQuestionProps) {
}: Readonly<RankingQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isCurrent = question.id === currentQuestionId;
const shuffledChoicesIds = useMemo(() => {

View File

@@ -56,6 +56,11 @@ export function AutoCloseWrapper({
};
useEffect(() => {
if (survey.autoClose) {
// Reset interaction state when auto-close is enabled
setHasInteracted(false);
setCountDownActive(true);
}
startCountdown();
return stopCountdown;
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to run this effect on every render

View File

@@ -124,28 +124,6 @@ describe("ScrollableContainer", () => {
});
});
test("uses 60dvh maxHeight by default when not in survey preview", () => {
vi.spyOn(document, "getElementById").mockReturnValue(null);
const { container } = render(
<ScrollableContainer>
<div>Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
expect(scrollableDiv).toHaveStyle({ maxHeight: "60dvh" });
});
test("uses 42dvh maxHeight when isSurveyPreview is true", () => {
vi.spyOn(document, "getElementById").mockReturnValue(document.createElement("div")); // Simulate survey-preview element exists
const { container } = render(
<ScrollableContainer>
<div>Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
expect(scrollableDiv).toHaveStyle({ maxHeight: "42dvh" });
});
test("cleans up scroll event listener on unmount", () => {
const { unmount, container } = render(
<ScrollableContainer>
@@ -213,4 +191,52 @@ describe("ScrollableContainer", () => {
);
}).not.toThrow();
});
test("applies reduced height when isSurveyPreview is true", () => {
// Create a survey-preview element to make isSurveyPreview true
const previewElement = document.createElement("div");
previewElement.id = "survey-preview";
document.body.appendChild(previewElement);
const { container } = render(
<ScrollableContainer>
<div>Preview Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>("#scrollable-container");
expect(scrollableDiv).toBeInTheDocument();
if (scrollableDiv) {
const computedStyle = scrollableDiv.style;
expect(computedStyle.maxHeight).toBe("calc(var(--fb-survey-card-max-height, 42dvh) * 0.66)");
expect(computedStyle.minHeight).toBe("calc(var(--fb-survey-card-min-height, 42dvh) * 0.66)");
}
// Clean up
document.body.removeChild(previewElement);
});
test("applies normal height when isSurveyPreview is false", () => {
// Ensure no survey-preview element exists
const existingPreview = document.getElementById("survey-preview");
if (existingPreview) {
document.body.removeChild(existingPreview);
}
const { container } = render(
<ScrollableContainer>
<div>Regular Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>("#scrollable-container");
expect(scrollableDiv).toBeInTheDocument();
if (scrollableDiv) {
const computedStyle = scrollableDiv.style;
expect(computedStyle.maxHeight).toBe("calc(var(--fb-survey-card-max-height, 42dvh) * 1)");
expect(computedStyle.minHeight).toBe("calc(var(--fb-survey-card-min-height, 42dvh) * 1)");
}
});
});

View File

@@ -3,21 +3,23 @@ import { useEffect, useRef, useState } from "preact/hooks";
import type { JSX } from "react";
interface ScrollableContainerProps {
className?: string;
children: JSX.Element;
}
export function ScrollableContainer({ children }: ScrollableContainerProps) {
export function ScrollableContainer({ className, children }: Readonly<ScrollableContainerProps>) {
const [isAtBottom, setIsAtBottom] = useState(false);
const [isAtTop, setIsAtTop] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const isSurveyPreview = Boolean(document.getElementById("survey-preview"));
const isMobilePreview = isSurveyPreview ? Boolean(document.getElementById("mobile-preview")) : false;
const previewScaleCoifficient = isSurveyPreview ? 0.66 : 1;
const checkScroll = () => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
setIsAtBottom(Math.round(scrollTop) + clientHeight >= scrollHeight);
setIsAtTop(scrollTop === 0);
};
@@ -45,12 +47,18 @@ export function ScrollableContainer({ children }: ScrollableContainerProps) {
<div className="fb-from-survey-bg fb-absolute fb-left-0 fb-right-2 fb-top-0 fb-z-10 fb-h-6 fb-bg-gradient-to-b fb-to-transparent" />
)}
<div
id="scrollable-container"
ref={containerRef}
style={{
scrollbarGutter: "stable both-edges",
maxHeight: isSurveyPreview ? "42dvh" : "60dvh",
maxHeight: isMobilePreview
? "30dvh"
: `calc(var(--fb-survey-card-max-height, 42dvh) * ${previewScaleCoifficient})`,
minHeight: isMobilePreview
? "30dvh"
: `calc(var(--fb-survey-card-min-height, 42dvh) * ${previewScaleCoifficient})`,
}}
className={cn("fb-overflow-auto fb-px-4 fb-pb-4 fb-bg-survey-bg")}>
className={cn(`fb-overflow-auto fb-px-4 fb-pb-4 fb-bg-survey-bg ${className}`)}>
{children}
</div>
{!isAtBottom && (

View File

@@ -1,5 +1,4 @@
import { MutableRef } from "preact/hooks";
import { useEffect, useMemo, useState } from "preact/hooks";
import { MutableRef, useEffect, useMemo, useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
import React from "react";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
@@ -32,10 +31,10 @@ export const StackedCard = ({
hovered,
cardArrangement,
}: StackedCardProps) => {
const isHidden = offset < 0;
const [delayedOffset, setDelayedOffset] = useState<number>(offset);
const [contentOpacity, setContentOpacity] = useState<number>(0);
const currentCardHeight = offset === 0 ? "auto" : offset < 0 ? "initial" : cardHeight;
const isHidden = offset < 0 || offset > 2;
const getBottomStyles = () => {
if (survey.type !== "link")
@@ -50,28 +49,26 @@ export const StackedCard = ({
const calculateCardTransform = useMemo(() => {
let rotationCoefficient = 3;
if (cardWidth >= 1000) {
rotationCoefficient = 1.5;
} else if (cardWidth > 650) {
rotationCoefficient = 2;
}
let rotationValue = ((hovered ? rotationCoefficient : rotationCoefficient - 0.5) * offset).toString();
let translateValue = ((hovered ? 12 : 10) * offset).toString();
return (offset: number) => {
switch (cardArrangement) {
case "casual":
return offset < 0
? `translateX(33%)`
: `translateX(0) rotate(-${((hovered ? rotationCoefficient : rotationCoefficient - 0.5) * offset).toString()}deg)`;
return offset < 0 ? `translateX(35%) scale(0.97)` : `translateX(0) rotate(-${rotationValue}deg)`;
case "straight":
return offset < 0
? `translateY(25%)`
: `translateY(-${((hovered ? 12 : 10) * offset).toString()}px)`;
return offset < 0 ? `translateY(35%) scale(0.97)` : `translateY(-${translateValue}px)`;
default:
return offset < 0 ? `translateX(0)` : `translateX(0)`;
return `translateX(0)`;
}
};
}, [cardArrangement, hovered, cardWidth]);
}, [cardArrangement, hovered, cardWidth, offset]);
const straightCardArrangementStyles =
cardArrangement === "straight"
@@ -105,7 +102,9 @@ export const StackedCard = ({
transform: calculateCardTransform(offset),
opacity: isHidden ? 0 : (100 - 20 * offset) / 100,
height: fullSizeCards ? "100%" : currentCardHeight,
transitionDuration: "600ms",
transitionProperty: "transform, opacity, margin, width",
transitionDuration: "500ms",
transitionBehavior: "ease-in-out",
pointerEvents: offset === 0 ? "auto" : "none",
...borderStyles,
...straightCardArrangementStyles,

View File

@@ -232,7 +232,7 @@ describe("StackedCardsContainer", () => {
test("renders stacked arrangement correctly", () => {
render(<StackedCardsContainer {...defaultProps} cardArrangement="casual" />);
// q1 is index 0. currentQuestionIdx = 0. prev = -1, next = 1, next+1 = 2
expect(mockStackedCardFn).toHaveBeenCalledTimes(4); // prev, current, next, next+1
expect(mockStackedCardFn).toHaveBeenCalledTimes(5); // prev, current, next, next+1, next+2
expect(screen.getByTestId("stacked-card-0")).toBeInTheDocument(); // current
expect(screen.getByTestId("stacked-card-1")).toBeInTheDocument(); // next
// Check that getCardContent is called for the current card (offset 0) via the mock

View File

@@ -30,7 +30,7 @@ export function StackedCardsContainer({
setQuestionId,
shouldResetQuestionId = true,
fullSizeCards = false,
}: StackedCardsContainerProps) {
}: Readonly<StackedCardsContainerProps>) {
const [hovered, setHovered] = useState(false);
const highlightBorderColor = survey.styling?.overwriteThemeStyling
? survey.styling?.highlightBorderColor?.light
@@ -40,7 +40,7 @@ export function StackedCardsContainer({
: styling.cardBorderColor?.light;
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const resizeObserver = useRef<ResizeObserver | null>(null);
const [cardHeight, setCardHeight] = useState("auto");
const [cardHeight, setCardHeight] = useState("inital");
const [cardWidth, setCardWidth] = useState<number>(0);
const questionIdxTemp = useMemo(() => {
@@ -130,7 +130,7 @@ export function StackedCardsContainer({
return (
<div
data-testid="stacked-cards-container"
className="fb-relative fb-flex fb-h-full fb-items-end fb-justify-center md:fb-items-center"
className="fb-relative fb-flex fb-h-full fb-items-end fb-justify-center md:fb-items-start"
onMouseEnter={() => {
setHovered(true);
}}
@@ -148,7 +148,8 @@ export function StackedCardsContainer({
</div>
) : (
questionIdxTemp !== undefined &&
[prevQuestionIdx, currentQuestionIdx, nextQuestionIdx, nextQuestionIdx + 1].map(
[prevQuestionIdx, currentQuestionIdx, nextQuestionIdx, nextQuestionIdx + 1, nextQuestionIdx + 2].map(
// [prevQuestionIdx, currentQuestionIdx, nextQuestionIdx, nextQuestionIdx + 1].map(
(dynamicQuestionIndex, index) => {
const hasEndingCard = survey.endings.length > 0;
// Check for hiding extra card

View File

@@ -10,6 +10,7 @@ interface SurveyContainerProps {
onClose?: () => void;
clickOutside?: boolean;
isOpen?: boolean;
style?: React.CSSProperties;
}
export function SurveyContainer({
@@ -20,7 +21,8 @@ export function SurveyContainer({
onClose,
clickOutside,
isOpen = true,
}: SurveyContainerProps) {
style,
}: Readonly<SurveyContainerProps>) {
const [show, setShow] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
@@ -73,7 +75,7 @@ export function SurveyContainer({
if (!isModal) {
return (
<div id="fbjs" className="fb-formbricks-form" style={{ height: "100%", width: "100%" }}>
<div id="fbjs" className="fb-formbricks-form" style={{ ...style, height: "100%", width: "100%" }}>
{children}
</div>
);