mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-24 15:10:36 -06:00
Compare commits
47 Commits
feautre/ai
...
survey-hei
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8d302dffe | ||
|
|
e9c45daf71 | ||
|
|
259f219eea | ||
|
|
828ce86764 | ||
|
|
f4fe8674fd | ||
|
|
25e4575172 | ||
|
|
acb6db4939 | ||
|
|
19221a65d7 | ||
|
|
a65cd855dc | ||
|
|
dabd5d361f | ||
|
|
dc47586813 | ||
|
|
be1a6f01c3 | ||
|
|
8dbad6f5ef | ||
|
|
f75e5bc7b4 | ||
|
|
9930f9af03 | ||
|
|
3ff20d04e0 | ||
|
|
8bac86638c | ||
|
|
100b15ac88 | ||
|
|
ddb1de95c4 | ||
|
|
913a6b5135 | ||
|
|
8b56786be5 | ||
|
|
a8a8cf6c88 | ||
|
|
dc0cc5e526 | ||
|
|
00e0307c81 | ||
|
|
84d4c59532 | ||
|
|
fba455c47f | ||
|
|
fd777ca227 | ||
|
|
512e9fb0a7 | ||
|
|
d9be37a134 | ||
|
|
187e509b41 | ||
|
|
9499e6265b | ||
|
|
3564faa638 | ||
|
|
5356ce4ed2 | ||
|
|
03ddf3d09a | ||
|
|
31496ee092 | ||
|
|
0f2b5e1709 | ||
|
|
b5a0b165ed | ||
|
|
25f8b2d07f | ||
|
|
0b88e58dcb | ||
|
|
89985d4f4f | ||
|
|
39e5518f2c | ||
|
|
1954c5ca61 | ||
|
|
b7679aa336 | ||
|
|
9e7c1d5245 | ||
|
|
4f9f064e76 | ||
|
|
6a9833dbeb | ||
|
|
94070774ad |
@@ -41,7 +41,7 @@ describe("Survey Builder", () => {
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
shuffleOption: "none",
|
||||
required: true,
|
||||
required: false,
|
||||
});
|
||||
expect(question.choices.length).toBe(3);
|
||||
expect(question.id).toBeDefined();
|
||||
@@ -141,7 +141,7 @@ describe("Survey Builder", () => {
|
||||
inputType: "text",
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: true,
|
||||
required: false,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -204,7 +204,7 @@ describe("Survey Builder", () => {
|
||||
range: 5,
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: true,
|
||||
required: false,
|
||||
isColorCodingEnabled: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
@@ -265,7 +265,7 @@ describe("Survey Builder", () => {
|
||||
headline: { default: "NPS Question" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: true,
|
||||
required: false,
|
||||
isColorCodingEnabled: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
@@ -324,7 +324,7 @@ describe("Survey Builder", () => {
|
||||
label: { default: "I agree to terms" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: true,
|
||||
required: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
});
|
||||
@@ -377,7 +377,7 @@ describe("Survey Builder", () => {
|
||||
headline: { default: "CTA Question" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: true,
|
||||
required: false,
|
||||
buttonExternal: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
|
||||
@@ -66,7 +66,7 @@ export const buildMultipleChoiceQuestion = ({
|
||||
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
|
||||
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
|
||||
shuffleOption: shuffleOption || "none",
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
logic,
|
||||
};
|
||||
};
|
||||
@@ -105,7 +105,7 @@ export const buildOpenTextQuestion = ({
|
||||
headline: { default: headline },
|
||||
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
|
||||
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
longAnswer,
|
||||
logic,
|
||||
charLimit: {
|
||||
@@ -153,7 +153,7 @@ export const buildRatingQuestion = ({
|
||||
range,
|
||||
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
|
||||
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? { default: lowerLabel } : undefined,
|
||||
upperLabel: upperLabel ? { default: upperLabel } : undefined,
|
||||
@@ -194,7 +194,7 @@ export const buildNPSQuestion = ({
|
||||
headline: { default: headline },
|
||||
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
|
||||
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? { default: lowerLabel } : undefined,
|
||||
upperLabel: upperLabel ? { default: upperLabel } : undefined,
|
||||
@@ -230,7 +230,7 @@ export const buildConsentQuestion = ({
|
||||
headline: { default: headline },
|
||||
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
|
||||
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
label: { default: label },
|
||||
logic,
|
||||
};
|
||||
@@ -269,7 +269,7 @@ export const buildCTAQuestion = ({
|
||||
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
|
||||
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
|
||||
dismissButtonLabel: dismissButtonLabel ? { default: dismissButtonLabel } : undefined,
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
buttonExternal,
|
||||
buttonUrl,
|
||||
logic,
|
||||
|
||||
@@ -105,7 +105,10 @@ export const env = createEnv({
|
||||
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
|
||||
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
|
||||
SESSION_MAX_AGE: z.string().transform((val) => parseInt(val)).optional(),
|
||||
SESSION_MAX_AGE: z
|
||||
.string()
|
||||
.transform((val) => parseInt(val))
|
||||
.optional(),
|
||||
},
|
||||
|
||||
/*
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"continue_with_saml": "Login mit SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Bitte bestätige dein Passwort, bevor du deine E-Mail-Adresse änderst",
|
||||
"email_already_exists": "Diese E-Mail wird bereits verwendet",
|
||||
"email_change_success": "E-Mail erfolgreich geändert",
|
||||
"email_change_success_description": "Du hast deine E-Mail-Adresse erfolgreich geändert. Bitte logge dich mit deiner neuen E-Mail-Adresse ein.",
|
||||
"email_verification_failed": "E-Mail-Bestätigung fehlgeschlagen",
|
||||
@@ -1158,7 +1157,6 @@
|
||||
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
|
||||
"invalid_file_type": "Ungültiger Dateityp. Nur JPEG-, PNG- und WEBP-Dateien sind erlaubt.",
|
||||
"lost_access": "Zugriff verloren",
|
||||
"new_email_update_success": "Deine Anfrage zur Änderung der E-Mail wurde erhalten.",
|
||||
"or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
|
||||
"organization_identification": "Hilf deiner Organisation, Dich auf Formbricks zu identifizieren",
|
||||
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"continue_with_saml": "Continue with SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Please confirm your password before changing your email address",
|
||||
"email_already_exists": "This email is already in use",
|
||||
"email_change_success": "Email changed successfully",
|
||||
"email_change_success_description": "You have successfully changed your email address. Please log in with your new email address.",
|
||||
"email_verification_failed": "Email verification failed",
|
||||
@@ -1158,7 +1157,6 @@
|
||||
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
|
||||
"invalid_file_type": "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.",
|
||||
"lost_access": "Lost access",
|
||||
"new_email_update_success": "Your email change request was received.",
|
||||
"or_enter_the_following_code_manually": "Or enter the following code manually:",
|
||||
"organization_identification": "Assist your organization in identifying you on Formbricks",
|
||||
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"continue_with_saml": "Continuer avec SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Veuillez confirmer votre mot de passe avant de changer votre adresse e-mail",
|
||||
"email_already_exists": "Cet e-mail est déjà utilisé",
|
||||
"email_change_success": "E-mail changé avec succès",
|
||||
"email_change_success_description": "Vous avez changé votre adresse e-mail avec succès. Veuillez vous connecter avec votre nouvelle adresse e-mail.",
|
||||
"email_verification_failed": "Échec de la vérification de l'email",
|
||||
@@ -1158,7 +1157,6 @@
|
||||
"file_size_must_be_less_than_10mb": "La taille du fichier doit être inférieure à 10 Mo.",
|
||||
"invalid_file_type": "Type de fichier invalide. Seuls les fichiers JPEG, PNG et WEBP sont autorisés.",
|
||||
"lost_access": "Accès perdu",
|
||||
"new_email_update_success": "Votre demande de changement d'email a été reçue.",
|
||||
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
|
||||
"organization_identification": "Aidez votre organisation à vous identifier sur Formbricks",
|
||||
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"continue_with_saml": "Continuar com SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Por favor, confirme sua senha antes de mudar seu endereço de e-mail",
|
||||
"email_already_exists": "Este e-mail já está em uso",
|
||||
"email_change_success": "E-mail alterado com sucesso",
|
||||
"email_change_success_description": "Você alterou seu endereço de e-mail com sucesso. Por favor, faça login com seu novo endereço de e-mail.",
|
||||
"email_verification_failed": "Falha na verificação do e-mail",
|
||||
@@ -1158,7 +1157,6 @@
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
|
||||
"invalid_file_type": "Tipo de arquivo inválido. Só são permitidos arquivos JPEG, PNG e WEBP.",
|
||||
"lost_access": "Perdi o acesso",
|
||||
"new_email_update_success": "Sua solicitação de alteração de e-mail foi recebida.",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organization_identification": "Ajude sua organização a te identificar no Formbricks",
|
||||
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"continue_with_saml": "Continuar com SAML SSO",
|
||||
"email-change": {
|
||||
"confirm_password_description": "Por favor, confirme a sua palavra-passe antes de alterar o seu endereço de email",
|
||||
"email_already_exists": "Este email já está a ser utilizado",
|
||||
"email_change_success": "Email alterado com sucesso",
|
||||
"email_change_success_description": "Alterou com sucesso o seu endereço de email. Por favor, inicie sessão com o seu novo endereço de email.",
|
||||
"email_verification_failed": "Falha na verificação do email",
|
||||
@@ -1158,7 +1157,6 @@
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do ficheiro deve ser inferior a 10MB.",
|
||||
"invalid_file_type": "Tipo de ficheiro inválido. Apenas são permitidos ficheiros JPEG, PNG e WEBP.",
|
||||
"lost_access": "Perdeu o acesso",
|
||||
"new_email_update_success": "O seu pedido de alteração de email foi recebido.",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organization_identification": "Ajude a sua organização a identificá-lo no Formbricks",
|
||||
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"continue_with_saml": "使用 SAML SSO 繼續",
|
||||
"email-change": {
|
||||
"confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼",
|
||||
"email_already_exists": "此電子郵件地址已被使用",
|
||||
"email_change_success": "電子郵件已成功更改",
|
||||
"email_change_success_description": "您已成功更改電子郵件地址。請使用您的新電子郵件地址登入。",
|
||||
"email_verification_failed": "電子郵件驗證失敗",
|
||||
@@ -1158,7 +1157,6 @@
|
||||
"file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。",
|
||||
"invalid_file_type": "無效的檔案類型。僅允許 JPEG、PNG 和 WEBP 檔案。",
|
||||
"lost_access": "無法存取",
|
||||
"new_email_update_success": "您的 email 更改請求已收到。",
|
||||
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
|
||||
"organization_identification": "協助您的組織在 Formbricks 上識別您",
|
||||
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
|
||||
|
||||
@@ -34,7 +34,7 @@ export const EditPublicSurveyAlertDialog = ({
|
||||
label: secondaryButtonText,
|
||||
onClick: secondaryButtonAction,
|
||||
disabled: isLoading,
|
||||
variant: "outline",
|
||||
variant: "secondary",
|
||||
});
|
||||
}
|
||||
if (primaryButtonAction) {
|
||||
|
||||
@@ -178,7 +178,7 @@ export const FileUploadQuestionForm = ({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<div className="mt-6 space-y-6">
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.allowMultipleFiles}
|
||||
onToggle={() => updateQuestion(questionIdx, { allowMultipleFiles: !question.allowMultipleFiles })}
|
||||
|
||||
@@ -341,7 +341,7 @@ export const getCXQuestionNameMap = (t: TFnType) =>
|
||||
) as Record<TSurveyQuestionTypeEnum, string>;
|
||||
|
||||
export const universalQuestionPresets = {
|
||||
required: true,
|
||||
required: false,
|
||||
};
|
||||
|
||||
export const getQuestionDefaults = (id: string, project: any, t: TFnType) => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export const LinkSurveyWrapper = ({
|
||||
surveyType={surveyType}
|
||||
styling={styling}
|
||||
onBackgroundLoaded={handleBackgroundLoaded}>
|
||||
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
|
||||
<div className="flex max-h-dvh min-h-dvh items-start justify-center overflow-clip pt-[16dvh]">
|
||||
{!styling.isLogoHidden && project.logo?.url && <ClientLogo projectLogo={project.logo} />}
|
||||
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
|
||||
{isPreview && (
|
||||
|
||||
@@ -166,6 +166,7 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={ContentRef}
|
||||
id="mobile-preview"
|
||||
className={`relative h-[90%] max-h-[42rem] w-[22rem] overflow-hidden rounded-[3rem] border-[6px] border-slate-400 ${getFilterStyle()}`}>
|
||||
{/* below element is use to create notch for the mobile device mockup */}
|
||||
<div className="absolute left-1/2 right-1/2 top-2 z-20 h-4 w-1/3 -translate-x-1/2 transform rounded-full bg-slate-400"></div>
|
||||
@@ -175,10 +176,10 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
|
||||
);
|
||||
} else if (isEditorView) {
|
||||
return (
|
||||
<div ref={ContentRef} className="overflow-hiddem flex flex-grow flex-col rounded-b-lg">
|
||||
<div ref={ContentRef} className="flex flex-grow flex-col overflow-hidden rounded-b-lg">
|
||||
<div className="relative flex w-full flex-grow flex-col items-center justify-center p-4 py-6">
|
||||
{renderBackground()}
|
||||
<div className="flex h-full w-full items-center justify-center">{children}</div>
|
||||
<div className="flex h-full w-full items-start justify-center pt-[10dvh]">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -289,7 +289,7 @@ export const PreviewSurvey = ({
|
||||
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
|
||||
)}
|
||||
</div>
|
||||
<div className="z-10 w-full max-w-md rounded-lg border border-transparent">
|
||||
<div className="z-10 w-full rounded-lg border border-transparent">
|
||||
<SurveyInline
|
||||
isPreviewMode={true}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
|
||||
@@ -103,7 +103,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
page.locator("#questionCard-3").getByText(surveys.createAndSubmit.ratingQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).not.toBeVisible();
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.locator("path").nth(3).click();
|
||||
|
||||
@@ -115,7 +115,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await expect(
|
||||
page.locator("#questionCard-4").getByText(surveys.createAndSubmit.npsQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
@@ -135,7 +135,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await expect(page.getByText(surveys.createAndSubmit.consentQuestion.checkboxLabel)).toBeVisible();
|
||||
await expect(page.locator("#questionCard-6").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-6").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByText(surveys.createAndSubmit.consentQuestion.checkboxLabel).check();
|
||||
await page.getByLabel(surveys.createAndSubmit.consentQuestion.checkboxLabel).check();
|
||||
await page.locator("#questionCard-6").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Picture Select Question
|
||||
@@ -760,7 +760,7 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("group", { name: "Choices" }).locator("path").nth(3).click();
|
||||
|
||||
@@ -772,7 +772,7 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
await expect(
|
||||
page.locator("#questionCard-5").getByText(surveys.createWithLogicAndSubmit.npsQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Next" })).not.toBeVisible();
|
||||
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
@@ -831,7 +831,7 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByText(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
|
||||
await page.getByLabel(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
|
||||
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// File Upload Question
|
||||
|
||||
@@ -418,7 +418,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
||||
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Multi Select Question
|
||||
await page
|
||||
@@ -463,8 +462,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
},
|
||||
]);
|
||||
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Rating Question
|
||||
await page
|
||||
.locator("div")
|
||||
@@ -510,7 +507,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("button", { name: "Add option" }).click();
|
||||
await page.getByPlaceholder("Option 5").click();
|
||||
await page.getByPlaceholder("Option 5").fill(params.ranking.choices[4]);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Matrix Question
|
||||
await page
|
||||
@@ -549,7 +545,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("button", { name: "Statement (Call to Action)" }).click();
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question);
|
||||
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Consent Question
|
||||
await page
|
||||
@@ -578,7 +573,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Date" }).click();
|
||||
await page.getByLabel("Question*").fill(params.date.question);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Cal Question
|
||||
await page
|
||||
@@ -588,7 +582,6 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Schedule a meeting" }).click();
|
||||
await page.getByLabel("Question*").fill(params.cal.question);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Fill Address Question
|
||||
await page
|
||||
@@ -633,8 +626,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("option", { name: "secret" }).click();
|
||||
await page.locator("#action-2-operator").click();
|
||||
await page.getByRole("option", { name: "Assign =" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("This ");
|
||||
await page.locator("#action-2-value-input").click();
|
||||
await page.locator("#action-2-value-input").fill("1");
|
||||
|
||||
// Single Select Question
|
||||
await page.getByRole("heading", { name: params.singleSelectQuestion.question }).click();
|
||||
|
||||
@@ -258,7 +258,9 @@ export const setup = async (
|
||||
});
|
||||
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
logger.debug(`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`);
|
||||
logger.debug(
|
||||
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
|
||||
);
|
||||
} catch {
|
||||
logger.debug("Error during sync. Please try again.");
|
||||
}
|
||||
@@ -314,9 +316,11 @@ export const setup = async (
|
||||
environment: environmentState,
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
logger.debug(`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`);
|
||||
logger.debug(
|
||||
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
|
||||
);
|
||||
} catch (e) {
|
||||
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
|
||||
}
|
||||
|
||||
@@ -109,8 +109,10 @@ export function EndingCard({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we only want to run this effect when isCurrent changes
|
||||
}, [isCurrent]);
|
||||
|
||||
const marginPreservingHeight = survey.type === "app" ? "fb-my-[37px]" : "";
|
||||
|
||||
return (
|
||||
<ScrollableContainer>
|
||||
<ScrollableContainer className={marginPreservingHeight}>
|
||||
<div className="fb-text-center">
|
||||
{isResponseSendingFinished ? (
|
||||
<>
|
||||
@@ -136,7 +138,7 @@ export function EndingCard({
|
||||
questionId="EndingCard"
|
||||
/>
|
||||
{endingCard.buttonLabel ? (
|
||||
<div className="fb-mt-6 fb-flex fb-w-full fb-flex-col fb-items-center fb-justify-center fb-space-y-4">
|
||||
<div className="fb-mt-4 fb-flex fb-w-full fb-flex-col fb-items-center fb-justify-center fb-space-y-4">
|
||||
<SubmitButton
|
||||
buttonLabel={replaceRecallInfo(
|
||||
getLocalizedValue(endingCard.buttonLabel, languageCode),
|
||||
|
||||
@@ -342,7 +342,7 @@ export function FileInput({
|
||||
{showUploader ? (
|
||||
<button
|
||||
type="button"
|
||||
className="focus:fb-outline-brand fb-flex fb-flex-col fb-items-center fb-justify-center fb-py-6 hover:fb-cursor-pointer w-full"
|
||||
className="focus:fb-outline-brand fb-flex fb-flex-col fb-items-center fb-justify-center fb-py-10 hover:fb-cursor-pointer w-full"
|
||||
aria-label="Upload files by clicking or dragging them here"
|
||||
onClick={() => document.getElementById(uniqueHtmlFor)?.click()}>
|
||||
<svg
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
@@ -26,7 +27,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="fb-group/image fb-relative fb-mb-4 fb-block fb-min-h-40 fb-rounded-md">
|
||||
<div className="fb-group/image fb-relative fb-mb-6 fb-block fb-min-h-40 fb-rounded-md">
|
||||
{isLoading ? (
|
||||
<div className="fb-absolute fb-inset-auto fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
|
||||
) : null}
|
||||
@@ -35,7 +36,10 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
key={imgUrl}
|
||||
src={imgUrl}
|
||||
alt={altText}
|
||||
className="fb-rounded-custom"
|
||||
className={cn(
|
||||
"fb-rounded-custom fb-max-h-[40dvh] fb-mx-auto fb-object-contain",
|
||||
isLoading ? "fb-opacity-0" : ""
|
||||
)}
|
||||
onLoad={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
@@ -48,7 +52,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
src={videoUrlWithParams}
|
||||
title="Question Video"
|
||||
frameBorder="0"
|
||||
className="fb-rounded-custom fb-aspect-video fb-w-full"
|
||||
className={cn("fb-rounded-custom fb-aspect-video fb-w-full", isLoading ? "fb-opacity-0" : "")}
|
||||
onLoad={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render } from "@testing-library/preact";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
// Ensure screen is imported
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
// Use test consistently
|
||||
import { RenderSurvey } from "./render-survey";
|
||||
|
||||
// Stub SurveyContainer to render children and capture props
|
||||
@@ -21,17 +23,33 @@ vi.mock("./survey", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver
|
||||
let resizeCallback: Function | undefined;
|
||||
const ResizeObserverMock = vi.fn((callback) => {
|
||||
resizeCallback = callback;
|
||||
return {
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
});
|
||||
global.ResizeObserver = ResizeObserverMock as any;
|
||||
|
||||
describe("RenderSurvey", () => {
|
||||
beforeEach(() => {
|
||||
surveySpy.mockClear();
|
||||
vi.useFakeTimers();
|
||||
// Reset styles on documentElement before each test
|
||||
document.documentElement.style.removeProperty("--fb-survey-card-max-height");
|
||||
document.documentElement.style.removeProperty("--fb-survey-card-min-height");
|
||||
resizeCallback = undefined; // Reset callback for each test
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders with default props and handles close", () => {
|
||||
test("renders with default props and handles close", () => {
|
||||
const onClose = vi.fn();
|
||||
const onFinished = vi.fn();
|
||||
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
|
||||
@@ -63,7 +81,7 @@ describe("RenderSurvey", () => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onFinished skips close if redirectToUrl", () => {
|
||||
test("onFinished skips close if redirectToUrl", () => {
|
||||
const onClose = vi.fn();
|
||||
const onFinished = vi.fn();
|
||||
const survey = { endings: [{ id: "e1", type: "redirectToUrl" }] } as any;
|
||||
@@ -88,7 +106,7 @@ describe("RenderSurvey", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onFinished closes after delay for non-redirect endings", () => {
|
||||
test("onFinished closes after delay for non-redirect endings", () => {
|
||||
const onClose = vi.fn();
|
||||
const onFinished = vi.fn();
|
||||
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
|
||||
@@ -115,7 +133,7 @@ describe("RenderSurvey", () => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onFinished does not auto-close when inline mode", () => {
|
||||
test("onFinished does not auto-close when inline mode", () => {
|
||||
const onClose = vi.fn();
|
||||
const onFinished = vi.fn();
|
||||
const survey = { endings: [] } as any;
|
||||
@@ -139,4 +157,49 @@ describe("RenderSurvey", () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// New tests for surveyTypeStyles
|
||||
test("should apply correct styles for link surveys", () => {
|
||||
const propsForLinkSurvey = {
|
||||
survey: { type: "link", endings: [] },
|
||||
styling: {},
|
||||
isBrandingEnabled: false,
|
||||
languageCode: "en",
|
||||
onClose: vi.fn(),
|
||||
onFinished: vi.fn(),
|
||||
placement: "bottomRight",
|
||||
mode: "modal",
|
||||
} as any;
|
||||
|
||||
render(<RenderSurvey {...propsForLinkSurvey} />);
|
||||
// Manually trigger the ResizeObserver callback if it was captured
|
||||
if (resizeCallback) {
|
||||
resizeCallback();
|
||||
}
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-max-height")).toBe("56dvh");
|
||||
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-min-height")).toBe("0");
|
||||
});
|
||||
|
||||
test("should apply correct styles for app (non-link) surveys", () => {
|
||||
const propsForAppSurvey = {
|
||||
survey: { type: "app", endings: [] },
|
||||
styling: {},
|
||||
isBrandingEnabled: false,
|
||||
languageCode: "en",
|
||||
onClose: vi.fn(),
|
||||
onFinished: vi.fn(),
|
||||
placement: "bottomRight",
|
||||
mode: "modal",
|
||||
} as any;
|
||||
|
||||
render(<RenderSurvey {...propsForAppSurvey} />);
|
||||
// Manually trigger the ResizeObserver callback if it was captured
|
||||
if (resizeCallback) {
|
||||
resizeCallback();
|
||||
}
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-max-height")).toBe("40dvh");
|
||||
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-min-height")).toBe("40dvh");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
|
||||
import { SurveyContainer } from "../wrappers/survey-container";
|
||||
import { Survey } from "./survey";
|
||||
|
||||
export function RenderSurvey(props: SurveyContainerProps) {
|
||||
export function RenderSurvey(props: Readonly<SurveyContainerProps>) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// Check viewport width on mount and resize
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const isDesktop = window.innerWidth > 768;
|
||||
|
||||
if (props.survey.type === "link") {
|
||||
root.style.setProperty("--fb-survey-card-max-height", isDesktop ? "56dvh" : "60dvh");
|
||||
root.style.setProperty("--fb-survey-card-min-height", isDesktop ? "0" : "42dvh");
|
||||
} else {
|
||||
root.style.setProperty("--fb-survey-card-max-height", "40dvh");
|
||||
root.style.setProperty("--fb-survey-card-min-height", "40dvh");
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(document.body);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
root.style.removeProperty("--fb-survey-card-max-height");
|
||||
root.style.removeProperty("--fb-survey-card-min-height");
|
||||
};
|
||||
}, [props.survey.type]);
|
||||
|
||||
const close = () => {
|
||||
setIsOpen(false);
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -139,7 +139,7 @@ export function WelcomeCard({
|
||||
{fileUrl ? (
|
||||
<img
|
||||
src={fileUrl}
|
||||
className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-rounded-lg fb-object-contain"
|
||||
className="fb-mb-8 fb-max-h-80 fb-w-1/4 fb-rounded-lg fb-object-contain"
|
||||
alt="Company Logo"
|
||||
/>
|
||||
) : null}
|
||||
@@ -156,9 +156,36 @@ export function WelcomeCard({
|
||||
htmlString={replaceRecallInfo(getLocalizedValue(html, languageCode), responseData, variablesData)}
|
||||
questionId="welcomeCard"
|
||||
/>
|
||||
{timeToFinish && !showResponseCount ? (
|
||||
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
|
||||
<TimerIcon />
|
||||
<p className="fb-pt-1 fb-text-xs">
|
||||
<span> Takes {calculateTimeToComplete()} </span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{showResponseCount && !timeToFinish && responseCount && responseCount > 3 ? (
|
||||
<div className="fb-items-center fb-text-subheading fb-my-4 b-flex">
|
||||
<UsersIcon />
|
||||
<p className="fb-pt-1 fb-text-xs">
|
||||
<span>{`${responseCount.toString()} people responded`}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{timeToFinish && showResponseCount ? (
|
||||
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
|
||||
<TimerIcon />
|
||||
<p className="fb-pt-1 fb-text-xs">
|
||||
<span> Takes {calculateTimeToComplete()} </span>
|
||||
<span>
|
||||
{responseCount && responseCount > 3 ? `⋅ ${responseCount.toString()} people responded` : ""}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
<div className="fb-mx-6 fb-mt-4 fb-flex fb-gap-4 fb-py-4">
|
||||
<div className="fb-px-6 fb-py-4 fb-flex fb-gap-4">
|
||||
<SubmitButton
|
||||
buttonLabel={getLocalizedValue(buttonLabel, languageCode)}
|
||||
isLastQuestion={false}
|
||||
@@ -173,33 +200,6 @@ export function WelcomeCard({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{timeToFinish && !showResponseCount ? (
|
||||
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
|
||||
<TimerIcon />
|
||||
<p className="fb-pt-1 fb-text-xs">
|
||||
<span> Takes {calculateTimeToComplete()} </span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{showResponseCount && !timeToFinish && responseCount && responseCount > 3 ? (
|
||||
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
|
||||
<UsersIcon />
|
||||
<p className="fb-pt-1 fb-text-xs">
|
||||
<span>{`${responseCount.toString()} people responded`}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{timeToFinish && showResponseCount ? (
|
||||
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
|
||||
<TimerIcon />
|
||||
<p className="fb-pt-1 fb-text-xs">
|
||||
<span> Takes {calculateTimeToComplete()} </span>
|
||||
<span>
|
||||
{responseCount && responseCount > 3 ? `⋅ ${responseCount.toString()} people responded` : ""}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function AddressQuestion({
|
||||
currentQuestionId,
|
||||
autoFocusEnabled,
|
||||
isBackButtonHidden,
|
||||
}: AddressQuestionProps) {
|
||||
}: Readonly<AddressQuestionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
@@ -136,7 +136,7 @@ export function AddressQuestion({
|
||||
questionId={question.id}
|
||||
/>
|
||||
|
||||
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
|
||||
<div className="fb-mt-4 fb-w-full fb-grid fb-grid-cols-1 fb-gap-3">
|
||||
{fields.map((field, index) => {
|
||||
const isFieldRequired = () => {
|
||||
if (field.required) {
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CalQuestion({
|
||||
setTtc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: CalQuestionProps) {
|
||||
}: Readonly<CalQuestionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ConsentQuestion({
|
||||
currentQuestionId,
|
||||
autoFocusEnabled,
|
||||
isBackButtonHidden,
|
||||
}: ConsentQuestionProps) {
|
||||
}: Readonly<ConsentQuestionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
@@ -48,8 +48,7 @@ export function ConsentQuestion({
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
|
||||
const consentRef = useCallback(
|
||||
(currentElement: HTMLLabelElement | null) => {
|
||||
// will focus on current element when the question ID matches the current question
|
||||
(currentElement: HTMLButtonElement | null) => {
|
||||
if (question.id && currentElement && autoFocusEnabled && question.id === currentQuestionId) {
|
||||
currentElement.focus();
|
||||
}
|
||||
@@ -80,14 +79,14 @@ export function ConsentQuestion({
|
||||
htmlString={getLocalizedValue(question.html, languageCode) || ""}
|
||||
questionId={question.id}
|
||||
/>
|
||||
<div className="fb-bg-survey-bg fb-sticky -fb-bottom-2 fb-z-10 fb-w-full fb-px-1 fb-py-1">
|
||||
<label
|
||||
<div className="fb-bg-survey-bg fb-sticky -fb-bottom-2 fb-z-10 fb-w-full fb-py-2">
|
||||
<button
|
||||
type="button"
|
||||
ref={consentRef}
|
||||
dir="auto"
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
id={`${question.id}-label`}
|
||||
onKeyDown={(e) => {
|
||||
// Accessibility: if spacebar was pressed pass this down to the input
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById(question.id)?.click();
|
||||
@@ -95,28 +94,30 @@ export function ConsentQuestion({
|
||||
}
|
||||
}}
|
||||
className="fb-border-border fb-bg-input-bg fb-text-heading hover:fb-bg-input-bg-selected focus:fb-bg-input-bg-selected focus:fb-ring-brand fb-rounded-custom fb-relative fb-z-10 fb-my-2 fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-border fb-p-4 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
|
||||
<input
|
||||
tabIndex={-1}
|
||||
type="checkbox"
|
||||
id={question.id}
|
||||
name={question.id}
|
||||
value={getLocalizedValue(question.label, languageCode)}
|
||||
onChange={(e) => {
|
||||
if (e.target instanceof HTMLInputElement && e.target.checked) {
|
||||
onChange({ [question.id]: "accepted" });
|
||||
} else {
|
||||
onChange({ [question.id]: "" });
|
||||
}
|
||||
}}
|
||||
checked={value === "accepted"}
|
||||
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
||||
aria-labelledby={`${question.id}-label`}
|
||||
required={question.required}
|
||||
/>
|
||||
<span id={`${question.id}-label`} className="fb-ml-3 fb-mr-3 fb-font-medium">
|
||||
{getLocalizedValue(question.label, languageCode)}
|
||||
</span>
|
||||
</label>
|
||||
<label className="fb-flex fb-w-full fb-cursor-pointer fb-items-center">
|
||||
<input
|
||||
tabIndex={-1}
|
||||
type="checkbox"
|
||||
id={question.id}
|
||||
name={question.id}
|
||||
value={getLocalizedValue(question.label, languageCode)}
|
||||
onChange={(e) => {
|
||||
if (e.target instanceof HTMLInputElement && e.target.checked) {
|
||||
onChange({ [question.id]: "accepted" });
|
||||
} else {
|
||||
onChange({ [question.id]: "" });
|
||||
}
|
||||
}}
|
||||
checked={value === "accepted"}
|
||||
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
|
||||
aria-labelledby={`${question.id}-label`}
|
||||
required={question.required}
|
||||
/>
|
||||
<span id={`${question.id}-label`} className="fb-ml-3 fb-mr-3 fb-font-medium">
|
||||
{getLocalizedValue(question.label, languageCode)}
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ContactInfoQuestion({
|
||||
currentQuestionId,
|
||||
autoFocusEnabled,
|
||||
isBackButtonHidden,
|
||||
}: ContactInfoQuestionProps) {
|
||||
}: Readonly<ContactInfoQuestionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
@@ -131,7 +131,7 @@ export function ContactInfoQuestion({
|
||||
questionId={question.id}
|
||||
/>
|
||||
|
||||
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
|
||||
<div className="fb-mt-4 fb-w-full fb-grid fb-grid-cols-1 fb-gap-3">
|
||||
{fields.map((field, index) => {
|
||||
const isFieldRequired = () => {
|
||||
if (field.required) {
|
||||
|
||||
@@ -41,7 +41,7 @@ export function CTAQuestion({
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
onOpenExternalURL,
|
||||
}: CTAQuestionProps) {
|
||||
}: Readonly<CTAQuestionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
|
||||
@@ -8,9 +8,8 @@ import { getMonthName, getOrdinalDate } from "@/lib/date-time";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import DatePicker from "react-date-picker";
|
||||
import { DatePickerProps } from "react-date-picker";
|
||||
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import DatePicker, { DatePickerProps } from "react-date-picker";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyDateQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import "../../styles/date-picker.css";
|
||||
@@ -23,13 +22,12 @@ interface DateQuestionProps {
|
||||
onBack: () => void;
|
||||
isFirstQuestion: boolean;
|
||||
isLastQuestion: boolean;
|
||||
autoFocus?: boolean;
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: TSurveyQuestionId;
|
||||
isBackButtonHidden: boolean;
|
||||
autoFocusEnabled?: boolean;
|
||||
}
|
||||
|
||||
function CalendarIcon() {
|
||||
@@ -94,7 +92,8 @@ export function DateQuestion({
|
||||
ttc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: DateQuestionProps) {
|
||||
autoFocusEnabled,
|
||||
}: Readonly<DateQuestionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
@@ -104,6 +103,15 @@ export function DateQuestion({
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value ? new Date(value) : undefined);
|
||||
const [hideInvalid, setHideInvalid] = useState(!selectedDate);
|
||||
|
||||
const datePickerRef = useCallback(
|
||||
(currentElement: HTMLButtonElement | null) => {
|
||||
if (currentElement && autoFocusEnabled && isCurrent) {
|
||||
currentElement.focus();
|
||||
}
|
||||
},
|
||||
[autoFocusEnabled, isCurrent]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (datePickerOpen) {
|
||||
if (!selectedDate) setSelectedDate(new Date());
|
||||
@@ -170,6 +178,7 @@ export function DateQuestion({
|
||||
<div className="fb-relative">
|
||||
{!datePickerOpen && (
|
||||
<button
|
||||
ref={datePickerRef}
|
||||
onClick={() => {
|
||||
setDatePickerOpen(true);
|
||||
}}
|
||||
|
||||
@@ -14,21 +14,21 @@ import { FileInput } from "../general/file-input";
|
||||
import { Subheader } from "../general/subheader";
|
||||
|
||||
interface FileUploadQuestionProps {
|
||||
readonly question: TSurveyFileUploadQuestion;
|
||||
readonly value: string[];
|
||||
readonly onChange: (responseData: TResponseData) => void;
|
||||
readonly onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
||||
readonly onBack: () => void;
|
||||
readonly onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
|
||||
readonly isFirstQuestion: boolean;
|
||||
readonly isLastQuestion: boolean;
|
||||
readonly surveyId: string;
|
||||
readonly languageCode: string;
|
||||
readonly ttc: TResponseTtc;
|
||||
readonly setTtc: (ttc: TResponseTtc) => void;
|
||||
readonly autoFocusEnabled: boolean;
|
||||
readonly currentQuestionId: TSurveyQuestionId;
|
||||
readonly isBackButtonHidden: boolean;
|
||||
question: TSurveyFileUploadQuestion;
|
||||
value: string[];
|
||||
onChange: (responseData: TResponseData) => void;
|
||||
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
||||
onBack: () => void;
|
||||
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
|
||||
isFirstQuestion: boolean;
|
||||
isLastQuestion: boolean;
|
||||
surveyId: string;
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: TSurveyQuestionId;
|
||||
isBackButtonHidden: boolean;
|
||||
}
|
||||
|
||||
export function FileUploadQuestion({
|
||||
@@ -46,7 +46,7 @@ export function FileUploadQuestion({
|
||||
setTtc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: FileUploadQuestionProps) {
|
||||
}: Readonly<FileUploadQuestionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function MatrixQuestion({
|
||||
setTtc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: MatrixQuestionProps) {
|
||||
}: Readonly<MatrixQuestionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
|
||||
@@ -41,7 +41,7 @@ export function MultipleChoiceMultiQuestion({
|
||||
autoFocusEnabled,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: MultipleChoiceMultiProps) {
|
||||
}: Readonly<MultipleChoiceMultiProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
|
||||
@@ -41,7 +41,7 @@ export function MultipleChoiceSingleQuestion({
|
||||
autoFocusEnabled,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: MultipleChoiceSingleProps) {
|
||||
}: Readonly<MultipleChoiceSingleProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [otherSelected, setOtherSelected] = useState(false);
|
||||
const otherSpecify = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -167,19 +167,6 @@ describe("NPSQuestion", () => {
|
||||
expect(getUpdatedTtc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("updates hover state when mouse moves over options", () => {
|
||||
render(<NPSQuestion {...mockProps} />);
|
||||
|
||||
const option = screen.getByText("5").closest("label");
|
||||
expect(option).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseOver(option!);
|
||||
expect(option).toHaveClass("fb-bg-accent-bg");
|
||||
|
||||
fireEvent.mouseLeave(option!);
|
||||
expect(option).not.toHaveClass("fb-bg-accent-bg");
|
||||
});
|
||||
|
||||
test("supports keyboard navigation", () => {
|
||||
render(<NPSQuestion {...mockProps} />);
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export function NPSQuestion({
|
||||
setTtc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: NPSQuestionProps) {
|
||||
}: Readonly<NPSQuestionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [hoveredNumber, setHoveredNumber] = useState(-1);
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
@@ -96,17 +96,15 @@ export function NPSQuestion({
|
||||
<div className="fb-flex">
|
||||
{Array.from({ length: 11 }, (_, i) => i).map((number, idx) => {
|
||||
return (
|
||||
<label
|
||||
<button
|
||||
type="button"
|
||||
key={number}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
onMouseOver={() => {
|
||||
setHoveredNumber(number);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredNumber(-1);
|
||||
}}
|
||||
onMouseOver={() => setHoveredNumber(number)}
|
||||
onMouseLeave={() => setHoveredNumber(-1)}
|
||||
onFocus={() => setHoveredNumber(number)}
|
||||
onBlur={() => setHoveredNumber(-1)}
|
||||
onKeyDown={(e) => {
|
||||
// Accessibility: if spacebar was pressed pass this down to the input
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById(number.toString())?.click();
|
||||
@@ -120,29 +118,29 @@ export function NPSQuestion({
|
||||
"fb-text-heading first:fb-rounded-l-custom last:fb-rounded-r-custom focus:fb-border-brand fb-relative fb-h-10 fb-flex-1 fb-cursor-pointer fb-overflow-hidden fb-border-b fb-border-l fb-border-t fb-text-center fb-text-sm last:fb-border-r focus:fb-border-2 focus:fb-outline-none",
|
||||
question.isColorCodingEnabled
|
||||
? "fb-h-[46px] fb-leading-[3.5em]"
|
||||
: "fb-h fb-leading-10",
|
||||
: "fb-h-[41px] fb-leading-10",
|
||||
hoveredNumber === number ? "fb-bg-accent-bg" : ""
|
||||
)}>
|
||||
{question.isColorCodingEnabled ? (
|
||||
<div
|
||||
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getNPSOptionColor(idx)}`}
|
||||
<label className="fb-w-full fb-h-full fb-flex fb-items-center fb-justify-center">
|
||||
{question.isColorCodingEnabled ? (
|
||||
<div
|
||||
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getNPSOptionColor(idx)}`}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
type="radio"
|
||||
id={number.toString()}
|
||||
name="nps"
|
||||
value={number}
|
||||
checked={value === number}
|
||||
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
|
||||
onClick={() => handleClick(number)}
|
||||
required={question.required}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
type="radio"
|
||||
id={number.toString()}
|
||||
name="nps"
|
||||
value={number}
|
||||
checked={value === number}
|
||||
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
|
||||
onClick={() => {
|
||||
handleClick(number);
|
||||
}}
|
||||
required={question.required}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{number}
|
||||
</label>
|
||||
{number}
|
||||
</label>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("OpenTextQuestion", () => {
|
||||
test("renders textarea for long answers", () => {
|
||||
render(<OpenTextQuestion {...defaultProps} question={{ ...defaultQuestion, longAnswer: true }} />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("rows", "3");
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("rows", "5");
|
||||
});
|
||||
|
||||
test("displays character limit when configured", () => {
|
||||
|
||||
@@ -20,7 +20,6 @@ interface OpenTextQuestionProps {
|
||||
onBack: () => void;
|
||||
isFirstQuestion: boolean;
|
||||
isLastQuestion: boolean;
|
||||
autoFocus?: boolean;
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
@@ -43,7 +42,7 @@ export function OpenTextQuestion({
|
||||
autoFocusEnabled,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: OpenTextQuestionProps) {
|
||||
}: Readonly<OpenTextQuestionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [currentLength, setCurrentLength] = useState(value.length || 0);
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
@@ -103,7 +102,7 @@ export function OpenTextQuestion({
|
||||
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
|
||||
questionId={question.id}
|
||||
/>
|
||||
<div className="fb-mt-4">
|
||||
<div className="fb-mt-4 fb-text-md">
|
||||
{question.longAnswer === false ? (
|
||||
<input
|
||||
ref={inputRef as RefObject<HTMLInputElement>}
|
||||
@@ -120,7 +119,7 @@ export function OpenTextQuestion({
|
||||
onInput={(e) => {
|
||||
handleInputChange(e.currentTarget.value);
|
||||
}}
|
||||
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
|
||||
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0"
|
||||
pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"}
|
||||
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
|
||||
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
|
||||
@@ -135,7 +134,7 @@ export function OpenTextQuestion({
|
||||
) : (
|
||||
<textarea
|
||||
ref={inputRef as RefObject<HTMLTextAreaElement>}
|
||||
rows={3}
|
||||
rows={5}
|
||||
autoFocus={isCurrent ? autoFocusEnabled : undefined}
|
||||
name={question.id}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
@@ -148,7 +147,7 @@ export function OpenTextQuestion({
|
||||
onInput={(e) => {
|
||||
handleInputChange(e.currentTarget.value);
|
||||
}}
|
||||
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm"
|
||||
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0"
|
||||
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}
|
||||
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
|
||||
maxLength={question.inputType === "text" ? question.charLimit?.max : undefined}
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("PictureSelectionQuestion", () => {
|
||||
render(<PictureSelectionQuestion {...mockProps} />);
|
||||
|
||||
const images = screen.getAllByRole("img");
|
||||
const label = images[0].closest("label");
|
||||
const label = images[0].closest("button");
|
||||
|
||||
fireEvent.keyDown(label!, { key: " " });
|
||||
|
||||
|
||||
@@ -41,8 +41,15 @@ export function PictureSelectionQuestion({
|
||||
setTtc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: PictureSelectionProps) {
|
||||
}: Readonly<PictureSelectionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>(() => {
|
||||
const initialLoadingState: Record<string, boolean> = {};
|
||||
question.choices.forEach((choice) => {
|
||||
initialLoadingState[choice.id] = true;
|
||||
});
|
||||
return initialLoadingState;
|
||||
});
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||
@@ -115,35 +122,72 @@ export function PictureSelectionQuestion({
|
||||
<div className="fb-mt-4">
|
||||
<fieldset>
|
||||
<legend className="fb-sr-only">Options</legend>
|
||||
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-2 fb-gap-4">
|
||||
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-1 sm:fb-grid-cols-2 fb-gap-4">
|
||||
{questionChoices.map((choice) => (
|
||||
<label
|
||||
key={choice.id}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
htmlFor={choice.id}
|
||||
onKeyDown={(e) => {
|
||||
// Accessibility: if spacebar was pressed pass this down to the input
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById(choice.id)?.click();
|
||||
document.getElementById(choice.id)?.focus();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
handleChange(choice.id);
|
||||
}}
|
||||
className={cn(
|
||||
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus:fb-outline-none fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] focus:fb-border-brand focus:fb-border-4 group/image",
|
||||
Array.isArray(value) && value.includes(choice.id)
|
||||
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
|
||||
: ""
|
||||
)}>
|
||||
<img
|
||||
src={choice.imageUrl}
|
||||
id={choice.id}
|
||||
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
|
||||
className="fb-h-full fb-w-full fb-object-cover"
|
||||
/>
|
||||
<div className="fb-relative" key={choice.id}>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
onKeyDown={(e) => {
|
||||
// Accessibility: if spacebar was pressed pass this down to the input
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById(choice.id)?.click();
|
||||
document.getElementById(choice.id)?.focus();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
handleChange(choice.id);
|
||||
}}
|
||||
className={cn(
|
||||
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus-visible:fb-outline-none focus-visible:fb-ring-2 focus-visible:fb-ring-brand focus-visible:fb-ring-offset-2 fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] group/image",
|
||||
Array.isArray(value) && value.includes(choice.id)
|
||||
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
|
||||
: ""
|
||||
)}>
|
||||
{loadingImages[choice.id] && (
|
||||
<div className="fb-absolute fb-inset-0 fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
|
||||
)}
|
||||
<img
|
||||
src={choice.imageUrl}
|
||||
id={choice.id}
|
||||
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
|
||||
className={cn(
|
||||
"fb-h-full fb-w-full fb-object-cover",
|
||||
loadingImages[choice.id] ? "fb-opacity-0" : ""
|
||||
)}
|
||||
onLoad={() => {
|
||||
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
|
||||
}}
|
||||
/>
|
||||
{question.allowMulti ? (
|
||||
<input
|
||||
id={`${choice.id}-checked`}
|
||||
name={`${choice.id}-checkbox`}
|
||||
type="checkbox"
|
||||
tabIndex={-1}
|
||||
checked={value.includes(choice.id)}
|
||||
className={cn(
|
||||
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
|
||||
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
|
||||
)}
|
||||
required={question.required && value.length ? false : question.required}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`${choice.id}-radio`}
|
||||
name={`${choice.id}-radio`}
|
||||
type="radio"
|
||||
tabIndex={-1}
|
||||
checked={value.includes(choice.id)}
|
||||
className={cn(
|
||||
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
|
||||
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
|
||||
)}
|
||||
required={question.required && value.length ? false : question.required}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href={choice.imageUrl}
|
||||
@@ -153,52 +197,25 @@ export function PictureSelectionQuestion({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
|
||||
className="fb-absolute fb-bottom-4 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-expand">
|
||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
||||
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
|
||||
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
|
||||
className="lucide lucide-image-down-icon lucide-image-down">
|
||||
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
|
||||
<path d="m14 19 3 3v-5.5" />
|
||||
<path d="m17 22 3-3" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
</svg>
|
||||
</a>
|
||||
{question.allowMulti ? (
|
||||
<input
|
||||
id={`${choice.id}-checked`}
|
||||
name={`${choice.id}-checkbox`}
|
||||
type="checkbox"
|
||||
tabIndex={-1}
|
||||
checked={value.includes(choice.id)}
|
||||
className={cn(
|
||||
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
|
||||
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
|
||||
)}
|
||||
required={question.required && value.length ? false : question.required}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`${choice.id}-radio`}
|
||||
name={`${choice.id}-radio`}
|
||||
type="radio"
|
||||
tabIndex={-1}
|
||||
checked={value.includes(choice.id)}
|
||||
className={cn(
|
||||
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
|
||||
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
|
||||
)}
|
||||
required={question.required && value.length ? false : question.required}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -46,7 +46,7 @@ export function RankingQuestion({
|
||||
autoFocusEnabled,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: RankingQuestionProps) {
|
||||
}: Readonly<RankingQuestionProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
const shuffledChoicesIds = useMemo(() => {
|
||||
|
||||
@@ -56,6 +56,11 @@ export function AutoCloseWrapper({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.autoClose) {
|
||||
// Reset interaction state when auto-close is enabled
|
||||
setHasInteracted(false);
|
||||
setCountDownActive(true);
|
||||
}
|
||||
startCountdown();
|
||||
return stopCountdown;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to run this effect on every render
|
||||
|
||||
@@ -124,28 +124,6 @@ describe("ScrollableContainer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("uses 60dvh maxHeight by default when not in survey preview", () => {
|
||||
vi.spyOn(document, "getElementById").mockReturnValue(null);
|
||||
const { container } = render(
|
||||
<ScrollableContainer>
|
||||
<div>Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
|
||||
expect(scrollableDiv).toHaveStyle({ maxHeight: "60dvh" });
|
||||
});
|
||||
|
||||
test("uses 42dvh maxHeight when isSurveyPreview is true", () => {
|
||||
vi.spyOn(document, "getElementById").mockReturnValue(document.createElement("div")); // Simulate survey-preview element exists
|
||||
const { container } = render(
|
||||
<ScrollableContainer>
|
||||
<div>Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
|
||||
expect(scrollableDiv).toHaveStyle({ maxHeight: "42dvh" });
|
||||
});
|
||||
|
||||
test("cleans up scroll event listener on unmount", () => {
|
||||
const { unmount, container } = render(
|
||||
<ScrollableContainer>
|
||||
@@ -213,4 +191,52 @@ describe("ScrollableContainer", () => {
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("applies reduced height when isSurveyPreview is true", () => {
|
||||
// Create a survey-preview element to make isSurveyPreview true
|
||||
const previewElement = document.createElement("div");
|
||||
previewElement.id = "survey-preview";
|
||||
document.body.appendChild(previewElement);
|
||||
|
||||
const { container } = render(
|
||||
<ScrollableContainer>
|
||||
<div>Preview Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
|
||||
const scrollableDiv = container.querySelector<HTMLElement>("#scrollable-container");
|
||||
expect(scrollableDiv).toBeInTheDocument();
|
||||
|
||||
if (scrollableDiv) {
|
||||
const computedStyle = scrollableDiv.style;
|
||||
expect(computedStyle.maxHeight).toBe("calc(var(--fb-survey-card-max-height, 42dvh) * 0.66)");
|
||||
expect(computedStyle.minHeight).toBe("calc(var(--fb-survey-card-min-height, 42dvh) * 0.66)");
|
||||
}
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(previewElement);
|
||||
});
|
||||
|
||||
test("applies normal height when isSurveyPreview is false", () => {
|
||||
// Ensure no survey-preview element exists
|
||||
const existingPreview = document.getElementById("survey-preview");
|
||||
if (existingPreview) {
|
||||
document.body.removeChild(existingPreview);
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ScrollableContainer>
|
||||
<div>Regular Content</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
|
||||
const scrollableDiv = container.querySelector<HTMLElement>("#scrollable-container");
|
||||
expect(scrollableDiv).toBeInTheDocument();
|
||||
|
||||
if (scrollableDiv) {
|
||||
const computedStyle = scrollableDiv.style;
|
||||
expect(computedStyle.maxHeight).toBe("calc(var(--fb-survey-card-max-height, 42dvh) * 1)");
|
||||
expect(computedStyle.minHeight).toBe("calc(var(--fb-survey-card-min-height, 42dvh) * 1)");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,21 +3,23 @@ import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface ScrollableContainerProps {
|
||||
className?: string;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export function ScrollableContainer({ children }: ScrollableContainerProps) {
|
||||
export function ScrollableContainer({ className, children }: Readonly<ScrollableContainerProps>) {
|
||||
const [isAtBottom, setIsAtBottom] = useState(false);
|
||||
const [isAtTop, setIsAtTop] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isSurveyPreview = Boolean(document.getElementById("survey-preview"));
|
||||
const isMobilePreview = isSurveyPreview ? Boolean(document.getElementById("mobile-preview")) : false;
|
||||
const previewScaleCoifficient = isSurveyPreview ? 0.66 : 1;
|
||||
|
||||
const checkScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
|
||||
setIsAtBottom(Math.round(scrollTop) + clientHeight >= scrollHeight);
|
||||
|
||||
setIsAtTop(scrollTop === 0);
|
||||
};
|
||||
|
||||
@@ -45,12 +47,18 @@ export function ScrollableContainer({ children }: ScrollableContainerProps) {
|
||||
<div className="fb-from-survey-bg fb-absolute fb-left-0 fb-right-2 fb-top-0 fb-z-10 fb-h-6 fb-bg-gradient-to-b fb-to-transparent" />
|
||||
)}
|
||||
<div
|
||||
id="scrollable-container"
|
||||
ref={containerRef}
|
||||
style={{
|
||||
scrollbarGutter: "stable both-edges",
|
||||
maxHeight: isSurveyPreview ? "42dvh" : "60dvh",
|
||||
maxHeight: isMobilePreview
|
||||
? "30dvh"
|
||||
: `calc(var(--fb-survey-card-max-height, 42dvh) * ${previewScaleCoifficient})`,
|
||||
minHeight: isMobilePreview
|
||||
? "30dvh"
|
||||
: `calc(var(--fb-survey-card-min-height, 42dvh) * ${previewScaleCoifficient})`,
|
||||
}}
|
||||
className={cn("fb-overflow-auto fb-px-4 fb-pb-4 fb-bg-survey-bg")}>
|
||||
className={cn(`fb-overflow-auto fb-px-4 fb-pb-4 fb-bg-survey-bg ${className}`)}>
|
||||
{children}
|
||||
</div>
|
||||
{!isAtBottom && (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MutableRef } from "preact/hooks";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { MutableRef, useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { JSX } from "preact/jsx-runtime";
|
||||
import React from "react";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
@@ -32,10 +31,10 @@ export const StackedCard = ({
|
||||
hovered,
|
||||
cardArrangement,
|
||||
}: StackedCardProps) => {
|
||||
const isHidden = offset < 0;
|
||||
const [delayedOffset, setDelayedOffset] = useState<number>(offset);
|
||||
const [contentOpacity, setContentOpacity] = useState<number>(0);
|
||||
const currentCardHeight = offset === 0 ? "auto" : offset < 0 ? "initial" : cardHeight;
|
||||
const isHidden = offset < 0 || offset > 2;
|
||||
|
||||
const getBottomStyles = () => {
|
||||
if (survey.type !== "link")
|
||||
@@ -50,28 +49,26 @@ export const StackedCard = ({
|
||||
|
||||
const calculateCardTransform = useMemo(() => {
|
||||
let rotationCoefficient = 3;
|
||||
|
||||
if (cardWidth >= 1000) {
|
||||
rotationCoefficient = 1.5;
|
||||
} else if (cardWidth > 650) {
|
||||
rotationCoefficient = 2;
|
||||
}
|
||||
|
||||
let rotationValue = ((hovered ? rotationCoefficient : rotationCoefficient - 0.5) * offset).toString();
|
||||
let translateValue = ((hovered ? 12 : 10) * offset).toString();
|
||||
|
||||
return (offset: number) => {
|
||||
switch (cardArrangement) {
|
||||
case "casual":
|
||||
return offset < 0
|
||||
? `translateX(33%)`
|
||||
: `translateX(0) rotate(-${((hovered ? rotationCoefficient : rotationCoefficient - 0.5) * offset).toString()}deg)`;
|
||||
return offset < 0 ? `translateX(35%) scale(0.97)` : `translateX(0) rotate(-${rotationValue}deg)`;
|
||||
case "straight":
|
||||
return offset < 0
|
||||
? `translateY(25%)`
|
||||
: `translateY(-${((hovered ? 12 : 10) * offset).toString()}px)`;
|
||||
return offset < 0 ? `translateY(35%) scale(0.97)` : `translateY(-${translateValue}px)`;
|
||||
default:
|
||||
return offset < 0 ? `translateX(0)` : `translateX(0)`;
|
||||
return `translateX(0)`;
|
||||
}
|
||||
};
|
||||
}, [cardArrangement, hovered, cardWidth]);
|
||||
}, [cardArrangement, hovered, cardWidth, offset]);
|
||||
|
||||
const straightCardArrangementStyles =
|
||||
cardArrangement === "straight"
|
||||
@@ -105,7 +102,9 @@ export const StackedCard = ({
|
||||
transform: calculateCardTransform(offset),
|
||||
opacity: isHidden ? 0 : (100 - 20 * offset) / 100,
|
||||
height: fullSizeCards ? "100%" : currentCardHeight,
|
||||
transitionDuration: "600ms",
|
||||
transitionProperty: "transform, opacity, margin, width",
|
||||
transitionDuration: "500ms",
|
||||
transitionBehavior: "ease-in-out",
|
||||
pointerEvents: offset === 0 ? "auto" : "none",
|
||||
...borderStyles,
|
||||
...straightCardArrangementStyles,
|
||||
|
||||
@@ -232,7 +232,7 @@ describe("StackedCardsContainer", () => {
|
||||
test("renders stacked arrangement correctly", () => {
|
||||
render(<StackedCardsContainer {...defaultProps} cardArrangement="casual" />);
|
||||
// q1 is index 0. currentQuestionIdx = 0. prev = -1, next = 1, next+1 = 2
|
||||
expect(mockStackedCardFn).toHaveBeenCalledTimes(4); // prev, current, next, next+1
|
||||
expect(mockStackedCardFn).toHaveBeenCalledTimes(5); // prev, current, next, next+1, next+2
|
||||
expect(screen.getByTestId("stacked-card-0")).toBeInTheDocument(); // current
|
||||
expect(screen.getByTestId("stacked-card-1")).toBeInTheDocument(); // next
|
||||
// Check that getCardContent is called for the current card (offset 0) via the mock
|
||||
|
||||
@@ -30,7 +30,7 @@ export function StackedCardsContainer({
|
||||
setQuestionId,
|
||||
shouldResetQuestionId = true,
|
||||
fullSizeCards = false,
|
||||
}: StackedCardsContainerProps) {
|
||||
}: Readonly<StackedCardsContainerProps>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const highlightBorderColor = survey.styling?.overwriteThemeStyling
|
||||
? survey.styling?.highlightBorderColor?.light
|
||||
@@ -40,7 +40,7 @@ export function StackedCardsContainer({
|
||||
: styling.cardBorderColor?.light;
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const resizeObserver = useRef<ResizeObserver | null>(null);
|
||||
const [cardHeight, setCardHeight] = useState("auto");
|
||||
const [cardHeight, setCardHeight] = useState("inital");
|
||||
const [cardWidth, setCardWidth] = useState<number>(0);
|
||||
|
||||
const questionIdxTemp = useMemo(() => {
|
||||
@@ -130,7 +130,7 @@ export function StackedCardsContainer({
|
||||
return (
|
||||
<div
|
||||
data-testid="stacked-cards-container"
|
||||
className="fb-relative fb-flex fb-h-full fb-items-end fb-justify-center md:fb-items-center"
|
||||
className="fb-relative fb-flex fb-h-full fb-items-end fb-justify-center md:fb-items-start"
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
@@ -148,7 +148,8 @@ export function StackedCardsContainer({
|
||||
</div>
|
||||
) : (
|
||||
questionIdxTemp !== undefined &&
|
||||
[prevQuestionIdx, currentQuestionIdx, nextQuestionIdx, nextQuestionIdx + 1].map(
|
||||
[prevQuestionIdx, currentQuestionIdx, nextQuestionIdx, nextQuestionIdx + 1, nextQuestionIdx + 2].map(
|
||||
// [prevQuestionIdx, currentQuestionIdx, nextQuestionIdx, nextQuestionIdx + 1].map(
|
||||
(dynamicQuestionIndex, index) => {
|
||||
const hasEndingCard = survey.endings.length > 0;
|
||||
// Check for hiding extra card
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SurveyContainerProps {
|
||||
onClose?: () => void;
|
||||
clickOutside?: boolean;
|
||||
isOpen?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function SurveyContainer({
|
||||
@@ -20,7 +21,8 @@ export function SurveyContainer({
|
||||
onClose,
|
||||
clickOutside,
|
||||
isOpen = true,
|
||||
}: SurveyContainerProps) {
|
||||
style,
|
||||
}: Readonly<SurveyContainerProps>) {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -73,7 +75,7 @@ export function SurveyContainer({
|
||||
|
||||
if (!isModal) {
|
||||
return (
|
||||
<div id="fbjs" className="fb-formbricks-form" style={{ height: "100%", width: "100%" }}>
|
||||
<div id="fbjs" className="fb-formbricks-form" style={{ ...style, height: "100%", width: "100%" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user