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

View File

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

View File

@@ -105,7 +105,10 @@ export const env = createEnv({
PROMETHEUS_EXPORTER_PORT: z.string().optional(), PROMETHEUS_EXPORTER_PORT: z.string().optional(),
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(), PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).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", "continue_with_saml": "Login mit SAML SSO",
"email-change": { "email-change": {
"confirm_password_description": "Bitte bestätige dein Passwort, bevor du deine E-Mail-Adresse änderst", "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": "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_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", "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.", "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.", "invalid_file_type": "Ungültiger Dateityp. Nur JPEG-, PNG- und WEBP-Dateien sind erlaubt.",
"lost_access": "Zugriff verloren", "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:", "or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
"organization_identification": "Hilf deiner Organisation, Dich auf Formbricks zu identifizieren", "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>", "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", "continue_with_saml": "Continue with SAML SSO",
"email-change": { "email-change": {
"confirm_password_description": "Please confirm your password before changing your email address", "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": "Email changed successfully",
"email_change_success_description": "You have successfully changed your email address. Please log in with your new email address.", "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", "email_verification_failed": "Email verification failed",
@@ -1158,7 +1157,6 @@
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.", "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.", "invalid_file_type": "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.",
"lost_access": "Lost access", "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:", "or_enter_the_following_code_manually": "Or enter the following code manually:",
"organization_identification": "Assist your organization in identifying you on Formbricks", "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>", "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", "continue_with_saml": "Continuer avec SAML SSO",
"email-change": { "email-change": {
"confirm_password_description": "Veuillez confirmer votre mot de passe avant de changer votre adresse e-mail", "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": "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_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", "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.", "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.", "invalid_file_type": "Type de fichier invalide. Seuls les fichiers JPEG, PNG et WEBP sont autorisés.",
"lost_access": "Accès perdu", "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 :", "or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
"organization_identification": "Aidez votre organisation à vous identifier sur Formbricks", "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>", "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", "continue_with_saml": "Continuar com SAML SSO",
"email-change": { "email-change": {
"confirm_password_description": "Por favor, confirme sua senha antes de mudar seu endereço de e-mail", "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": "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_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", "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.", "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.", "invalid_file_type": "Tipo de arquivo inválido. Só são permitidos arquivos JPEG, PNG e WEBP.",
"lost_access": "Perdi o acesso", "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:", "or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organization_identification": "Ajude sua organização a te identificar no Formbricks", "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>", "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", "continue_with_saml": "Continuar com SAML SSO",
"email-change": { "email-change": {
"confirm_password_description": "Por favor, confirme a sua palavra-passe antes de alterar o seu endereço de email", "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": "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_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", "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.", "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.", "invalid_file_type": "Tipo de ficheiro inválido. Apenas são permitidos ficheiros JPEG, PNG e WEBP.",
"lost_access": "Perdeu o acesso", "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:", "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", "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>", "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 繼續", "continue_with_saml": "使用 SAML SSO 繼續",
"email-change": { "email-change": {
"confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼", "confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼",
"email_already_exists": "此電子郵件地址已被使用",
"email_change_success": "電子郵件已成功更改", "email_change_success": "電子郵件已成功更改",
"email_change_success_description": "您已成功更改電子郵件地址。請使用您的新電子郵件地址登入。", "email_change_success_description": "您已成功更改電子郵件地址。請使用您的新電子郵件地址登入。",
"email_verification_failed": "電子郵件驗證失敗", "email_verification_failed": "電子郵件驗證失敗",
@@ -1158,7 +1157,6 @@
"file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。", "file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。",
"invalid_file_type": "無效的檔案類型。僅允許 JPEG、PNG 和 WEBP 檔案。", "invalid_file_type": "無效的檔案類型。僅允許 JPEG、PNG 和 WEBP 檔案。",
"lost_access": "無法存取", "lost_access": "無法存取",
"new_email_update_success": "您的 email 更改請求已收到。",
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:", "or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
"organization_identification": "協助您的組織在 Formbricks 上識別您", "organization_identification": "協助您的組織在 Formbricks 上識別您",
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>", "organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",

View File

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

View File

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

View File

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

View File

@@ -78,7 +78,7 @@ export const LinkSurveyWrapper = ({
surveyType={surveyType} surveyType={surveyType}
styling={styling} styling={styling}
onBackgroundLoaded={handleBackgroundLoaded}> 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} />} {!styling.isLogoHidden && project.logo?.url && <ClientLogo projectLogo={project.logo} />}
<div className="h-full w-full max-w-4xl space-y-6 px-1.5"> <div className="h-full w-full max-w-4xl space-y-6 px-1.5">
{isPreview && ( {isPreview && (

View File

@@ -166,6 +166,7 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
return ( return (
<div <div
ref={ContentRef} ref={ContentRef}
id="mobile-preview"
className={`relative h-[90%] max-h-[42rem] w-[22rem] overflow-hidden rounded-[3rem] border-[6px] border-slate-400 ${getFilterStyle()}`}> 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 */} {/* 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> <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) { } else if (isEditorView) {
return ( 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"> <div className="relative flex w-full flex-grow flex-col items-center justify-center p-4 py-6">
{renderBackground()} {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>
</div> </div>
); );

View File

@@ -289,7 +289,7 @@ export const PreviewSurvey = ({
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey /> <ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
)} )}
</div> </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 <SurveyInline
isPreviewMode={true} isPreviewMode={true}
survey={{ ...survey, type: "link" }} 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) page.locator("#questionCard-3").getByText(surveys.createAndSubmit.ratingQuestion.highLabel)
).toBeVisible(); ).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5); 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 expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
await page.locator("path").nth(3).click(); await page.locator("path").nth(3).click();
@@ -115,7 +115,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect( await expect(
page.locator("#questionCard-4").getByText(surveys.createAndSubmit.npsQuestion.highLabel) page.locator("#questionCard-4").getByText(surveys.createAndSubmit.npsQuestion.highLabel)
).toBeVisible(); ).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(); await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
for (let i = 0; i < 11; i++) { 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.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: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-6").getByRole("button", { name: "Back" })).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(); await page.locator("#questionCard-6").getByRole("button", { name: "Next" }).click();
// Picture Select Question // Picture Select Question
@@ -760,7 +760,7 @@ test.describe("Testing Survey with advanced logic", async () => {
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.highLabel) page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.highLabel)
).toBeVisible(); ).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5); 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 expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("group", { name: "Choices" }).locator("path").nth(3).click(); 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( await expect(
page.locator("#questionCard-5").getByText(surveys.createWithLogicAndSubmit.npsQuestion.highLabel) page.locator("#questionCard-5").getByText(surveys.createWithLogicAndSubmit.npsQuestion.highLabel)
).toBeVisible(); ).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(); await expect(page.locator("#questionCard-5").getByRole("button", { name: "Back" })).toBeVisible();
for (let i = 0; i < 11; i++) { for (let i = 0; i < 11; i++) {
@@ -831,7 +831,7 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible(); ).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible(); await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).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(); await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
// File Upload Question // 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 1").fill(params.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]); await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click(); await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
await page.getByLabel("Required").click();
// Multi Select Question // Multi Select Question
await page await page
@@ -463,8 +462,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
}, },
]); ]);
await page.getByLabel("Required").click();
// Rating Question // Rating Question
await page await page
.locator("div") .locator("div")
@@ -510,7 +507,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.getByRole("button", { name: "Add option" }).click(); await page.getByRole("button", { name: "Add option" }).click();
await page.getByPlaceholder("Option 5").click(); await page.getByPlaceholder("Option 5").click();
await page.getByPlaceholder("Option 5").fill(params.ranking.choices[4]); await page.getByPlaceholder("Option 5").fill(params.ranking.choices[4]);
await page.getByLabel("Required").click();
// Matrix Question // Matrix Question
await page 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.getByRole("button", { name: "Statement (Call to Action)" }).click();
await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question); await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question);
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel); await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
await page.getByLabel("Required").click();
// Consent Question // Consent Question
await page await page
@@ -578,7 +573,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.click(); .click();
await page.getByRole("button", { name: "Date" }).click(); await page.getByRole("button", { name: "Date" }).click();
await page.getByLabel("Question*").fill(params.date.question); await page.getByLabel("Question*").fill(params.date.question);
await page.getByLabel("Required").click();
// Cal Question // Cal Question
await page await page
@@ -588,7 +582,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.click(); .click();
await page.getByRole("button", { name: "Schedule a meeting" }).click(); await page.getByRole("button", { name: "Schedule a meeting" }).click();
await page.getByLabel("Question*").fill(params.cal.question); await page.getByLabel("Question*").fill(params.cal.question);
await page.getByLabel("Required").click();
// Fill Address Question // Fill Address Question
await page await page
@@ -633,8 +626,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.getByRole("option", { name: "secret" }).click(); await page.getByRole("option", { name: "secret" }).click();
await page.locator("#action-2-operator").click(); await page.locator("#action-2-operator").click();
await page.getByRole("option", { name: "Assign =" }).click(); await page.getByRole("option", { name: "Assign =" }).click();
await page.getByRole("textbox", { name: "Value" }).click(); await page.locator("#action-2-value-input").click();
await page.getByRole("textbox", { name: "Value" }).fill("This "); await page.locator("#action-2-value-input").fill("1");
// Single Select Question // Single Select Question
await page.getByRole("heading", { name: params.singleSelectQuestion.question }).click(); 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); 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 { } catch {
logger.debug("Error during sync. Please try again."); logger.debug("Error during sync. Please try again.");
} }
@@ -314,9 +316,11 @@ export const setup = async (
environment: environmentState, environment: environmentState,
filteredSurveys, filteredSurveys,
}); });
const surveyNames = filteredSurveys.map((s) => s.name); 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) { } catch (e) {
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string }); 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 // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want to run this effect when isCurrent changes
}, [isCurrent]); }, [isCurrent]);
const marginPreservingHeight = survey.type === "app" ? "fb-my-[37px]" : "";
return ( return (
<ScrollableContainer> <ScrollableContainer className={marginPreservingHeight}>
<div className="fb-text-center"> <div className="fb-text-center">
{isResponseSendingFinished ? ( {isResponseSendingFinished ? (
<> <>
@@ -136,7 +138,7 @@ export function EndingCard({
questionId="EndingCard" questionId="EndingCard"
/> />
{endingCard.buttonLabel ? ( {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 <SubmitButton
buttonLabel={replaceRecallInfo( buttonLabel={replaceRecallInfo(
getLocalizedValue(endingCard.buttonLabel, languageCode), getLocalizedValue(endingCard.buttonLabel, languageCode),

View File

@@ -342,7 +342,7 @@ export function FileInput({
{showUploader ? ( {showUploader ? (
<button <button
type="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" aria-label="Upload files by clicking or dragging them here"
onClick={() => document.getElementById(uniqueHtmlFor)?.click()}> onClick={() => document.getElementById(uniqueHtmlFor)?.click()}>
<svg <svg

View File

@@ -1,3 +1,4 @@
import { cn } from "@/lib/utils";
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload"; import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
@@ -26,7 +27,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
return ( 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 ? ( {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" /> <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} ) : null}
@@ -35,7 +36,10 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
key={imgUrl} key={imgUrl}
src={imgUrl} src={imgUrl}
alt={altText} 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={() => { onLoad={() => {
setIsLoading(false); setIsLoading(false);
}} }}
@@ -48,7 +52,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
src={videoUrlWithParams} src={videoUrlWithParams}
title="Question Video" title="Question Video"
frameBorder="0" 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={() => { onLoad={() => {
setIsLoading(false); setIsLoading(false);
}} }}

View File

@@ -1,6 +1,8 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/preact"; 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"; import { RenderSurvey } from "./render-survey";
// Stub SurveyContainer to render children and capture props // 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", () => { describe("RenderSurvey", () => {
beforeEach(() => { beforeEach(() => {
surveySpy.mockClear(); surveySpy.mockClear();
vi.useFakeTimers(); 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(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it("renders with default props and handles close", () => { test("renders with default props and handles close", () => {
const onClose = vi.fn(); const onClose = vi.fn();
const onFinished = vi.fn(); const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any; const survey = { endings: [{ id: "e1", type: "question" }] } as any;
@@ -63,7 +81,7 @@ describe("RenderSurvey", () => {
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
it("onFinished skips close if redirectToUrl", () => { test("onFinished skips close if redirectToUrl", () => {
const onClose = vi.fn(); const onClose = vi.fn();
const onFinished = vi.fn(); const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "redirectToUrl" }] } as any; const survey = { endings: [{ id: "e1", type: "redirectToUrl" }] } as any;
@@ -88,7 +106,7 @@ describe("RenderSurvey", () => {
expect(onClose).not.toHaveBeenCalled(); 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 onClose = vi.fn();
const onFinished = vi.fn(); const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any; const survey = { endings: [{ id: "e1", type: "question" }] } as any;
@@ -115,7 +133,7 @@ describe("RenderSurvey", () => {
expect(onClose).toHaveBeenCalled(); 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 onClose = vi.fn();
const onFinished = vi.fn(); const onFinished = vi.fn();
const survey = { endings: [] } as any; const survey = { endings: [] } as any;
@@ -139,4 +157,49 @@ describe("RenderSurvey", () => {
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);
expect(onClose).not.toHaveBeenCalled(); 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 { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { SurveyContainer } from "../wrappers/survey-container"; import { SurveyContainer } from "../wrappers/survey-container";
import { Survey } from "./survey"; import { Survey } from "./survey";
export function RenderSurvey(props: SurveyContainerProps) { export function RenderSurvey(props: Readonly<SurveyContainerProps>) {
const [isOpen, setIsOpen] = useState(true); 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 = () => { const close = () => {
setIsOpen(false); setIsOpen(false);
setTimeout(() => { setTimeout(() => {

View File

@@ -139,7 +139,7 @@ export function WelcomeCard({
{fileUrl ? ( {fileUrl ? (
<img <img
src={fileUrl} 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" alt="Company Logo"
/> />
) : null} ) : null}
@@ -156,9 +156,36 @@ export function WelcomeCard({
htmlString={replaceRecallInfo(getLocalizedValue(html, languageCode), responseData, variablesData)} htmlString={replaceRecallInfo(getLocalizedValue(html, languageCode), responseData, variablesData)}
questionId="welcomeCard" 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> </div>
</ScrollableContainer> </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 <SubmitButton
buttonLabel={getLocalizedValue(buttonLabel, languageCode)} buttonLabel={getLocalizedValue(buttonLabel, languageCode)}
isLastQuestion={false} isLastQuestion={false}
@@ -173,33 +200,6 @@ export function WelcomeCard({
}} }}
/> />
</div> </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> </div>
); );
} }

View File

@@ -43,7 +43,7 @@ export function AddressQuestion({
currentQuestionId, currentQuestionId,
autoFocusEnabled, autoFocusEnabled,
isBackButtonHidden, isBackButtonHidden,
}: AddressQuestionProps) { }: Readonly<AddressQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl; const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
@@ -136,7 +136,7 @@ export function AddressQuestion({
questionId={question.id} 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) => { {fields.map((field, index) => {
const isFieldRequired = () => { const isFieldRequired = () => {
if (field.required) { if (field.required) {

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ export function ContactInfoQuestion({
currentQuestionId, currentQuestionId,
autoFocusEnabled, autoFocusEnabled,
isBackButtonHidden, isBackButtonHidden,
}: ContactInfoQuestionProps) { }: Readonly<ContactInfoQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl; const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
@@ -131,7 +131,7 @@ export function ContactInfoQuestion({
questionId={question.id} 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) => { {fields.map((field, index) => {
const isFieldRequired = () => { const isFieldRequired = () => {
if (field.required) { if (field.required) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -167,19 +167,6 @@ describe("NPSQuestion", () => {
expect(getUpdatedTtc).toHaveBeenCalled(); 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", () => { test("supports keyboard navigation", () => {
render(<NPSQuestion {...mockProps} />); render(<NPSQuestion {...mockProps} />);

View File

@@ -40,7 +40,7 @@ export function NPSQuestion({
setTtc, setTtc,
currentQuestionId, currentQuestionId,
isBackButtonHidden, isBackButtonHidden,
}: NPSQuestionProps) { }: Readonly<NPSQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const [hoveredNumber, setHoveredNumber] = useState(-1); const [hoveredNumber, setHoveredNumber] = useState(-1);
const isMediaAvailable = question.imageUrl || question.videoUrl; const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -96,17 +96,15 @@ export function NPSQuestion({
<div className="fb-flex"> <div className="fb-flex">
{Array.from({ length: 11 }, (_, i) => i).map((number, idx) => { {Array.from({ length: 11 }, (_, i) => i).map((number, idx) => {
return ( return (
<label <button
type="button"
key={number} key={number}
tabIndex={isCurrent ? 0 : -1} tabIndex={isCurrent ? 0 : -1}
onMouseOver={() => { onMouseOver={() => setHoveredNumber(number)}
setHoveredNumber(number); onMouseLeave={() => setHoveredNumber(-1)}
}} onFocus={() => setHoveredNumber(number)}
onMouseLeave={() => { onBlur={() => setHoveredNumber(-1)}
setHoveredNumber(-1);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") { if (e.key === " ") {
e.preventDefault(); e.preventDefault();
document.getElementById(number.toString())?.click(); 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", "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 question.isColorCodingEnabled
? "fb-h-[46px] fb-leading-[3.5em]" ? "fb-h-[46px] fb-leading-[3.5em]"
: "fb-h fb-leading-10", : "fb-h-[41px] fb-leading-10",
hoveredNumber === number ? "fb-bg-accent-bg" : "" hoveredNumber === number ? "fb-bg-accent-bg" : ""
)}> )}>
{question.isColorCodingEnabled ? ( <label className="fb-w-full fb-h-full fb-flex fb-items-center fb-justify-center">
<div {question.isColorCodingEnabled ? (
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getNPSOptionColor(idx)}`} <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} {number}
<input </label>
type="radio" </button>
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>
); );
})} })}
</div> </div>

View File

@@ -122,7 +122,7 @@ describe("OpenTextQuestion", () => {
test("renders textarea for long answers", () => { test("renders textarea for long answers", () => {
render(<OpenTextQuestion {...defaultProps} question={{ ...defaultQuestion, longAnswer: true }} />); 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", () => { test("displays character limit when configured", () => {

View File

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

View File

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

View File

@@ -41,8 +41,15 @@ export function PictureSelectionQuestion({
setTtc, setTtc,
currentQuestionId, currentQuestionId,
isBackButtonHidden, isBackButtonHidden,
}: PictureSelectionProps) { }: Readonly<PictureSelectionProps>) {
const [startTime, setStartTime] = useState(performance.now()); 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 isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId; const isCurrent = question.id === currentQuestionId;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
@@ -115,35 +122,72 @@ export function PictureSelectionQuestion({
<div className="fb-mt-4"> <div className="fb-mt-4">
<fieldset> <fieldset>
<legend className="fb-sr-only">Options</legend> <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) => ( {questionChoices.map((choice) => (
<label <div className="fb-relative" key={choice.id}>
key={choice.id} <button
tabIndex={isCurrent ? 0 : -1} type="button"
htmlFor={choice.id} tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => { onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input // Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") { if (e.key === " ") {
e.preventDefault(); e.preventDefault();
document.getElementById(choice.id)?.click(); document.getElementById(choice.id)?.click();
document.getElementById(choice.id)?.focus(); document.getElementById(choice.id)?.focus();
} }
}} }}
onClick={() => { onClick={() => {
handleChange(choice.id); handleChange(choice.id);
}} }}
className={cn( 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", "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) Array.isArray(value) && value.includes(choice.id)
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm" ? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
: "" : ""
)}> )}>
<img {loadingImages[choice.id] && (
src={choice.imageUrl} <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" />
id={choice.id} )}
alt={getOriginalFileNameFromUrl(choice.imageUrl)} <img
className="fb-h-full fb-w-full fb-object-cover" 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 <a
tabIndex={-1} tabIndex={-1}
href={choice.imageUrl} href={choice.imageUrl}
@@ -153,52 +197,25 @@ export function PictureSelectionQuestion({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="24"
height="16" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1" strokeWidth="1"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="lucide lucide-expand"> className="lucide lucide-image-down-icon lucide-image-down">
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" /> <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="M3 16.2V21m0 0h4.8M3 21l6-6" /> <path d="m14 19 3 3v-5.5" />
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" /> <path d="m17 22 3-3" />
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" /> <circle cx="9" cy="9" r="2" />
</svg> </svg>
</a> </a>
{question.allowMulti ? ( </div>
<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> </fieldset>

View File

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

View File

@@ -56,6 +56,11 @@ export function AutoCloseWrapper({
}; };
useEffect(() => { useEffect(() => {
if (survey.autoClose) {
// Reset interaction state when auto-close is enabled
setHasInteracted(false);
setCountDownActive(true);
}
startCountdown(); startCountdown();
return stopCountdown; return stopCountdown;
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to run this effect on every render // 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", () => { test("cleans up scroll event listener on unmount", () => {
const { unmount, container } = render( const { unmount, container } = render(
<ScrollableContainer> <ScrollableContainer>
@@ -213,4 +191,52 @@ describe("ScrollableContainer", () => {
); );
}).not.toThrow(); }).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"; import type { JSX } from "react";
interface ScrollableContainerProps { interface ScrollableContainerProps {
className?: string;
children: JSX.Element; children: JSX.Element;
} }
export function ScrollableContainer({ children }: ScrollableContainerProps) { export function ScrollableContainer({ className, children }: Readonly<ScrollableContainerProps>) {
const [isAtBottom, setIsAtBottom] = useState(false); const [isAtBottom, setIsAtBottom] = useState(false);
const [isAtTop, setIsAtTop] = useState(false); const [isAtTop, setIsAtTop] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const isSurveyPreview = Boolean(document.getElementById("survey-preview")); const isSurveyPreview = Boolean(document.getElementById("survey-preview"));
const isMobilePreview = isSurveyPreview ? Boolean(document.getElementById("mobile-preview")) : false;
const previewScaleCoifficient = isSurveyPreview ? 0.66 : 1;
const checkScroll = () => { const checkScroll = () => {
if (!containerRef.current) return; if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current; const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
setIsAtBottom(Math.round(scrollTop) + clientHeight >= scrollHeight); setIsAtBottom(Math.round(scrollTop) + clientHeight >= scrollHeight);
setIsAtTop(scrollTop === 0); 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 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 <div
id="scrollable-container"
ref={containerRef} ref={containerRef}
style={{ style={{
scrollbarGutter: "stable both-edges", 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} {children}
</div> </div>
{!isAtBottom && ( {!isAtBottom && (

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ interface SurveyContainerProps {
onClose?: () => void; onClose?: () => void;
clickOutside?: boolean; clickOutside?: boolean;
isOpen?: boolean; isOpen?: boolean;
style?: React.CSSProperties;
} }
export function SurveyContainer({ export function SurveyContainer({
@@ -20,7 +21,8 @@ export function SurveyContainer({
onClose, onClose,
clickOutside, clickOutside,
isOpen = true, isOpen = true,
}: SurveyContainerProps) { style,
}: Readonly<SurveyContainerProps>) {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
@@ -73,7 +75,7 @@ export function SurveyContainer({
if (!isModal) { if (!isModal) {
return ( 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} {children}
</div> </div>
); );