Compare commits

..

2 Commits

Author SHA1 Message Date
Johannes
d40d4c6770 updates 2025-05-25 17:17:38 +07:00
Johannes
8723e3162e concept draft 2025-05-23 12:38:29 +07:00
70 changed files with 2346 additions and 778 deletions

View File

@@ -1,7 +1,7 @@
"use server";
import {
getIsEmailUnique,
checkUserExistsByEmail,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
@@ -10,13 +10,13 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
AuthenticationError,
AuthorizationError,
InvalidInputError,
OperationNotAllowedError,
TooManyRequestsError,
} from "@formbricks/types/errors";
@@ -37,11 +37,10 @@ export const updateUserAction = authenticatedActionClient
const inputEmail = parsedInput.email?.trim().toLowerCase();
let payload: TUserUpdateInput = {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
name: parsedInput.name,
locale: parsedInput.locale,
};
// Only process email update if a new email is provided and it's different from current email
if (inputEmail && ctx.user.email !== inputEmail) {
// Check rate limit
try {
@@ -62,26 +61,20 @@ export const updateUserAction = authenticatedActionClient
throw new AuthorizationError("Incorrect credentials");
}
// Check if the new email is unique, no user exists with the new email
const isEmailUnique = await getIsEmailUnique(inputEmail);
const doesUserExist = await checkUserExistsByEmail(inputEmail);
// If the new email is unique, proceed with the email update
if (isEmailUnique) {
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
if (doesUserExist) {
throw new InvalidInputError("This email is already in use");
}
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
}
// Only proceed with updateUser if we have actual changes to make
if (Object.keys(payload).length > 0) {
await updateUser(ctx.user.id, payload);
}
return true;
return await updateUser(ctx.user.id, payload);
});
const ZUpdateAvatarAction = z.object({

View File

@@ -21,13 +21,11 @@ import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { TUser, TUserUpdateInput, ZUser } from "@formbricks/types/user";
import { updateUserAction } from "../actions";
// Schema & types
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
email: ZUserEmail.transform((val) => val?.trim().toLowerCase()),
});
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true });
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
export const EditProfileDetailsForm = ({
@@ -82,9 +80,9 @@ export const EditProfileDetailsForm = ({
if (updatedUserResult?.data) {
if (!emailVerificationDisabled) {
toast.success(t("auth.verification-requested.new_email_verification_success"));
toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email }));
} else {
toast.success(t("environments.settings.profile.email_change_initiated"));
toast.success(t("environments.settings.profile.profile_updated_successfully"));
await signOut({ redirect: false });
router.push(`/email-change-without-verification-success`);
return;
@@ -100,6 +98,11 @@ export const EditProfileDetailsForm = ({
};
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
if (data.email !== user.email && data.email.toLowerCase() === user.email.toLowerCase()) {
toast.error(t("auth.email-change.email_already_exists"));
return;
}
if (data.email !== user.email) {
setShowModal(true);
} else {

View File

@@ -2,7 +2,7 @@ import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getIsEmailUnique, verifyUserPassword } from "./user";
import { checkUserExistsByEmail, verifyUserPassword } from "./user";
// Mock dependencies
vi.mock("@/lib/user/cache", () => ({
@@ -116,27 +116,27 @@ describe("User Library Tests", () => {
});
});
describe("getIsEmailUnique", () => {
describe("checkUserExistsByEmail", () => {
const email = "test@example.com";
test("should return false if user exists", async () => {
test("should return true if user exists", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
id: "some-user-id",
} as any);
const result = await getIsEmailUnique(email);
expect(result).toBe(false);
const result = await checkUserExistsByEmail(email);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
test("should return true if user does not exist", async () => {
test("should return false if user does not exist", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
const result = await getIsEmailUnique(email);
expect(result).toBe(true);
const result = await checkUserExistsByEmail(email);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },

View File

@@ -47,7 +47,7 @@ export const verifyUserPassword = async (userId: string, password: string): Prom
return true;
};
export const getIsEmailUnique = reactCache(
export const checkUserExistsByEmail = reactCache(
async (email: string): Promise<boolean> =>
cache(
async () => {
@@ -60,9 +60,9 @@ export const getIsEmailUnique = reactCache(
},
});
return !user;
return !!user;
},
[`getIsEmailUnique-${email}`],
[`checkUserExistsByEmail-${email}`],
{
tags: [userCache.tag.byEmail(email)],
}

View File

@@ -41,7 +41,7 @@ describe("Survey Builder", () => {
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
shuffleOption: "none",
required: false,
required: true,
});
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: false,
required: true,
charLimit: {
enabled: false,
},
@@ -204,7 +204,7 @@ describe("Survey Builder", () => {
range: 5,
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
required: true,
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: false,
required: true,
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: false,
required: true,
});
expect(question.id).toBeDefined();
});
@@ -377,7 +377,7 @@ describe("Survey Builder", () => {
headline: { default: "CTA Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
required: true,
buttonExternal: false,
});
expect(question.id).toBeDefined();

View File

@@ -66,7 +66,7 @@ export const buildMultipleChoiceQuestion = ({
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
shuffleOption: shuffleOption || "none",
required: required ?? false,
required: required ?? true,
logic,
};
};
@@ -105,7 +105,7 @@ export const buildOpenTextQuestion = ({
headline: { default: headline },
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
required: required ?? false,
required: required ?? true,
longAnswer,
logic,
charLimit: {
@@ -153,7 +153,7 @@ export const buildRatingQuestion = ({
range,
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
required: required ?? false,
required: required ?? true,
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 ?? false,
required: required ?? true,
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 ?? false,
required: required ?? true,
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 ?? false,
required: required ?? true,
buttonExternal,
buttonUrl,
logic,

View File

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

View File

@@ -9,11 +9,10 @@
"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",
"email_verification_loading": "E-Mail-Bestätigung läuft...",
"email_verification_loading_description": "Wir aktualisieren Ihre E-Mail-Adresse in unserem System. Dies kann einige Sekunden dauern.",
"invalid_or_expired_token": "E-Mail-Änderung fehlgeschlagen. Dein Token ist ungültig oder abgelaufen.",
"new_email": "Neue E-Mail",
"old_email": "Alte E-Mail"
@@ -89,7 +88,6 @@
"verification-requested": {
"invalid_email_address": "Ungültige E-Mail-Adresse",
"invalid_token": "Ungültiges Token ☹️",
"new_email_verification_success": "Wenn die Adresse gültig ist, wurde eine Bestätigungs-E-Mail gesendet.",
"no_email_provided": "Keine E-Mail bereitgestellt",
"please_click_the_link_in_the_email_to_activate_your_account": "Bitte klicke auf den Link in der E-Mail, um dein Konto zu aktivieren.",
"please_confirm_your_email_address": "Bitte bestätige deine E-Mail-Adresse",
@@ -1151,7 +1149,6 @@
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",

View File

@@ -9,11 +9,10 @@
"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",
"email_verification_loading": "Email verification in progress...",
"email_verification_loading_description": "We are updating your email address in our system. This may take a few seconds.",
"invalid_or_expired_token": "Email change failed. Your token is invalid or expired.",
"new_email": "New Email",
"old_email": "Old Email"
@@ -89,7 +88,6 @@
"verification-requested": {
"invalid_email_address": "Invalid email address",
"invalid_token": "Invalid token ☹️",
"new_email_verification_success": "If the address is valid, a verification email has been sent.",
"no_email_provided": "No email provided",
"please_click_the_link_in_the_email_to_activate_your_account": "Please click the link in the email to activate your account.",
"please_confirm_your_email_address": "Please confirm your email address",
@@ -986,7 +984,7 @@
"2000_monthly_identified_users": "2000 Monthly Identified Users",
"30000_monthly_identified_users": "30000 Monthly Identified Users",
"3_projects": "3 Projects",
"5000_monthly_responses": "5,000 Monthly Responses",
"5000_monthly_responses": "5000 Monthly Responses",
"5_projects": "5 Projects",
"7500_monthly_identified_users": "7500 Monthly Identified Users",
"advanced_targeting": "Advanced Targeting",
@@ -1151,7 +1149,6 @@
"disable_two_factor_authentication": "Disable two factor authentication",
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
"email_change_initiated": "Your email change request has been initiated.",
"enable_two_factor_authentication": "Enable two factor authentication",
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",

View File

@@ -9,11 +9,10 @@
"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",
"email_verification_loading": "Vérification de l'email en cours...",
"email_verification_loading_description": "Nous mettons à jour votre adresse email dans notre système. Cela peut prendre quelques secondes.",
"invalid_or_expired_token": "Échec du changement d'email. Votre jeton est invalide ou expiré.",
"new_email": "Nouvel Email",
"old_email": "Ancien Email"
@@ -89,7 +88,6 @@
"verification-requested": {
"invalid_email_address": "Adresse e-mail invalide",
"invalid_token": "Jeton non valide ☹️",
"new_email_verification_success": "Si l'adresse est valide, un email de vérification a été envoyé.",
"no_email_provided": "Aucun e-mail fourni",
"please_click_the_link_in_the_email_to_activate_your_account": "Veuillez cliquer sur le lien dans l'e-mail pour activer votre compte.",
"please_confirm_your_email_address": "Veuillez confirmer votre adresse e-mail.",
@@ -986,7 +984,7 @@
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
"3_projects": "3 Projets",
"5000_monthly_responses": "5,000 Réponses Mensuelles",
"5000_monthly_responses": "5000 Réponses Mensuelles",
"5_projects": "5 Projets",
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
"advanced_targeting": "Ciblage Avancé",
@@ -1151,7 +1149,6 @@
"disable_two_factor_authentication": "Désactiver l'authentification à deux facteurs",
"disable_two_factor_authentication_description": "Si vous devez désactiver l'authentification à deux facteurs, nous vous recommandons de la réactiver dès que possible.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Chaque code de sauvegarde peut être utilisé exactement une fois pour accorder l'accès sans votre authentificateur.",
"email_change_initiated": "Votre demande de changement d'email a été initiée.",
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
"file_size_must_be_less_than_10mb": "La taille du fichier doit être inférieure à 10 Mo.",

View File

@@ -9,11 +9,10 @@
"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",
"email_verification_loading": "Verificação de e-mail em andamento...",
"email_verification_loading_description": "Estamos atualizando seu endereço de e-mail em nosso sistema. Isso pode levar alguns segundos.",
"invalid_or_expired_token": "Falha na alteração do e-mail. Seu token é inválido ou expirou.",
"new_email": "Novo Email",
"old_email": "Email Antigo"
@@ -89,7 +88,6 @@
"verification-requested": {
"invalid_email_address": "Endereço de email inválido",
"invalid_token": "Token inválido ☹️",
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
"no_email_provided": "Nenhum e-mail fornecido",
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clica no link do e-mail pra ativar sua conta.",
"please_confirm_your_email_address": "Por favor, confirme seu endereço de e-mail",
@@ -986,7 +984,7 @@
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"5000_monthly_responses": "5000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
"advanced_targeting": "Mira Avançada",
@@ -1151,7 +1149,6 @@
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",

View File

@@ -9,11 +9,10 @@
"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",
"email_verification_loading": "Verificação do email em progresso...",
"email_verification_loading_description": "Estamos a atualizar o seu endereço de email no nosso sistema. Isto pode demorar alguns segundos.",
"invalid_or_expired_token": "Falha na alteração do email. O seu token é inválido ou expirou.",
"new_email": "Novo Email",
"old_email": "Email Antigo"
@@ -89,7 +88,6 @@
"verification-requested": {
"invalid_email_address": "Endereço de email inválido",
"invalid_token": "Token inválido ☹️",
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
"no_email_provided": "Nenhum email fornecido",
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clique no link no email para ativar a sua conta.",
"please_confirm_your_email_address": "Por favor, confirme o seu endereço de email",
@@ -986,7 +984,7 @@
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"5000_monthly_responses": "5000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
"advanced_targeting": "Segmentação Avançada",
@@ -1151,7 +1149,6 @@
"disable_two_factor_authentication": "Desativar autenticação de dois fatores",
"disable_two_factor_authentication_description": "Se precisar de desativar a autenticação de dois fatores, recomendamos que a reative o mais rapidamente possível.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
"file_size_must_be_less_than_10mb": "O tamanho do ficheiro deve ser inferior a 10MB.",

View File

@@ -9,11 +9,10 @@
"continue_with_saml": "使用 SAML SSO 繼續",
"email-change": {
"confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼",
"email_already_exists": "此電子郵件地址已被使用",
"email_change_success": "電子郵件已成功更改",
"email_change_success_description": "您已成功更改電子郵件地址。請使用您的新電子郵件地址登入。",
"email_verification_failed": "電子郵件驗證失敗",
"email_verification_loading": "電子郵件驗證進行中...",
"email_verification_loading_description": "我們正在系統中更新您的電子郵件地址。這可能需要幾秒鐘。",
"invalid_or_expired_token": "電子郵件更改失敗。您的 token 無效或已過期。",
"new_email": "新 電子郵件",
"old_email": "舊 電子郵件"
@@ -89,7 +88,6 @@
"verification-requested": {
"invalid_email_address": "無效的電子郵件地址",
"invalid_token": "無效的權杖 ☹️",
"new_email_verification_success": "如果地址有效,驗證電子郵件已發送。",
"no_email_provided": "未提供電子郵件",
"please_click_the_link_in_the_email_to_activate_your_account": "請點擊電子郵件中的連結以啟用您的帳戶。",
"please_confirm_your_email_address": "請確認您的電子郵件地址",
@@ -1151,7 +1149,6 @@
"disable_two_factor_authentication": "停用雙重驗證",
"disable_two_factor_authentication_description": "如果您需要停用 2FA我們建議您盡快重新啟用它。",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每個備份碼只能使用一次,以便在沒有驗證器的情況下授予存取權限。",
"email_change_initiated": "您的 email 更改請求已啟動。",
"enable_two_factor_authentication": "啟用雙重驗證",
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
"file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。",

View File

@@ -1,7 +1,8 @@
import { validateInputs } from "@/lib/utils/validate";
import { Response } from "node-fetch";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { createBrevoCustomer, updateBrevoCustomer } from "./brevo";
import { createBrevoCustomer } from "./brevo";
vi.mock("@/lib/constants", () => ({
BREVO_API_KEY: "mock_api_key",
@@ -41,87 +42,18 @@ describe("createBrevoCustomer", () => {
await createBrevoCustomer({ id: "123", email: "test@example.com" });
expect(validateInputs).toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error sending user to Brevo");
});
test("should log the error response if fetch status is not 201", async () => {
test("should log the error response if fetch status is not 200", async () => {
const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockResolvedValueOnce(
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
);
await createBrevoCustomer({ id: "123", email: "test@example.com" });
expect(validateInputs).toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error sending user to Brevo");
});
});
describe("updateBrevoCustomer", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should return early if BREVO_API_KEY is not defined", async () => {
vi.doMock("@/lib/constants", () => ({
BREVO_API_KEY: undefined,
BREVO_LIST_ID: "123",
}));
const { updateBrevoCustomer } = await import("./brevo"); // Re-import to get the mocked version
const result = await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
expect(result).toBeUndefined();
expect(global.fetch).not.toHaveBeenCalled();
expect(validateInputs).not.toHaveBeenCalled();
});
test("should log an error if fetch fails", async () => {
const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed"));
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
expect(validateInputs).toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error updating user in Brevo");
});
test("should log the error response if fetch status is not 204", async () => {
const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockResolvedValueOnce(
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
);
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
expect(validateInputs).toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error updating user in Brevo");
});
test("should successfully update a Brevo customer", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce(new global.Response(null, { status: 204 }));
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
expect(global.fetch).toHaveBeenCalledWith(
"https://api.brevo.com/v3/contacts/user123?identifierType=ext_id",
expect.objectContaining({
method: "PUT",
headers: {
Accept: "application/json",
"api-key": "mock_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
attributes: { EMAIL: "test@example.com" },
}),
})
);
expect(validateInputs).toHaveBeenCalled();
});
});

View File

@@ -4,26 +4,6 @@ import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
type BrevoCreateContact = {
email?: string;
ext_id?: string;
attributes?: Record<string, string | string[]>;
emailBlacklisted?: boolean;
smsBlacklisted?: boolean;
listIds?: number[];
updateEnabled?: boolean;
smtpBlacklistSender?: string[];
};
type BrevoUpdateContact = {
attributes?: Record<string, string>;
emailBlacklisted?: boolean;
smsBlacklisted?: boolean;
listIds?: number[];
unlinkListIds?: number[];
smtpBlacklistSender?: string[];
};
export const createBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
if (!BREVO_API_KEY) {
return;
@@ -32,7 +12,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
validateInputs([id, ZId], [email, ZUserEmail]);
try {
const requestBody: BrevoCreateContact = {
const requestBody: any = {
email,
ext_id: id,
updateEnabled: false,
@@ -54,7 +34,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
body: JSON.stringify(requestBody),
});
if (res.status !== 201) {
if (res.status !== 200) {
const errorText = await res.text();
logger.error({ errorText }, "Error sending user to Brevo");
}
@@ -62,36 +42,3 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
logger.error(error, "Error sending user to Brevo");
}
};
export const updateBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
if (!BREVO_API_KEY) {
return;
}
validateInputs([id, ZId], [email, ZUserEmail]);
try {
const requestBody: BrevoUpdateContact = {
attributes: {
EMAIL: email,
},
};
const res = await fetch(`https://api.brevo.com/v3/contacts/${id}?identifierType=ext_id`, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"api-key": BREVO_API_KEY,
},
body: JSON.stringify(requestBody),
});
if (res.status !== 204) {
const errorText = await res.text();
logger.error({ errorText }, "Error updating user in Brevo");
}
} catch (error) {
logger.error(error, "Error updating user in Brevo");
}
};

View File

@@ -5,15 +5,17 @@ import { getTranslate } from "@/tolgee/server";
export const SignupWithoutVerificationSuccessPage = async () => {
const t = await getTranslate();
return (
<FormWrapper>
<h1 className="leading-2 mb-4 text-center font-bold">
{t("auth.signup_without_verification_success.user_successfully_created")}
</h1>
<p className="text-center text-sm">
{t("auth.signup_without_verification_success.user_successfully_created_description")}
</p>
<hr className="my-4" />
<BackToLoginButton />
</FormWrapper>
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
<FormWrapper>
<h1 className="leading-2 mb-4 text-center font-bold">
{t("auth.signup_without_verification_success.user_successfully_created")}
</h1>
<p className="text-center text-sm">
{t("auth.signup_without_verification_success.user_successfully_created_description")}
</p>
<hr className="my-4" />
<BackToLoginButton />
</FormWrapper>
</div>
);
};

View File

@@ -2,7 +2,6 @@
import { verifyEmailChangeToken } from "@/lib/jwt";
import { actionClient } from "@/lib/utils/action-client";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { updateUser } from "@/modules/auth/lib/user";
import { z } from "zod";
@@ -18,6 +17,5 @@ export const verifyEmailChangeAction = actionClient
if (!user) {
throw new Error("User not found or email update failed");
}
await updateBrevoCustomer({ id: user.id, email: user.email });
return user;
});

View File

@@ -28,7 +28,7 @@ describe("EmailChangeSignIn", () => {
test("shows loading state initially", () => {
render(<EmailChangeSignIn token="valid-token" />);
expect(screen.getByText("auth.email-change.email_verification_loading")).toBeInTheDocument();
expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument();
});
test("handles successful email change verification", async () => {

View File

@@ -5,11 +5,7 @@ import { useTranslate } from "@tolgee/react";
import { signOut } from "next-auth/react";
import { useEffect, useState } from "react";
interface EmailChangeSignInProps {
token: string;
}
export const EmailChangeSignIn = ({ token }: EmailChangeSignInProps) => {
export const EmailChangeSignIn = ({ token }: { token: string }) => {
const { t } = useTranslate();
const [status, setStatus] = useState<"success" | "error" | "loading">("loading");
@@ -41,25 +37,18 @@ export const EmailChangeSignIn = ({ token }: EmailChangeSignInProps) => {
}
}, [status]);
const text = {
heading: {
success: t("auth.email-change.email_change_success"),
error: t("auth.email-change.email_verification_failed"),
loading: t("auth.email-change.email_verification_loading"),
},
description: {
success: t("auth.email-change.email_change_success_description"),
error: t("auth.email-change.invalid_or_expired_token"),
loading: t("auth.email-change.email_verification_loading_description"),
},
};
return (
<>
<h1 className={`leading-2 mb-4 text-center font-bold ${status === "error" ? "text-red-600" : ""}`}>
{text.heading[status]}
{status === "success"
? t("auth.email-change.email_change_success")
: t("auth.email-change.email_verification_failed")}
</h1>
<p className="text-center text-sm">{text.description[status]}</p>
<p className="text-center text-sm">
{status === "success"
? t("auth.email-change.email_change_success_description")
: t("auth.email-change.invalid_or_expired_token")}
</p>
<hr className="my-4" />
</>
);

View File

@@ -200,11 +200,11 @@ export const TeamSettingsModal = ({
open={open}
setOpen={setOpen}
noPadding
className="flex max-h-[90dvh] flex-col overflow-visible"
className="overflow-visible"
size="md"
hideCloseButton
closeOnOutsideClick={true}>
<div className="sticky top-0 z-10 rounded-t-lg bg-slate-100">
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
@@ -213,27 +213,27 @@ export const TeamSettingsModal = ({
<XIcon className="h-6 w-6 rounded-md bg-white" />
<span className="sr-only">Close</span>
</button>
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div>
<H4>
{t("environments.settings.teams.team_name_settings_title", {
teamName: team.name,
})}
</H4>
<Muted className="text-slate-500">
{t("environments.settings.teams.team_settings_description")}
</Muted>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div>
<H4>
{t("environments.settings.teams.team_name_settings_title", {
teamName: team.name,
})}
</H4>
<Muted className="text-slate-500">
{t("environments.settings.teams.team_settings_description")}
</Muted>
</div>
</div>
</div>
</div>
</div>
<FormProvider {...form}>
<form
className="flex w-full flex-grow flex-col overflow-hidden"
onSubmit={handleSubmit(handleUpdateTeam)}>
<div className="flex-grow space-y-6 overflow-y-auto p-6">
<div className="space-y-6">
<form className="w-full" onSubmit={handleSubmit(handleUpdateTeam)}>
<div className="flex flex-col gap-6 p-6">
<div className="max-h-[500px] space-y-6 overflow-y-auto">
<FormField
control={control}
name="name"
@@ -512,8 +512,6 @@ export const TeamSettingsModal = ({
/>
</div>
</div>
</div>
<div className="sticky bottom-0 z-10 border-slate-200 p-6">
<div className="flex justify-between">
<Button size="default" type="button" variant="outline" onClick={closeSettingsModal}>
{t("common.cancel")}

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,15 +78,6 @@ describe("getMetadataForLinkSurvey", () => {
alternates: {
canonical: `/s/${mockSurveyId}`,
},
robots: {
index: false,
follow: true,
googleBot: {
index: false,
follow: true,
noimageindex: true,
},
},
});
});
@@ -162,15 +153,6 @@ describe("getMetadataForLinkSurvey", () => {
alternates: {
canonical: `/s/${mockSurveyId}`,
},
robots: {
index: false,
follow: true,
googleBot: {
index: false,
follow: true,
noimageindex: true,
},
},
});
});
@@ -201,15 +183,6 @@ describe("getMetadataForLinkSurvey", () => {
alternates: {
canonical: `/s/${mockSurveyId}`,
},
robots: {
index: false,
follow: true,
googleBot: {
index: false,
follow: true,
noimageindex: true,
},
},
});
});
});

View File

@@ -35,14 +35,5 @@ export const getMetadataForLinkSurvey = async (surveyId: string): Promise<Metada
alternates: {
canonical: canonicalPath,
},
robots: {
index: false,
follow: true,
googleBot: {
index: false,
follow: true,
noimageindex: true,
},
},
};
};

View File

@@ -166,7 +166,6 @@ 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>
@@ -176,10 +175,10 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
);
} else if (isEditorView) {
return (
<div ref={ContentRef} className="flex flex-grow flex-col overflow-hidden rounded-b-lg">
<div ref={ContentRef} className="overflow-hiddem flex flex-grow flex-col 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-start justify-center pt-[10dvh]">{children}</div>
<div className="flex h-full w-full items-center justify-center">{children}</div>
</div>
</div>
);

View File

@@ -6,7 +6,7 @@ import * as React from "react";
const DialogPortal = DialogPrimitive.Portal;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { blur?: boolean }
>(({ className, blur, ...props }, ref) => (
<DialogPrimitive.Overlay
@@ -35,7 +35,7 @@ const sizeClassName = {
};
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & DialogContentProps
>(
(

View File

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

View File

@@ -103,7 +103,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
page.locator("#questionCard-3").getByText(surveys.createAndSubmit.ratingQuestion.highLabel)
).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).not.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" })).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.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.getByLabel(surveys.createAndSubmit.consentQuestion.checkboxLabel).check();
await page.getByText(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" })).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.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" })).toBeVisible();
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Next" })).not.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.getByLabel(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
await page.getByText(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
// File Upload Question

View File

@@ -418,6 +418,7 @@ 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
@@ -462,6 +463,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
},
]);
await page.getByLabel("Required").click();
// Rating Question
await page
.locator("div")
@@ -507,6 +510,7 @@ 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
@@ -545,6 +549,7 @@ 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
@@ -573,6 +578,7 @@ 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
@@ -582,6 +588,7 @@ 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
@@ -626,8 +633,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.locator("#action-2-value-input").click();
await page.locator("#action-2-value-input").fill("1");
await page.getByRole("textbox", { name: "Value" }).click();
await page.getByRole("textbox", { name: "Value" }).fill("This ");
// Single Select Question
await page.getByRole("heading", { name: params.singleSelectQuestion.question }).click();

View File

@@ -193,7 +193,6 @@ export const setup = async (
if (environmentStateResponse.ok) {
environmentState = environmentStateResponse.data;
logger.debug(`Fetched ${environmentState.data.surveys.length.toString()} surveys from the backend`);
} else {
logger.error(
`Error fetching environment state: ${environmentStateResponse.error.code} - ${environmentStateResponse.error.responseMessage ?? ""}`
@@ -258,9 +257,7 @@ 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(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`);
} catch {
logger.debug("Error during sync. Please try again.");
}
@@ -306,7 +303,6 @@ export const setup = async (
}
const environmentState = environmentStateResponse.data;
logger.debug(`Fetched ${environmentState.data.surveys.length.toString()} surveys from the backend`);
const filteredSurveys = filterSurveys(environmentState, userState);
config.update({
@@ -316,11 +312,6 @@ 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(", ")}`
);
} catch (e) {
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
}

View File

@@ -109,10 +109,8 @@ 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 className={marginPreservingHeight}>
<ScrollableContainer>
<div className="fb-text-center">
{isResponseSendingFinished ? (
<>
@@ -138,7 +136,7 @@ export function EndingCard({
questionId="EndingCard"
/>
{endingCard.buttonLabel ? (
<div className="fb-mt-4 fb-flex fb-w-full fb-flex-col fb-items-center fb-justify-center fb-space-y-4">
<div className="fb-mt-6 fb-flex fb-w-full fb-flex-col fb-items-center fb-justify-center fb-space-y-4">
<SubmitButton
buttonLabel={replaceRecallInfo(
getLocalizedValue(endingCard.buttonLabel, languageCode),

View File

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

View File

@@ -5,7 +5,7 @@ interface LabelProps {
export function Label({ text, htmlForId }: Readonly<LabelProps>) {
return (
<label htmlFor={htmlForId} className="fb-text-subheading fb-font-normal fb-text-sm fb-block" dir="auto">
<label htmlFor={htmlForId} className="fb-text-subheading fb-font-normal fb-text-sm">
{text}
</label>
);

View File

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

View File

@@ -1,8 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/preact";
// Ensure screen is imported
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Use test consistently
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RenderSurvey } from "./render-survey";
// Stub SurveyContainer to render children and capture props
@@ -23,33 +21,17 @@ 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();
});
test("renders with default props and handles close", () => {
it("renders with default props and handles close", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "question" }] } as any;
@@ -81,7 +63,7 @@ describe("RenderSurvey", () => {
expect(onClose).toHaveBeenCalled();
});
test("onFinished skips close if redirectToUrl", () => {
it("onFinished skips close if redirectToUrl", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [{ id: "e1", type: "redirectToUrl" }] } as any;
@@ -106,7 +88,7 @@ describe("RenderSurvey", () => {
expect(onClose).not.toHaveBeenCalled();
});
test("onFinished closes after delay for non-redirect endings", () => {
it("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;
@@ -133,7 +115,7 @@ describe("RenderSurvey", () => {
expect(onClose).toHaveBeenCalled();
});
test("onFinished does not auto-close when inline mode", () => {
it("onFinished does not auto-close when inline mode", () => {
const onClose = vi.fn();
const onFinished = vi.fn();
const survey = { endings: [] } as any;
@@ -157,49 +139,4 @@ describe("RenderSurvey", () => {
vi.advanceTimersByTime(5000);
expect(onClose).not.toHaveBeenCalled();
});
// New tests for surveyTypeStyles
test("should apply correct styles for link surveys", () => {
const propsForLinkSurvey = {
survey: { type: "link", endings: [] },
styling: {},
isBrandingEnabled: false,
languageCode: "en",
onClose: vi.fn(),
onFinished: vi.fn(),
placement: "bottomRight",
mode: "modal",
} as any;
render(<RenderSurvey {...propsForLinkSurvey} />);
// Manually trigger the ResizeObserver callback if it was captured
if (resizeCallback) {
resizeCallback();
}
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-max-height")).toBe("56dvh");
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-min-height")).toBe("0");
});
test("should apply correct styles for app (non-link) surveys", () => {
const propsForAppSurvey = {
survey: { type: "app", endings: [] },
styling: {},
isBrandingEnabled: false,
languageCode: "en",
onClose: vi.fn(),
onFinished: vi.fn(),
placement: "bottomRight",
mode: "modal",
} as any;
render(<RenderSurvey {...propsForAppSurvey} />);
// Manually trigger the ResizeObserver callback if it was captured
if (resizeCallback) {
resizeCallback();
}
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-max-height")).toBe("40dvh");
expect(document.documentElement.style.getPropertyValue("--fb-survey-card-min-height")).toBe("40dvh");
});
});

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ export function AddressQuestion({
currentQuestionId,
autoFocusEnabled,
isBackButtonHidden,
}: Readonly<AddressQuestionProps>) {
}: 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-mt-4 fb-w-full fb-grid fb-grid-cols-1 fb-gap-3">
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {

View File

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

View File

@@ -40,7 +40,7 @@ export function ConsentQuestion({
currentQuestionId,
autoFocusEnabled,
isBackButtonHidden,
}: Readonly<ConsentQuestionProps>) {
}: ConsentQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;
@@ -48,7 +48,8 @@ export function ConsentQuestion({
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
const consentRef = useCallback(
(currentElement: HTMLButtonElement | null) => {
(currentElement: HTMLLabelElement | null) => {
// will focus on current element when the question ID matches the current question
if (question.id && currentElement && autoFocusEnabled && question.id === currentQuestionId) {
currentElement.focus();
}
@@ -79,14 +80,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-py-2">
<button
type="button"
<div className="fb-bg-survey-bg fb-sticky -fb-bottom-2 fb-z-10 fb-w-full fb-px-1 fb-py-1">
<label
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();
@@ -94,30 +95,28 @@ 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">
<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>
<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>
</div>
</div>
</ScrollableContainer>

View File

@@ -43,7 +43,7 @@ export function ContactInfoQuestion({
currentQuestionId,
autoFocusEnabled,
isBackButtonHidden,
}: Readonly<ContactInfoQuestionProps>) {
}: 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-mt-4 fb-w-full fb-grid fb-grid-cols-1 fb-gap-3">
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {

View File

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

View File

@@ -8,8 +8,9 @@ import { getMonthName, getOrdinalDate } from "@/lib/date-time";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
import DatePicker, { DatePickerProps } from "react-date-picker";
import { useEffect, useMemo, useState } from "preact/hooks";
import DatePicker from "react-date-picker";
import { 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";
@@ -22,12 +23,13 @@ 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() {
@@ -92,8 +94,7 @@ export function DateQuestion({
ttc,
currentQuestionId,
isBackButtonHidden,
autoFocusEnabled,
}: Readonly<DateQuestionProps>) {
}: DateQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState("");
const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -103,15 +104,6 @@ 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());
@@ -178,7 +170,6 @@ export function DateQuestion({
<div className="fb-relative">
{!datePickerOpen && (
<button
ref={datePickerRef}
onClick={() => {
setDatePickerOpen(true);
}}

View File

@@ -14,21 +14,21 @@ import { FileInput } from "../general/file-input";
import { Subheader } from "../general/subheader";
interface FileUploadQuestionProps {
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;
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;
}
export function FileUploadQuestion({
@@ -46,7 +46,7 @@ export function FileUploadQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: Readonly<FileUploadQuestionProps>) {
}: FileUploadQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);

View File

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

View File

@@ -65,7 +65,6 @@ vi.mock("@/lib/utils", () => ({
}
return choices.map((choice: { id: string }) => choice.id);
}),
isRTL: vi.fn((text) => text.includes("rtl")),
}));
describe("MultipleChoiceMultiQuestion", () => {

View File

@@ -6,7 +6,7 @@ import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds, isRTL } from "@/lib/utils";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -41,7 +41,7 @@ export function MultipleChoiceMultiQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: Readonly<MultipleChoiceMultiProps>) {
}: MultipleChoiceMultiProps) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
@@ -140,12 +140,6 @@ export function MultipleChoiceMultiQuestion({
: question.required;
};
const otherOptionDir = useMemo(() => {
const placeholder = getLocalizedValue(question.otherOptionPlaceholder, languageCode);
if (!otherValue) return isRTL(placeholder) ? "rtl" : "ltr";
return "auto";
}, [languageCode, question.otherOptionPlaceholder, otherValue]);
return (
<form
key={question.id}
@@ -273,7 +267,7 @@ export function MultipleChoiceMultiQuestion({
{otherSelected ? (
<input
ref={otherSpecify}
dir={otherOptionDir}
dir="auto"
id={`${otherOption.id}-label`}
maxLength={250}
name={question.id}
@@ -285,9 +279,7 @@ export function MultipleChoiceMultiQuestion({
}}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={
getLocalizedValue(question.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(question.otherOptionPlaceholder, languageCode)
: "Please specify"
getLocalizedValue(question.otherOptionPlaceholder, languageCode) ?? "Please specify"
}
required={question.required}
aria-labelledby={`${otherOption.id}-label`}

View File

@@ -62,7 +62,6 @@ vi.mock("@/lib/ttc", () => ({
vi.mock("@/lib/utils", () => ({
cn: vi.fn((...args) => args.filter(Boolean).join(" ")),
getShuffledChoicesIds: vi.fn((choices) => choices.map((choice: any) => choice.id)),
isRTL: vi.fn((text) => text.includes("rtl")),
}));
// Test data

View File

@@ -6,7 +6,7 @@ import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds, isRTL } from "@/lib/utils";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -41,7 +41,7 @@ export function MultipleChoiceSingleQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: Readonly<MultipleChoiceSingleProps>) {
}: MultipleChoiceSingleProps) {
const [startTime, setStartTime] = useState(performance.now());
const [otherSelected, setOtherSelected] = useState(false);
const otherSpecify = useRef<HTMLInputElement | null>(null);
@@ -100,12 +100,6 @@ export function MultipleChoiceSingleQuestion({
}
}, [otherSelected]);
const otherOptionDir = useMemo(() => {
const placeholder = getLocalizedValue(question.otherOptionPlaceholder, languageCode);
if (!value) return isRTL(placeholder) ? "rtl" : "ltr";
return "auto";
}, [languageCode, question.otherOptionPlaceholder, value]);
return (
<form
key={question.id}
@@ -202,7 +196,7 @@ export function MultipleChoiceSingleQuestion({
document.getElementById(otherOption.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
dir="auto"
@@ -218,7 +212,10 @@ export function MultipleChoiceSingleQuestion({
}}
checked={otherSelected}
/>
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
<span
id={`${otherOption.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
{getLocalizedValue(otherOption.label, languageCode)}
</span>
</span>
@@ -226,7 +223,7 @@ export function MultipleChoiceSingleQuestion({
<input
ref={otherSpecify}
id={`${otherOption.id}-label`}
dir={otherOptionDir}
dir="auto"
name={question.id}
pattern=".*\S+.*"
value={value}

View File

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

View File

@@ -40,7 +40,7 @@ export function NPSQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: Readonly<NPSQuestionProps>) {
}: NPSQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const [hoveredNumber, setHoveredNumber] = useState(-1);
const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -96,15 +96,17 @@ export function NPSQuestion({
<div className="fb-flex">
{Array.from({ length: 11 }, (_, i) => i).map((number, idx) => {
return (
<button
type="button"
<label
key={number}
tabIndex={isCurrent ? 0 : -1}
onMouseOver={() => setHoveredNumber(number)}
onMouseLeave={() => setHoveredNumber(-1)}
onFocus={() => setHoveredNumber(number)}
onBlur={() => setHoveredNumber(-1)}
onMouseOver={() => {
setHoveredNumber(number);
}}
onMouseLeave={() => {
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();
@@ -118,29 +120,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-[41px] fb-leading-10",
: "fb-h fb-leading-10",
hoveredNumber === number ? "fb-bg-accent-bg" : ""
)}>
<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}
{question.isColorCodingEnabled ? (
<div
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getNPSOptionColor(idx)}`}
/>
{number}
</label>
</button>
) : 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>
);
})}
</div>

View File

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

View File

@@ -6,9 +6,8 @@ import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { isRTL } from "@/lib/utils";
import { type RefObject } from "preact";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useEffect, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -20,6 +19,7 @@ interface OpenTextQuestionProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
autoFocus?: boolean;
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
@@ -42,7 +42,7 @@ export function OpenTextQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: Readonly<OpenTextQuestionProps>) {
}: OpenTextQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const [currentLength, setCurrentLength] = useState(value.length || 0);
const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -80,12 +80,6 @@ export function OpenTextQuestion({
onSubmit({ [question.id]: value }, updatedTtc);
};
const dir = useMemo(() => {
const placeholder = getLocalizedValue(question.placeholder, languageCode);
if (!value) return isRTL(placeholder) ? "rtl" : "ltr";
return "auto";
}, [value, languageCode, question.placeholder]);
return (
<form key={question.id} onSubmit={handleOnSubmit} className="fb-w-full">
<ScrollableContainer>
@@ -102,7 +96,7 @@ export function OpenTextQuestion({
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4 fb-text-md">
<div className="fb-mt-4">
{question.longAnswer === false ? (
<input
ref={inputRef as RefObject<HTMLInputElement>}
@@ -111,7 +105,7 @@ export function OpenTextQuestion({
name={question.id}
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir={dir}
dir="auto"
step="any"
required={question.required}
value={value ? value : ""}
@@ -119,7 +113,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"
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"
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}
@@ -134,20 +128,20 @@ export function OpenTextQuestion({
) : (
<textarea
ref={inputRef as RefObject<HTMLTextAreaElement>}
rows={5}
rows={3}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={question.id}
tabIndex={isCurrent ? 0 : -1}
aria-label="textarea"
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir={dir}
dir="auto"
required={question.required}
value={value}
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"
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"
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
maxLength={question.inputType === "text" ? question.charLimit?.max : undefined}

View File

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

View File

@@ -41,15 +41,8 @@ export function PictureSelectionQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
}: Readonly<PictureSelectionProps>) {
}: 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);
@@ -122,72 +115,35 @@ 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-1 sm:fb-grid-cols-2 fb-gap-4">
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-2 fb-gap-4">
{questionChoices.map((choice) => (
<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>
<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"
/>
<a
tabIndex={-1}
href={choice.imageUrl}
@@ -197,25 +153,52 @@ export function PictureSelectionQuestion({
onClick={(e) => {
e.stopPropagation();
}}
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">
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">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
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" />
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" />
</svg>
</a>
</div>
{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>
</fieldset>

View File

@@ -46,7 +46,7 @@ export function RankingQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
}: Readonly<RankingQuestionProps>) {
}: RankingQuestionProps) {
const [startTime, setStartTime] = useState(performance.now());
const isCurrent = question.id === currentQuestionId;
const shuffledChoicesIds = useMemo(() => {
@@ -199,7 +199,7 @@ export function RankingQuestion({
)}>
{(idx + 1).toString()}
</span>
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm fb-text-start" dir="auto">
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm">
{getLocalizedValue(item.label, languageCode)}
</div>
</button>

View File

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

View File

@@ -124,6 +124,28 @@ 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>
@@ -191,52 +213,4 @@ describe("ScrollableContainer", () => {
);
}).not.toThrow();
});
test("applies reduced height when isSurveyPreview is true", () => {
// Create a survey-preview element to make isSurveyPreview true
const previewElement = document.createElement("div");
previewElement.id = "survey-preview";
document.body.appendChild(previewElement);
const { container } = render(
<ScrollableContainer>
<div>Preview Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>("#scrollable-container");
expect(scrollableDiv).toBeInTheDocument();
if (scrollableDiv) {
const computedStyle = scrollableDiv.style;
expect(computedStyle.maxHeight).toBe("calc(var(--fb-survey-card-max-height, 42dvh) * 0.66)");
expect(computedStyle.minHeight).toBe("calc(var(--fb-survey-card-min-height, 42dvh) * 0.66)");
}
// Clean up
document.body.removeChild(previewElement);
});
test("applies normal height when isSurveyPreview is false", () => {
// Ensure no survey-preview element exists
const existingPreview = document.getElementById("survey-preview");
if (existingPreview) {
document.body.removeChild(existingPreview);
}
const { container } = render(
<ScrollableContainer>
<div>Regular Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>("#scrollable-container");
expect(scrollableDiv).toBeInTheDocument();
if (scrollableDiv) {
const computedStyle = scrollableDiv.style;
expect(computedStyle.maxHeight).toBe("calc(var(--fb-survey-card-max-height, 42dvh) * 1)");
expect(computedStyle.minHeight).toBe("calc(var(--fb-survey-card-min-height, 42dvh) * 1)");
}
});
});

View File

@@ -3,23 +3,21 @@ import { useEffect, useRef, useState } from "preact/hooks";
import type { JSX } from "react";
interface ScrollableContainerProps {
className?: string;
children: JSX.Element;
}
export function ScrollableContainer({ className, children }: Readonly<ScrollableContainerProps>) {
export function ScrollableContainer({ children }: 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);
};
@@ -47,18 +45,12 @@ export function ScrollableContainer({ className, children }: Readonly<Scrollable
<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: isMobilePreview
? "30dvh"
: `calc(var(--fb-survey-card-max-height, 42dvh) * ${previewScaleCoifficient})`,
minHeight: isMobilePreview
? "30dvh"
: `calc(var(--fb-survey-card-min-height, 42dvh) * ${previewScaleCoifficient})`,
maxHeight: isSurveyPreview ? "42dvh" : "60dvh",
}}
className={cn(`fb-overflow-auto fb-px-4 fb-pb-4 fb-bg-survey-bg ${className}`)}>
className={cn("fb-overflow-auto fb-px-4 fb-pb-4 fb-bg-survey-bg")}>
{children}
</div>
{!isAtBottom && (

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ export function StackedCardsContainer({
setQuestionId,
shouldResetQuestionId = true,
fullSizeCards = false,
}: Readonly<StackedCardsContainerProps>) {
}: 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("inital");
const [cardHeight, setCardHeight] = useState("auto");
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-start"
className="fb-relative fb-flex fb-h-full fb-items-end fb-justify-center md:fb-items-center"
onMouseEnter={() => {
setHovered(true);
}}
@@ -148,8 +148,7 @@ export function StackedCardsContainer({
</div>
) : (
questionIdxTemp !== undefined &&
[prevQuestionIdx, currentQuestionIdx, nextQuestionIdx, nextQuestionIdx + 1, nextQuestionIdx + 2].map(
// [prevQuestionIdx, currentQuestionIdx, nextQuestionIdx, nextQuestionIdx + 1].map(
[prevQuestionIdx, currentQuestionIdx, nextQuestionIdx, nextQuestionIdx + 1].map(
(dynamicQuestionIndex, index) => {
const hasEndingCard = survey.endings.length > 0;
// Check for hiding extra card

View File

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

View File

@@ -168,12 +168,3 @@ export const getDefaultLanguageCode = (survey: TJsEnvironmentStateSurvey): strin
// Function to convert file extension to its MIME type
export const getMimeType = (extension: TAllowedFileExtension): string => mimeTypes[extension];
/**
* Returns true if the string contains any RTL character.
* @param text The input string to test
*/
export function isRTL(text: string): boolean {
const rtlCharRegex = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
return rtlCharRegex.test(text);
}

1291
packaging-plan-of-action.mdx Normal file

File diff suppressed because it is too large Load Diff

320
packaging-status-quo.mdx Normal file
View File

@@ -0,0 +1,320 @@
# Enterprise Edition Access Control Analysis
## Current Implementation Overview
The system currently has two parallel mechanisms for controlling enterprise features:
### A. Cloud Implementation (Stripe-based)
- Uses Stripe for subscription management
- Plans are defined in the database with hardcoded limits
- Features are controlled based on subscription plans (free, startup, scale, enterprise)
- Key files:
- `apps/web/modules/ee/billing/components/pricing-table.tsx`
- `apps/web/modules/ee/billing/api/lib/subscription-created-or-updated.ts`
- `packages/database/zod/organizations.ts`
#### Default Limits Definition and Usage
The default limits for cloud plans are defined in multiple places and used in different contexts:
1. **Primary Definition (`apps/web/lib/constants.ts`)**
```typescript
export const BILLING_LIMITS = {
FREE: {
PROJECTS: 3,
RESPONSES: 1500,
MIU: 2000,
},
STARTUP: {
PROJECTS: 3,
RESPONSES: 5000,
MIU: 7500,
},
SCALE: {
PROJECTS: 5,
RESPONSES: 10000,
MIU: 30000,
},
} as const;
```
#### Stripe Metadata Handling
The system uses Stripe product metadata to dynamically set limits for organizations. This is handled in several places:
1. **Product Metadata Structure**
- Each Stripe product has metadata fields for:
- `responses`: Number of monthly responses allowed (or "unlimited")
- `miu`: Number of monthly identified users allowed (or "unlimited")
- `projects`: Number of projects allowed (or "unlimited")
- `plan`: The plan type (free, startup, scale, enterprise)
- `period`: Billing period (monthly, yearly)
2. **Subscription Creation/Update Flow**
- When a subscription is created or updated (`subscription-created-or-updated.ts`):
```typescript
// Extract limits from product metadata
if (product.metadata.responses === "unlimited") {
responses = null;
} else if (parseInt(product.metadata.responses) > 0) {
responses = parseInt(product.metadata.responses);
}
// Similar handling for miu and projects
```
- These limits are then stored in the organization's billing object
3. **Checkout Session Handling**
- During checkout (`checkout-session-completed.ts`):
- Metadata is passed from the checkout session to the subscription
- Includes organization ID and limit information
- Updates customer metadata with organization details
4. **Limit Enforcement**
- Limits are checked in various places:
- Response creation (`response.ts`) to send a notification to PostHog. So far we're not doing anything with that information.
- Project creation
- User identification
- When limits are reached:
- Events are sent to PostHog for tracking
- Users are notified of plan limits with a banner at the top of the screen
5. **User Notifications**
- **Limits Reached Banner**
- Shows at the top of the screen when limits are reached
- Displays messages for MIU, response, or both limits
- Links to billing settings
- **Project Limit Modal**
- Appears when trying to create more projects than allowed
- Shows current limit and upgrade options
- **Billing Settings Page**
- Visual indicators for approaching limits
- Upgrade options when limits are reached
- **PostHog Events**
- Events sent when limits are reached
- Cached for 7 days to prevent spam
- **Error Messages**
- Clear error messages for limit violations
- Role permission errors
6. **UI Display of Limits**
- Limits are displayed in the billing settings page (`pricing-table.tsx`):
```typescript
// Unlimited checks for different metrics
const responsesUnlimitedCheck =
organization.billing.plan === "enterprise" &&
organization.billing.limits.monthly.responses === null;
const peopleUnlimitedCheck =
organization.billing.plan === "enterprise" &&
organization.billing.limits.monthly.miu === null;
const projectsUnlimitedCheck =
organization.billing.plan === "enterprise" &&
organization.billing.limits.projects === null;
```
- Uses `BillingSlider` component to show:
- Current usage
- Limit thresholds
- Visual indicators for approaching limits
- Displays different UI states:
- Unlimited badges for enterprise plans
- Warning indicators when approaching limits
- Clear messaging about current plan limits
- Supports both monthly and yearly billing periods
- Shows upgrade options when limits are reached
7. **Error Handling and Fallback Mechanisms**
- **API Error Handling**
- Retries on specific HTTP status codes (429, 502, 503, 504)
- Maximum retry attempts: 3
- Exponential backoff between retries
```typescript
if (retryCount < CONFIG.CACHE.MAX_RETRIES && [429, 502, 503, 504].includes(res.status)) {
await sleep(CONFIG.CACHE.RETRY_DELAY_MS * Math.pow(2, retryCount));
return fetchLicenseFromServerInternal(retryCount + 1);
}
```
- **Fallback Levels**
- "live": Direct API response
- "cached": Using cached license data
- "grace": Using previous valid result within grace period
- "default": Fallback to default limits
```typescript
const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime);
```
- **Grace Period System**
- Cache TTL: 24 hours
- Previous result TTL: 4 days
- Grace period: 3 days
```typescript
const CONFIG = {
CACHE: {
FETCH_LICENSE_TTL_MS: 24 * 60 * 60 * 1000, // 24 hours
PREVIOUS_RESULT_TTL_MS: 4 * 24 * 60 * 60 * 1000, // 4 days
GRACE_PERIOD_MS: 3 * 24 * 60 * 60 * 1000, // 3 days
}
};
```
- **Subscription Error Handling**
- Handles failed subscription updates
- Maintains previous valid state on errors
- Logs errors for debugging
```typescript
try {
await updateOrganization(organizationId, {
billing: {
...organization.billing,
plan: updatedBillingPlan,
limits: {
projects,
monthly: {
responses,
miu,
},
},
},
});
} catch (error) {
logger.error(error, "Failed to update organization billing");
// Maintain previous state
}
```
- **Limit Validation**
- Validates metadata values before applying
- Falls back to default limits if invalid
- Logs validation errors
```typescript
if (product.metadata.responses === "unlimited") {
responses = null;
} else if (parseInt(product.metadata.responses) > 0) {
responses = parseInt(product.metadata.responses);
} else {
logger.error({ responses: product.metadata.responses }, "Invalid responses metadata in product");
throw new Error("Invalid responses metadata in product");
}
```
### B. On-Premise Implementation (License-based)
- Uses a license key system
- Features are controlled through license validation
- Makes API calls to `https://ee.formbricks.com/api/licenses/check`
- Key files:
- `apps/web/modules/ee/license-check/lib/license.ts`
- `apps/web/modules/ee/license-check/lib/utils.ts`
#### License Check Implementation Details
1. **License Validation Flow**
- Validates license key against `ee.formbricks.com/api/licenses/check`
- Includes usage metrics (e.g., response count) in validation request
- Supports proxy configuration for enterprise networks
- Implements timeout and retry logic for API calls
2. **Caching System**
- Uses a multi-level caching strategy:
- Live: Direct API response
- Cached: Using cached license data (24 hours TTL)
- Grace: Using previous valid result (3 days grace period)
- Default: Fallback to default limits
- Cache keys are hashed based on license key for security
3. **Feature Access Control**
- Features are defined in `TEnterpriseLicenseFeatures`:
```typescript
{
isMultiOrgEnabled: boolean,
contacts: boolean,
projects: number | null,
whitelabel: boolean,
removeBranding: boolean,
twoFactorAuth: boolean,
sso: boolean,
saml: boolean,
spamProtection: boolean,
ai: boolean
}
```
4. **Error Handling**
- Implements retry logic for specific HTTP status codes (429, 502, 503, 504)
- Maximum retry attempts: 3
- Exponential backoff between retries
- Grace period system for handling API failures
#### Teams & Access Roles and Multi-language Surveys Implementation
1. **Teams & Access Roles**
- Controlled by both license and billing plan
- Permission check implementation:
```typescript
export const getRoleManagementPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE ||
billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
```
- Access control is implemented through:
- Organization roles (Owner, Manager, Billing, Member)
- Project-level permissions (Read, Read & Write, Manage)
- Team-level roles (Team Contributors, Team Admins)
- Permission checks are performed in:
- Team management actions
- Project access control
- Survey management
- Role updates
2. **Multi-language Surveys**
- Controlled by both license and billing plan
- Permission check implementation:
```typescript
export const getMultiLanguagePermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const license = await getEnterpriseLicense();
if (IS_FORMBRICKS_CLOUD)
return (
license.active &&
(billingPlan === PROJECT_FEATURE_KEYS.SCALE ||
billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
else if (!IS_FORMBRICKS_CLOUD) return license.active;
return false;
};
```
- Checks are performed at multiple levels:
- Survey creation
- Survey updates
- Language management
- Response handling
## Current Issues
1. **Dual System Complexity**
- Different code paths for cloud vs on-premise
- Duplicate feature checks in different places
- Inconsistent feature access patterns
2. **Hardcoded Plans**
- Plans and limits are hardcoded in the database
- Stripe integration is tightly coupled with the application
- Difficult to modify plans without code changes
- Some limits are hardcoded while others come from Stripe metadata
3. **Feature Access Control**
- Features are checked in multiple places with different logic
- No centralized feature management
- Inconsistent handling of feature flags
4. **Error Handling**
- Current implementation has some error handling for license checks
- Uses a fallback system with grace periods
- But could be more robust for API failures

View File

@@ -0,0 +1,271 @@
# Unified Telemetry System Plan
## 1. Core Architecture
### Instance Identification
- **Base Identifier System**
- Use `organizationId` as the primary identifier for all instances
- For Community Edition: Hash the `organizationId` before transmission
- For Enterprise Edition: Use raw `organizationId` for detailed insights
- Store mapping between hashed and raw IDs in a secure database for EE instances
### Architecture Diagram
```mermaid
graph TD
subgraph "Formbricks Instance"
A[Instance Telemetry] -->|1. Collect Metrics| B[Telemetry Collector]
B -->|2. Format Data| C[Instance Telemetry]
C -->|3. Send to License Server| D[EE License Server]
end
subgraph "EE License Server"
D -->|4. Process & Validate| E[License Server Telemetry]
E -->|5. Store Data| F[(Telemetry DB)]
E -->|6. Forward to Analytics| G[PostHog]
end
subgraph "Analytics"
G -->|7. Group by Organization| H[PostHog Groups]
H -->|8. Track Metrics| I[PostHog Analytics]
end
style A fill:#f9f,stroke:#333,stroke-width:2px
style D fill:#bbf,stroke:#333,stroke-width:2px
style G fill:#bfb,stroke:#333,stroke-width:2px
```
### Data Collection Structure
```typescript
interface TelemetryData {
// Anonymous Metrics (Both Editions)
instanceId: string; // Hashed organizationId
alivePing: {
timestamp: string;
version: string;
};
activityMetrics: {
totalResponses: number;
totalUsers: number;
totalDisplays: number;
totalProjects: number;
totalContacts: number;
appSetupComplete: boolean;
};
// Non-Anonymous Metrics (Enterprise Only)
enterpriseMetrics?: {
deploymentUrl: string;
adminEmail?: string; // Only if consented during setup
hashedLicenseKey: string; // For EE license validation
};
}
```
## 2. Implementation Details
### Data Flow Architecture
```typescript
// apps/web/lib/telemetry/instance.ts
export class InstanceTelemetry {
private static instance: InstanceTelemetry;
private isEnterprise: boolean;
private constructor() {
this.isEnterprise = await this.checkEnterpriseStatus();
}
public async sendTelemetry(organizationId: string) {
const metrics = await this.gatherMetrics(organizationId);
// Send to our EE License Server
await fetch('https://license.formbricks.com/api/telemetry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.LICENSE_SERVER_API_KEY}`
},
body: JSON.stringify({
organizationId,
metrics,
timestamp: new Date().toISOString(),
isEnterprise: this.isEnterprise
})
});
}
}
```
### Data Collection Service
```typescript
// apps/web/lib/telemetry/collector.ts
export class TelemetryCollector {
public async collectMetrics(organizationId: string) {
const [
responseCount,
userCount,
displayCount,
projectCount,
contactCount,
appSetupStatus
] = await Promise.all([
this.getResponseCount(organizationId),
this.getUserCount(organizationId),
this.getDisplayCount(organizationId),
this.getProjectCount(organizationId),
this.getContactCount(organizationId),
this.getAppSetupStatus(organizationId)
]);
return {
totalResponses: responseCount,
totalUsers: userCount,
totalDisplays: displayCount,
totalProjects: projectCount,
totalContacts: contactCount,
appSetupComplete: appSetupStatus
};
}
}
```
## 3. Collection Schedule
### Regular Collection Points
1. **Alive Ping**
- Every 24 hours
- Aligned with EE license check
- Includes basic instance health
2. **Activity Metrics**
- Every 6 hours
- Aggregated counts
- No personal data
3. **Enterprise Metrics**
- On significant changes
- License updates
- Admin changes
## 4. Privacy & Security
### Data Handling
- **Anonymous Data**
- All metrics except deployment URL, admin email, and license key
- Aggregated counts only
- No personal identifiers
- **Enterprise Data**
- Stored separately
- Access controlled
- Encrypted at rest
### Consent Management
```typescript
// apps/web/lib/telemetry/consent.ts
export class ConsentManager {
public async checkConsent(organizationId: string) {
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { telemetryConsent: true }
});
return organization?.telemetryConsent ?? false;
}
}
```
## 5. Integration Points
### Alive Ping Integration
```typescript
// apps/web/lib/telemetry/alive-ping.ts
export class AlivePingService {
public async sendAlivePing(organizationId: string) {
const telemetry = new InstanceTelemetry();
await telemetry.sendTelemetry({
organizationId,
alivePing: {
timestamp: new Date().toISOString(),
version: process.env.NEXT_PUBLIC_VERSION
}
});
}
}
```
### License Check Integration
```typescript
// apps/web/modules/ee/license-check/lib/license.ts
export const getEnterpriseLicense = reactCache(
async (): Promise<{
active: boolean;
features: TEnterpriseLicenseFeatures;
limits: YearlyLimit;
}> => {
const license = await fetchLicenseFromServerInternal();
// Track license status through our server
if (license) {
const telemetry = new InstanceTelemetry();
await telemetry.sendTelemetry({
organizationId: env.ORGANIZATION_ID,
enterpriseMetrics: {
hashedLicenseKey: hashString(env.ENTERPRISE_LICENSE_KEY),
deploymentUrl: env.DEPLOYMENT_URL
}
});
}
return license;
}
);
```
## 6. Migration Strategy
### Phase 1: Basic Metrics
- Implement instance telemetry
- Set up EE License Server endpoint
- Add basic activity metrics
### Phase 2: Enterprise Integration
- Add enterprise-specific fields
- Implement consent management
- Set up license tracking
### Phase 3: Validation & Cleanup
- Verify data collection
- Remove old telemetry system
- Update documentation
## 7. Monitoring & Validation
### Health Checks
```typescript
// apps/web/lib/telemetry/health.ts
export class TelemetryHealth {
public async validateCollection() {
const metrics = await this.collectMetrics();
const expectedFields = [
'totalResponses',
'totalUsers',
'totalDisplays',
'totalProjects',
'totalContacts',
'appSetupComplete'
];
return expectedFields.every(field => field in metrics);
}
}
```
This plan provides a focused approach to telemetry that:
1. Sends data through our EE License Server first
2. Collects specific KPIs for both editions
3. Maintains clear separation between anonymous and non-anonymous data
4. Integrates with existing license check logic
5. Provides flexibility to change analytics providers

View File

@@ -0,0 +1,58 @@
## Telemetry Implementation
### Community Edition Telemetry
The Community Edition currently implements basic telemetry through a simple system:
1. **Basic Usage Metrics**
- Anonymous instance identification using hashed CRON_SECRET
- Basic usage statistics:
- Survey count
- Response count
- User count
- Version tracking
- Can be disabled via `TELEMETRY_DISABLED=1` environment variable
2. **Implementation Details**
- Uses a dedicated telemetry endpoint (`telemetry.formbricks.com`)
- Data is collected anonymously
- No personal or customer data is transmitted
- Simple event-based system with minimal properties
3. **Current Limitations**
- Very basic metrics only
- No feature usage tracking
- No error tracking
- No performance metrics
- No user behavior insights
### Enterprise Edition Telemetry
The Enterprise Edition currently has no dedicated telemetry system:
1. **Current State**
- No specific telemetry for enterprise features
- No usage tracking for enterprise features
- No monitoring of license usage patterns
- No insights into feature adoption
2. **Missing Capabilities**
- No tracking of enterprise feature usage
- No monitoring of license validation patterns
- No insights into limit usage and patterns
- No tracking of enterprise-specific errors
- No monitoring of enterprise feature performance
3. **Impact**
- Limited ability to understand enterprise customer needs
- No data to drive enterprise feature development
- No insights into enterprise feature adoption
- Limited ability to proactively address issues
- No data to inform enterprise pricing decisions
This lack of telemetry in the Enterprise Edition represents a significant gap in our ability to understand and improve the product for enterprise customers. It makes it difficult to:
- Track feature adoption and usage patterns
- Identify common issues and pain points
- Make data-driven decisions about feature development
- Provide proactive support
- Understand enterprise customer needs and behaviors