mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-31 19:01:29 -06:00
Compare commits
2 Commits
survey-hei
...
simplify-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d40d4c6770 | ||
|
|
8723e3162e |
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
/*
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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。",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const EditPublicSurveyAlertDialog = ({
|
||||
label: secondaryButtonText,
|
||||
onClick: secondaryButtonAction,
|
||||
disabled: isLoading,
|
||||
variant: "secondary",
|
||||
variant: "outline",
|
||||
});
|
||||
}
|
||||
if (primaryButtonAction) {
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
>(
|
||||
(
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: " " });
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
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
320
packaging-status-quo.mdx
Normal 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
|
||||
|
||||
271
packaging-telemetry-plan.mdx
Normal file
271
packaging-telemetry-plan.mdx
Normal 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
|
||||
58
packaging-telemetry-status-quo.mdx
Normal file
58
packaging-telemetry-status-quo.mdx
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user