Compare commits

..

13 Commits

Author SHA1 Message Date
Anshuman Pandey
ec208960e8 fix: surveys package resize observer issue (#5907)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-29 19:00:28 +00:00
Piyush Gupta
b9505158b4 fix: ciphers issue for fb staging (#5908) 2025-05-29 14:39:20 +00:00
abhishek
ad0c3421f0 fix: alignment issue in file upload (#5828) 2025-05-29 16:40:18 +02:00
Matti Nannt
916c00344b chore: clean up public directory and update cache headers (#5904)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-29 10:46:41 +00:00
Jakob Schott
459cdee17e chore: tweak language select dropdown width (#5878) 2025-05-29 03:54:51 +00:00
Harsh Bhat
bb26a64dbb docs: follow up update (#5601)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-29 03:24:58 +00:00
Harsh Bhat
29a3fa532a docs: RTL support in multi-lang docs (#5898)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-29 03:02:52 +00:00
Harsh Bhat
738b8f9012 docs: android sdk (#5889) 2025-05-29 02:47:26 +00:00
Matti Nannt
c95272288e fix: caching issue in newest next version (#5902) 2025-05-28 21:44:39 +02:00
Piyush Gupta
919febd166 fix: resend verification email translation (#5881) 2025-05-28 09:51:55 +00:00
Dhruwang Jariwala
10ccc20b53 fix: recall not working for NPS question (#5895) 2025-05-28 09:44:55 +00:00
Dhruwang Jariwala
d9ca64da54 fix: favicon warning (#5874)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-28 08:09:51 +00:00
Anshuman Pandey
ce00ec97d1 fix: js-core trackAction bugs (#5843)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-27 17:14:21 +00:00
67 changed files with 1572 additions and 480 deletions

View File

@@ -7,7 +7,8 @@ import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
@@ -175,20 +176,24 @@ export const EditProfileDetailsForm = ({
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label[field.value] ?? "NA"}
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40 bg-slate-50 text-slate-700" align="start">
{appLanguages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => field.onChange(lang.code)}
className="min-h-8 cursor-pointer">
{lang.label[field.value]}
</DropdownMenuItem>
))}
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -59,7 +59,6 @@ describe("endpoint-validator", () => {
describe("isClientSideApiRoute", () => {
test("should return true for client-side API routes", () => {
expect(isClientSideApiRoute("/api/packages/something")).toBe(true);
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/storage")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/other")).toBe(true);

View File

@@ -8,7 +8,6 @@ export const isVerifyEmailRoute = (url: string) => url === "/auth/verify-email";
export const isForgotPasswordRoute = (url: string) => url === "/auth/forgot-password";
export const isClientSideApiRoute = (url: string): boolean => {
if (url.includes("/api/packages/")) return true;
if (url.includes("/api/v1/js/actions")) return true;
if (url.includes("/api/v1/client/storage")) return true;
const regex = /^\/api\/v\d+\/client\//;

View File

@@ -11,6 +11,8 @@ const { PHASE_PRODUCTION_BUILD } = require("next/constants");
// @fortedigital/nextjs-cache-handler dependencies
const createRedisHandler = require("@fortedigital/nextjs-cache-handler/redis-strings").default;
const createBufferStringHandler =
require("@fortedigital/nextjs-cache-handler/buffer-string-decorator").default;
const { Next15CacheHandler } = require("@fortedigital/nextjs-cache-handler/next-15-cache-handler");
// Usual onCreation from @neshca/cache-handler
@@ -85,7 +87,7 @@ CacheHandler.onCreation(() => {
global.cacheHandlerConfigPromise = null;
global.cacheHandlerConfig = {
handlers: [redisCacheHandler],
handlers: [createBufferStringHandler(redisCacheHandler)],
};
return global.cacheHandlerConfig;

View File

@@ -94,7 +94,7 @@
"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",
"resend_verification_email": "Bestätigungs-E-Mail erneut senden",
"verification_email_successfully_sent": "Bestätigungs-E-Mail an {email} gesendet. Bitte überprüfen Sie, um das Update abzuschließen.",
"verification_email_resent_successfully": "Bestätigungs-E-Mail gesendet! Bitte überprüfe dein Postfach.",
"we_sent_an_email_to": "Wir haben eine E-Mail an {email} gesendet",
"you_didnt_receive_an_email_or_your_link_expired": "Hast Du keine E-Mail erhalten oder ist dein Link abgelaufen?"
},

View File

@@ -94,7 +94,7 @@
"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",
"resend_verification_email": "Resend verification email",
"verification_email_successfully_sent": "Verification email sent to {email}. Please verify to complete the update.",
"verification_email_resent_successfully": "Verification email sent! Please check your inbox.",
"we_sent_an_email_to": "We sent an email to {email}. ",
"you_didnt_receive_an_email_or_your_link_expired": "You didn't receive an email or your link expired?"
},

View File

@@ -94,7 +94,7 @@
"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.",
"resend_verification_email": "Renvoyer l'email de vérification",
"verification_email_successfully_sent": "Email de vérification envoyé à {email}. Veuillez vérifier pour compléter la mise à jour.",
"verification_email_resent_successfully": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.",
"we_sent_an_email_to": "Nous avons envoyé un email à {email}",
"you_didnt_receive_an_email_or_your_link_expired": "Vous n'avez pas reçu d'email ou votre lien a expiré ?"
},

View File

@@ -94,7 +94,7 @@
"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",
"resend_verification_email": "Reenviar e-mail de verificação",
"verification_email_successfully_sent": "E-mail de verificação enviado para {email}. Verifique para concluir a atualização.",
"verification_email_resent_successfully": "E-mail de verificação enviado! Por favor, verifique sua caixa de entrada.",
"we_sent_an_email_to": "Enviamos um email para {email}",
"you_didnt_receive_an_email_or_your_link_expired": "Você não recebeu um e-mail ou seu link expirou?"
},

View File

@@ -94,7 +94,7 @@
"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",
"resend_verification_email": "Reenviar email de verificação",
"verification_email_successfully_sent": "Email de verificação enviado para {email}. Por favor, verifique para completar a atualização.",
"verification_email_resent_successfully": "Email de verificação enviado! Por favor, verifique a sua caixa de entrada.",
"we_sent_an_email_to": "Enviámos um email para {email}. ",
"you_didnt_receive_an_email_or_your_link_expired": "Não recebeu um email ou o seu link expirou?"
},

View File

@@ -94,7 +94,7 @@
"please_click_the_link_in_the_email_to_activate_your_account": "請點擊電子郵件中的連結以啟用您的帳戶。",
"please_confirm_your_email_address": "請確認您的電子郵件地址",
"resend_verification_email": "重新發送驗證電子郵件",
"verification_email_successfully_sent": "验证电子邮件已发送至 {email}。请验证以完成更新。",
"verification_email_resent_successfully": "驗證電子郵件已發送!請檢查您的收件箱。",
"we_sent_an_email_to": "我們已發送一封電子郵件至 <email>'{'email'}'</email>。",
"you_didnt_receive_an_email_or_your_link_expired": "您沒有收到電子郵件或您的連結已過期?"
},

View File

@@ -12,8 +12,8 @@ vi.mock("@tolgee/react", () => ({
if (key === "auth.verification-requested.no_email_provided") {
return "No email provided";
}
if (key === "auth.verification-requested.verification_email_successfully_sent") {
return `Verification email sent to ${params?.email}`;
if (key === "auth.verification-requested.verification_email_resent_successfully") {
return `Verification email sent! Please check your inbox.`;
}
if (key === "auth.verification-requested.resend_verification_email") {
return "Resend verification email";
@@ -61,7 +61,7 @@ describe("RequestVerificationEmail", () => {
await fireEvent.click(button);
expect(resendVerificationEmailAction).toHaveBeenCalledWith({ email: mockEmail });
expect(toast.success).toHaveBeenCalledWith(`Verification email sent to ${mockEmail}`);
expect(toast.success).toHaveBeenCalledWith(`Verification email sent! Please check your inbox.`);
});
test("reloads page when visibility changes to visible", () => {

View File

@@ -31,7 +31,7 @@ export const RequestVerificationEmail = ({ email }: RequestVerificationEmailProp
if (!email) return toast.error(t("auth.verification-requested.no_email_provided"));
const response = await resendVerificationEmailAction({ email });
if (response?.data) {
toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email }));
toast.success(t("auth.verification-requested.verification_email_resent_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(response);
toast.error(errorMessage);

View File

@@ -510,7 +510,7 @@ describe("SegmentFilter", () => {
qualifier: {
operator: "greaterThan",
},
value: "10",
value: "hello",
};
const segmentWithArithmeticFilter: TSegment = {
@@ -527,7 +527,7 @@ describe("SegmentFilter", () => {
const currentProps = { ...baseProps, segment: segmentWithArithmeticFilter };
render(<SegmentFilter {...currentProps} connector="and" resource={arithmeticFilterResource} />);
const valueInput = screen.getByDisplayValue("10");
const valueInput = screen.getByDisplayValue("hello");
await userEvent.clear(valueInput);
fireEvent.change(valueInput, { target: { value: "abc" } });
@@ -694,7 +694,7 @@ describe("SegmentFilter", () => {
id: "filter-person-2",
root: { type: "person", personIdentifier: "userId" },
qualifier: { operator: "greaterThan" },
value: "10",
value: "hello",
};
const segmentWithPersonFilterArithmetic: TSegment = {
@@ -715,7 +715,7 @@ describe("SegmentFilter", () => {
resource={personFilterResourceWithArithmeticOperator}
/>
);
const valueInput = screen.getByDisplayValue("10");
const valueInput = screen.getByDisplayValue("hello");
await userEvent.clear(valueInput);
fireEvent.change(valueInput, { target: { value: "abc" } });

View File

@@ -236,7 +236,7 @@ function AttributeSegmentFilter({
setValueError(t("environments.segments.value_must_be_a_number"));
}
}
}, [resource.qualifier, resource.value]);
}, [resource.qualifier, resource.value, t]);
const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
return {
@@ -327,7 +327,7 @@ function AttributeSegmentFilter({
<SelectContent>
{contactAttributeKeys.map((attrClass) => (
<SelectItem key={attrClass.id} value={attrClass.key}>
{attrClass.name}
{attrClass.name ?? attrClass.key}
</SelectItem>
))}
</SelectContent>
@@ -422,7 +422,7 @@ function PersonSegmentFilter({
setValueError(t("environments.segments.value_must_be_a_number"));
}
}
}, [resource.qualifier, resource.value]);
}, [resource.qualifier, resource.value, t]);
const operatorArr = PERSON_OPERATORS.map((operator) => {
return {

View File

@@ -23,7 +23,6 @@ const nextConfig = {
productionBrowserSourceMaps: false,
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
outputFileTracingIncludes: {
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
},
experimental: {},
@@ -189,7 +188,8 @@ const nextConfig = {
headers: [
{
key: "Cache-Control",
value: "public, max-age=3600, s-maxage=604800, stale-while-revalidate=3600, stale-if-error=3600",
value:
"public, max-age=3600, s-maxage=2592000, stale-while-revalidate=3600, stale-if-error=86400",
},
{
key: "Content-Type",
@@ -199,20 +199,151 @@ const nextConfig = {
key: "Access-Control-Allow-Origin",
value: "*",
},
{
key: "Vary",
value: "Accept-Encoding",
},
],
},
// headers for /api/packages/(.*) -- the api route does not exist, but we still need the headers for the rewrites to work correctly!
// Favicon files - long cache since they rarely change
{
source: "/api/packages/(.*)",
source: "/favicon/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=3600, s-maxage=604800, stale-while-revalidate=3600, stale-if-error=3600",
value: "public, max-age=2592000, s-maxage=31536000, immutable",
},
{
key: "Access-Control-Allow-Origin",
value: "*",
},
],
},
// Root favicon.ico - long cache
{
source: "/favicon.ico",
headers: [
{
key: "Cache-Control",
value: "public, max-age=2592000, s-maxage=31536000, immutable",
},
{
key: "Access-Control-Allow-Origin",
value: "*",
},
],
},
// SVG files (icons, logos) - long cache since they're usually static
{
source: "/(.*)\\.svg",
headers: [
{
key: "Cache-Control",
value: "public, max-age=2592000, s-maxage=31536000, immutable",
},
{
key: "Content-Type",
value: "application/javascript; charset=UTF-8",
value: "image/svg+xml",
},
{
key: "Access-Control-Allow-Origin",
value: "*",
},
],
},
// Image backgrounds - medium cache (might update more frequently)
{
source: "/image-backgrounds/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=86400, s-maxage=2592000, stale-while-revalidate=86400",
},
{
key: "Access-Control-Allow-Origin",
value: "*",
},
{
key: "Vary",
value: "Accept-Encoding",
},
],
},
// Video files - long cache since they're large and expensive to transfer
{
source: "/video/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=604800, s-maxage=31536000, stale-while-revalidate=604800",
},
{
key: "Access-Control-Allow-Origin",
value: "*",
},
{
key: "Accept-Ranges",
value: "bytes",
},
],
},
// Animated backgrounds (4K videos) - very long cache since they're large and immutable
{
source: "/animated-bgs/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=604800, s-maxage=31536000, immutable",
},
{
key: "Access-Control-Allow-Origin",
value: "*",
},
{
key: "Accept-Ranges",
value: "bytes",
},
],
},
// CSV templates - shorter cache since they might update with feature changes
{
source: "/sample-csv/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=3600",
},
{
key: "Content-Type",
value: "text/csv",
},
{
key: "Access-Control-Allow-Origin",
value: "*",
},
],
},
// Web manifest and browser config files - medium cache
{
source: "/(site\\.webmanifest|browserconfig\\.xml)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400",
},
{
key: "Access-Control-Allow-Origin",
value: "*",
},
],
},
// Optimize caching for other static assets in public folder (fallback)
{
source: "/(images|fonts|icons)/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, s-maxage=31536000, immutable",
},
{
key: "Access-Control-Allow-Origin",

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="31" fill="none"><g opacity=".9"><path fill="url(#a)" d="M13 .4v29.3H7V6.3h-.2L0 10.5V5L7.2.4H13Z"/><path fill="url(#b)" d="M28.8 30.1c-2.2 0-4-.3-5.7-1-1.7-.8-3-1.8-4-3.1a7.7 7.7 0 0 1-1.4-4.6h6.2c0 .8.3 1.4.7 2 .4.5 1 .9 1.7 1.2.7.3 1.6.4 2.5.4 1 0 1.7-.2 2.5-.5.7-.3 1.3-.8 1.7-1.4.4-.6.6-1.2.6-2s-.2-1.5-.7-2.1c-.4-.6-1-1-1.8-1.4-.8-.4-1.8-.5-2.9-.5h-2.7v-4.6h2.7a6 6 0 0 0 2.5-.5 4 4 0 0 0 1.7-1.3c.4-.6.6-1.3.6-2a3.5 3.5 0 0 0-2-3.3 5.6 5.6 0 0 0-4.5 0 4 4 0 0 0-1.7 1.2c-.4.6-.6 1.2-.6 2h-6c0-1.7.6-3.2 1.5-4.5 1-1.3 2.2-2.3 3.8-3C25 .4 26.8 0 28.8 0s3.8.4 5.3 1.1c1.5.7 2.7 1.7 3.6 3a7.2 7.2 0 0 1 1.2 4.2c0 1.6-.5 3-1.5 4a7 7 0 0 1-4 2.2v.2c2.2.3 3.8 1 5 2.2a6.4 6.4 0 0 1 1.6 4.6c0 1.7-.5 3.1-1.4 4.4a9.7 9.7 0 0 1-4 3.1c-1.7.8-3.7 1.1-5.8 1.1Z"/></g><defs><linearGradient id="a" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient><linearGradient id="b" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Before

Width:  |  Height:  |  Size: 629 B

Binary file not shown.

View File

@@ -91,6 +91,7 @@ export default defineConfig({
"packages/surveys/src/components/general/smileys.tsx", // Smiley components
"modules/analysis/components/SingleResponseCard/components/Smileys.tsx", // Analysis smiley components
"modules/auth/lib/mock-data.ts", // Mock data for authentication
"packages/js-core/src/index.ts", // JS Core index file
// Other
"**/scripts/**", // Utility scripts

View File

@@ -44,4 +44,4 @@ We currently have the following Management API methods exposed and below is thei
---
**Cant figure it out?** Get help in [GitHub Discussions](https://github.com/formbricks/formbricks/discussions).
**Need help?** Reach out in [GitHub Discussions](https://github.com/formbricks/formbricks/discussions).

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -1,74 +1,77 @@
---
title: "Email Follow-ups"
description: "Follow-ups are a feature that allows you to send emails to your users on different survey events."
description: "Automatically send customized emails to respondents based on their survey responses or specific survey endings."
icon: "envelope"
---
## Overview
The email followup feature allows survey creators to automatically send customized emails to respondents based on their survey responses or when they reach specific survey endings. This feature is particularly useful for following up with respondents, sending thank you notes, or providing additional information.
<Note>
Email followups is a paid feature. It is only available for users on paid plans or if you have [Enterprise Edition](/self-hosting/advanced/license).
</Note>
## Key Components
## What are Email Follow-ups?
### 1. Trigger Types
Email followups allow you to automatically send customized emails to respondents based on their survey responses or when they reach specific survey endings. This feature is perfect for:
- Sending thank you notes
- Following up with respondents
- Providing additional information
- Sharing survey response data
There are two types of triggers for email followups:
### Trigger Types
- **Response-based**: Triggered when a response is submitted
- **Ending-based**: Triggered when respondents reach specific survey endings
<Card title="Response-based">
Emails are sent when a response to your survey is completed.
</Card>
### 2. Email Configuration
<Card title="Ending-based">
Emails are triggered when respondents reach specific survey endings.
</Card>
Each followup email can be configured with:
## Setting Up Email Follow-ups
- **Name**: A descriptive name for the followup
- **To**: Email recipient (sourced from):
- Open text questions with email input type
- Contact info questions
- Hidden fields
- **Reply-To**: One or more email addresses for replies
- **Subject**: Email subject line
- **Body**: HTML-formatted email content
<Steps>
<Step title="Go to Follow-ups Section and Create New Follow-up">
Navigate to the survey editor and access the Follow-ups section.
</Step>
## Setup Process
<Step title="Configure Recipients">
The "To" field can be configured to use:
1. Navigate to the survey editor
2. Access the `follow-ups` section
<ul>
<li><strong>Email Questions:</strong> Responses to question type `Open Text` of type `email`</li>
<li><strong>Contact Info:</strong> Responses to question type `Contact`</li>
<li><strong>Hidden Fields:</strong> Values from hidden fields</li>
<li><strong>Team Members:</strong> Members of your team</li>
<li><strong>Yourself:</strong> Your own email address</li>
</ul>
![Followups tab](/images/xm-and-surveys/core-features/email-followups/followups-tab.webp)
<Image src="/images/xm-and-surveys/core-features/email-followups/followup-recipient.webp" alt="Followup recipient configuration" />
</Step>
3. Click the "New follow-up" button to add a new followup
4. Fill in the required information:
<Step title="Set Up Reply-To">
- Add one or more valid email addresses
- Addresses can be added by typing and pressing space or comma
- Invalid email addresses are automatically rejected
</Step>
- Followup name
- Trigger type (response or endings)
<Step title="Configure Email Content">
<Image src="/images/xm-and-surveys/core-features/email-followups/followup-content.webp" alt="Followup content configuration" />
![Followup form](/images/xm-and-surveys/core-features/email-followups/followup-form.webp)
<ul>
<li><strong>Subject:</strong> Customize your email subject line</li>
<li><strong>Body:</strong> Supports basic HTML formatting (`p`, `span`, `b`, `strong`, `i`, `em`, `a`, `br` tags)</li>
<li>
<strong>Survey Response Data:</strong> Option to include detailed response data with support for:
<ul>
<li>File uploads</li>
<li>Images</li>
<li>Rankings</li>
<li>Translations</li>
</ul>
</li>
</ul>
</Step>
5. **Configuring Recipients**:
The "To" field can be configured to use:
- Responses from email-type open text questions
- Responses from contact info questions
- Values from hidden fields
6. **Configure the Reply-To**:
- Add one or more valid email addresses
- Addresses can be added by typing and pressing space or comma
- Invalid email addresses are automatically rejected
![Followup recipient](/images/xm-and-surveys/core-features/email-followups/followup-recipient.webp)
7. **Configuring the Email Content**:
- Subject
- Body: Supports basic HTML formatting (p, span, b, strong, i, em, a, br tags)
![Followup content](/images/xm-and-surveys/core-features/email-followups/followup-content.webp)
8. **Save and Activate**
<Step title="Save to Activate">
Once you've configured all settings, save your survey to activate the email follow-up.
</Step>
</Steps>

View File

@@ -51,4 +51,4 @@ You can export the metadata of your responses along with the response data. When
---
**Cant figure it out?**: [Get help in Github Discussions](https://github.com/formbricks/formbricks/discussions)
**Need help?** [Reach out in Github Discussions](https://github.com/formbricks/formbricks/discussions)

View File

@@ -108,4 +108,31 @@ Without the `lang` parameter, Formbricks will show the survey in the default lan
You can now start collecting responses in multiple languages!
**Cant figure it out?**: [Get help in Github Discussions](https://github.com/formbricks/formbricks/discussions)
---
## RTL Language Support
Formbricks fully supports Right-to-Left (RTL) languages such as Arabic, Hebrew, Persian, and Urdu. When you add an RTL language to your survey, the survey interface automatically adjusts to display content from right to left.
### How RTL Support Works
- Text alignment automatically switches to right-to-left
- Survey layout and UI elements adjust to RTL orientation
- Button placement and navigation flow adapt to RTL reading direction
- Form elements maintain proper RTL formatting
### Setting Up RTL Languages
No additional configuration is needed to enable RTL support. Simply:
1. Add an RTL language (like Arabic or Hebrew) in the **Survey Languages** settings
2. Create translations for your survey content in the RTL language
3. The survey will automatically display in RTL format when that language is selected
![RTL Language Support](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/rtl-support.webp)
---
**Need help?** [Reach out in Github Discussions](https://github.com/formbricks/formbricks/discussions)

View File

@@ -194,4 +194,4 @@ PS: If you do not see any signature settings, just use one of the methods we've
---
**Cant figure it out?**: [Get help in Github Discussions](https://github.com/formbricks/formbricks/discussions)
**Need help?** [Reach out in Github Discussions](https://github.com/formbricks/formbricks/discussions)

View File

@@ -33,6 +33,10 @@ Integrate the **Formbricks App Survey SDK** into your app using multiple options
[Use our iOS SDK to quickly integrate surveys into your iOS applications.](https://formbricks.com/docs/app-surveys/framework-guides#swift)
</Card>
<Card title="Android" icon="android" color="green" href="#android">
[Integrate surveys into your Android applications using our native Kotlin SDK.](https://formbricks.com/docs/app-surveys/framework-guides#android)
</Card>
</CardGroup>
## Prerequisites
@@ -409,6 +413,77 @@ Formbricks.cleanup(waitForOperations: true) {
| environment-id | string | Formbricks Environment ID. |
| app-url | string | URL of the hosted Formbricks instance. |
Now, visit the [Validate Your Setup](#validate-your-setup) section to verify your setup!
## Android
Install the Formbricks Android SDK using the following steps:
### Installation
Add the Maven Central repository and the Formbricks SDK dependency to your application's `build.gradle.kts`:
```kotlin
repositories {
google()
mavenCentral()
}
dependencies {
implementation("com.formbricks:android:1.0.0") // replace with latest version
}
```
Enable DataBinding in your app's module build.gradle.kts:
```kotlin
android {
buildFeatures {
dataBinding = true
}
}
```
### Usage
```kotlin
// 1. Initialize the SDK
val config = FormbricksConfig.Builder(
"https://your-formbricks-server.com",
"YOUR_ENVIRONMENT_ID"
)
.setLoggingEnabled(true)
.setFragmentManager(supportFragmentManager)
.build()
// 2. Setup Formbricks
Formbricks.setup(this, config)
// 3. Identify the user
Formbricks.setUserId("user123")
// 4. Track events
Formbricks.track("button_pressed")
// 5. Set or add user attributes
Formbricks.setAttribute("test@web.com", "email")
Formbricks.setAttributes(mapOf(Pair("attr1", "val1"), Pair("attr2", "val2")))
// 6. Change language (no userId required):
Formbricks.setLanguage("de")
// 7. Log out:
Formbricks.logout()
```
### Required Customizations
| Name | Type | Description |
| -------------- | ------ | -------------------------------------- |
| environment-id | string | Formbricks Environment ID. |
| app-url | string | URL of the hosted Formbricks instance. |
## Validate your setup
Once youve completed the steps above, validate your setup by checking the Setup Checklist in the Settings. The widget status indicator should change from this:
@@ -420,6 +495,10 @@ To this:
## Debugging Formbricks Integration
<Note>
The debug mode is only available in the JavaScript SDK and works exclusively in the browser. It is not supported in mobile SDKs such as React Native, iOS, or Android.
</Note>
Enabling debug mode in your browser can help troubleshoot issues with Formbricks. Heres how to activate it and what to look for in the logs.
### Activate Debug Mode

View File

@@ -375,4 +375,4 @@ And lastly, in the `updateFeedback` function
Something doesnt work? Check your browser console for the error.
**Cant figure it out?**: [Get help in GitHub Discussions](https://github.com/formbricks/formbricks/discussions)
**Need help?** [Reach out in GitHub Discussions](https://github.com/formbricks/formbricks/discussions)

View File

@@ -136,7 +136,7 @@ ingress:
alb.ingress.kubernetes.io/healthcheck-path: /health
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-Res-2021-06
alb.ingress.kubernetes.io/ssl-redirect: "443"
alb.ingress.kubernetes.io/target-type: ip
enabled: true

View File

@@ -1,5 +1,5 @@
/* eslint-disable import/no-default-export -- required for default export*/
import { CommandQueue } from "@/lib/common/command-queue";
import { CommandQueue, CommandType } from "@/lib/common/command-queue";
import * as Setup from "@/lib/common/setup";
import { getIsDebug } from "@/lib/common/utils";
import * as Action from "@/lib/survey/action";
@@ -9,7 +9,7 @@ import * as User from "@/lib/user/user";
import { type TConfigInput, type TLegacyConfigInput } from "@/types/config";
import { type TTrackProperties } from "@/types/survey";
const queue = new CommandQueue();
const queue = CommandQueue.getInstance();
const setup = async (setupConfig: TConfigInput): Promise<void> => {
// If the initConfig has a userId or attributes, we need to use the legacy init
@@ -27,45 +27,41 @@ const setup = async (setupConfig: TConfigInput): Promise<void> => {
// eslint-disable-next-line no-console -- legacy init
console.warn("🧱 Formbricks - Warning: Using legacy init");
}
queue.add(Setup.setup, false, {
await queue.add(Setup.setup, CommandType.Setup, false, {
...setupConfig,
// @ts-expect-error -- apiHost was in the older type
...(setupConfig.apiHost && { appUrl: setupConfig.apiHost as string }),
} as unknown as TConfigInput);
} else {
queue.add(Setup.setup, false, setupConfig);
await queue.wait();
await queue.add(Setup.setup, CommandType.Setup, false, setupConfig);
}
// wait for setup to complete
await queue.wait();
};
const setUserId = async (userId: string): Promise<void> => {
queue.add(User.setUserId, true, userId);
await queue.wait();
await queue.add(User.setUserId, CommandType.UserAction, true, userId);
};
const setEmail = async (email: string): Promise<void> => {
await setAttribute("email", email);
await queue.wait();
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { email });
};
const setAttribute = async (key: string, value: string): Promise<void> => {
queue.add(Attribute.setAttributes, true, { [key]: value });
await queue.wait();
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { [key]: value });
};
const setAttributes = async (attributes: Record<string, string>): Promise<void> => {
queue.add(Attribute.setAttributes, true, attributes);
await queue.wait();
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, attributes);
};
const setLanguage = async (language: string): Promise<void> => {
queue.add(Attribute.setAttributes, true, { language });
await queue.wait();
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { language });
};
const logout = async (): Promise<void> => {
queue.add(User.logout, true);
await queue.wait();
await queue.add(User.logout, CommandType.GeneralAction);
};
/**
@@ -73,13 +69,11 @@ const logout = async (): Promise<void> => {
* @param properties - Optional properties to set, like the hidden fields (deprecated, hidden fields will be removed in a future version)
*/
const track = async (code: string, properties?: TTrackProperties): Promise<void> => {
queue.add<string | TTrackProperties | undefined>(Action.trackCodeAction, true, code, properties);
await queue.wait();
await queue.add(Action.trackCodeAction, CommandType.GeneralAction, true, code, properties);
};
const registerRouteChange = async (): Promise<void> => {
queue.add(checkPageUrl, true);
await queue.wait();
await queue.add(checkPageUrl, CommandType.GeneralAction);
};
const formbricks = {

View File

@@ -1,32 +1,68 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- required for command queue */
/* eslint-disable no-console -- we need to log global errors */
import { checkSetup } from "@/lib/common/setup";
import { checkSetup } from "@/lib/common/status";
import { wrapThrowsAsync } from "@/lib/common/utils";
import type { Result } from "@/types/error";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type Result } from "@/types/error";
export type TCommand = (
...args: any[]
) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>;
export enum CommandType {
Setup,
UserAction,
GeneralAction,
}
interface InternalQueueItem {
command: TCommand;
type: CommandType;
checkSetup: boolean;
commandArgs: any[];
}
export class CommandQueue {
private queue: {
command: TCommand;
checkSetup: boolean;
commandArgs: any[];
}[] = [];
private queue: InternalQueueItem[] = [];
private running = false;
private resolvePromise: (() => void) | null = null;
private commandPromise: Promise<void> | null = null;
private static instance: CommandQueue | null = null;
public add<A>(command: TCommand, shouldCheckSetup = true, ...args: A[]): void {
this.queue.push({ command, checkSetup: shouldCheckSetup, commandArgs: args });
public static getInstance(): CommandQueue {
CommandQueue.instance ??= new CommandQueue();
return CommandQueue.instance;
}
if (!this.running) {
this.commandPromise = new Promise((resolve) => {
this.resolvePromise = resolve;
void this.run();
});
}
public add(
command: TCommand,
type: CommandType,
shouldCheckSetupFlag = true,
...args: any[]
): Promise<Result<void, unknown>> {
return new Promise((addResolve) => {
try {
const newItem: InternalQueueItem = {
command,
type,
checkSetup: shouldCheckSetupFlag,
commandArgs: args,
};
this.queue.push(newItem);
if (!this.running) {
this.commandPromise = new Promise((resolve) => {
this.resolvePromise = resolve;
void this.run();
});
}
addResolve({ ok: true, data: undefined });
} catch (error) {
addResolve({ ok: false, error: error as Error });
}
});
}
public async wait(): Promise<void> {
@@ -37,21 +73,29 @@ export class CommandQueue {
private async run(): Promise<void> {
this.running = true;
while (this.queue.length > 0) {
const currentItem = this.queue.shift();
if (!currentItem) continue;
// make sure formbricks is setup
if (currentItem.checkSetup) {
// call different function based on package type
const setupResult = checkSetup();
if (!setupResult.ok) {
console.warn(`🧱 Formbricks - Setup not complete.`);
continue;
}
}
if (currentItem.type === CommandType.GeneralAction) {
// first check if there are pending updates in the update queue
const updateQueue = UpdateQueue.getInstance();
if (!updateQueue.isEmpty()) {
console.log("🧱 Formbricks - Waiting for pending updates to complete before executing command");
await updateQueue.processUpdates();
}
}
const executeCommand = async (): Promise<Result<void, unknown>> => {
return (await currentItem.command.apply(null, currentItem.commandArgs)) as Result<void, unknown>;
};
@@ -64,6 +108,7 @@ export class CommandQueue {
console.error("🧱 Formbricks - Global error: ", result.data.error);
}
}
this.running = false;
if (this.resolvePromise) {
this.resolvePromise();

View File

@@ -16,10 +16,7 @@ export class Config {
}
static getInstance(): Config {
if (!Config.instance) {
Config.instance = new Config();
}
Config.instance ??= new Config();
return Config.instance;
}

View File

@@ -1,12 +1,9 @@
/* eslint-disable no-console -- required for logging */
import { Config } from "@/lib/common/config";
import { JS_LOCAL_STORAGE_KEY } from "@/lib/common/constants";
import {
addCleanupEventListeners,
addEventListeners,
removeAllEventListeners,
} from "@/lib/common/event-listeners";
import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-listeners";
import { Logger } from "@/lib/common/logger";
import { getIsSetup, setIsSetup } from "@/lib/common/status";
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { checkPageUrl } from "@/lib/survey/no-code-action";
@@ -24,18 +21,11 @@ import {
type MissingFieldError,
type MissingPersonError,
type NetworkError,
type NotSetupError,
type Result,
err,
okVoid,
} from "@/types/error";
let isSetup = false;
export const setIsSetup = (state: boolean): void => {
isSetup = state;
};
const migrateLocalStorage = (): { changed: boolean; newState?: TConfig } => {
const existingConfig = localStorage.getItem(JS_LOCAL_STORAGE_KEY);
@@ -99,7 +89,7 @@ export const setup = async (
}
}
if (isSetup) {
if (getIsSetup()) {
logger.debug("Already set up, skipping setup.");
return okVoid();
}
@@ -258,7 +248,9 @@ export const setup = async (
});
const surveyNames = filteredSurveys.map((s) => s.name);
logger.debug(`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`);
logger.debug(
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
);
} catch {
logger.debug("Error during sync. Please try again.");
}
@@ -314,9 +306,11 @@ export const setup = async (
environment: environmentState,
filteredSurveys,
});
const surveyNames = filteredSurveys.map((s) => s.name);
logger.debug(`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`);
logger.debug(
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
);
} catch (e) {
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
}
@@ -334,35 +328,26 @@ export const setup = async (
return okVoid();
};
export const checkSetup = (): Result<void, NotSetupError> => {
const logger = Logger.getInstance();
logger.debug("Check if set up");
if (!isSetup) {
return err({
code: "not_setup",
message: "Formbricks is not set up. Call setup() first.",
});
}
return okVoid();
};
export const tearDown = (): void => {
const logger = Logger.getInstance();
const appConfig = Config.getInstance();
const { environment } = appConfig.get();
const filteredSurveys = filterSurveys(environment, DEFAULT_USER_STATE_NO_USER_ID);
logger.debug("Setting user state to default");
// clear the user state and set it to the default value
appConfig.update({
...appConfig.get(),
user: DEFAULT_USER_STATE_NO_USER_ID,
filteredSurveys,
});
// remove container element from DOM
removeWidgetContainer();
addWidgetContainer();
setIsSurveyRunning(false);
removeAllEventListeners();
setIsSetup(false);
};
export const handleErrorOnFirstSetup = (e: { code: string; responseMessage: string }): Promise<never> => {

View File

@@ -0,0 +1,26 @@
import { Logger } from "@/lib/common/logger";
import { type NotSetupError, type Result, err, okVoid } from "@/types/error";
let isSetup = false;
export const setIsSetup = (state: boolean): void => {
isSetup = state;
};
export const getIsSetup = (): boolean => {
return isSetup;
};
export const checkSetup = (): Result<void, NotSetupError> => {
const logger = Logger.getInstance();
logger.debug("Check if set up");
if (!isSetup) {
return err({
code: "not_setup",
message: "Formbricks is not set up. Call setup() first.",
});
}
return okVoid();
};

View File

@@ -1,13 +1,24 @@
import { CommandQueue } from "@/lib/common/command-queue";
import { checkSetup } from "@/lib/common/setup";
import { CommandQueue, CommandType } from "@/lib/common/command-queue";
import { checkSetup } from "@/lib/common/status";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type Result } from "@/types/error";
import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock the setup module so we can control checkSetup()
vi.mock("@/lib/common/setup", () => ({
vi.mock("@/lib/common/status", () => ({
checkSetup: vi.fn(),
}));
// Mock the UpdateQueue
vi.mock("@/lib/user/update-queue", () => ({
UpdateQueue: {
getInstance: vi.fn(() => ({
isEmpty: vi.fn(),
processUpdates: vi.fn(),
})),
},
}));
describe("CommandQueue", () => {
let queue: CommandQueue;
@@ -51,9 +62,9 @@ describe("CommandQueue", () => {
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
// Enqueue commands
queue.add(cmdA, true);
queue.add(cmdB, true);
queue.add(cmdC, true);
await queue.add(cmdA, CommandType.GeneralAction, true);
await queue.add(cmdB, CommandType.GeneralAction, true);
await queue.add(cmdC, CommandType.GeneralAction, true);
// Wait for them to finish
await queue.wait();
@@ -79,7 +90,7 @@ describe("CommandQueue", () => {
},
});
queue.add(cmd, true);
await queue.add(cmd, CommandType.GeneralAction, true);
await queue.wait();
// Command should never have been called
@@ -99,7 +110,7 @@ describe("CommandQueue", () => {
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
// Here we pass 'false' for the second argument, so no check is performed
queue.add(cmd, false);
await queue.add(cmd, CommandType.GeneralAction, false);
await queue.wait();
expect(cmd).toHaveBeenCalledTimes(1);
@@ -128,7 +139,7 @@ describe("CommandQueue", () => {
throw new Error("some error");
});
queue.add(failingCmd, true);
await queue.add(failingCmd, CommandType.GeneralAction, true);
await queue.wait();
expect(consoleErrorSpy).toHaveBeenCalledWith("🧱 Formbricks - Global error: ", expect.any(Error));
@@ -153,8 +164,8 @@ describe("CommandQueue", () => {
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
queue.add(cmd1, true);
queue.add(cmd2, true);
await queue.add(cmd1, CommandType.GeneralAction, true);
await queue.add(cmd2, CommandType.GeneralAction, true);
await queue.wait();
@@ -162,4 +173,70 @@ describe("CommandQueue", () => {
expect(cmd1).toHaveBeenCalled();
expect(cmd2).toHaveBeenCalled();
});
test("processes UpdateQueue before executing GeneralAction commands", async () => {
const mockUpdateQueue = {
isEmpty: vi.fn().mockReturnValue(false),
processUpdates: vi.fn().mockResolvedValue("test"),
};
const mockUpdateQueueInstance = vi.spyOn(UpdateQueue, "getInstance");
mockUpdateQueueInstance.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
const generalActionCmd = vi.fn((): Promise<Result<void, unknown>> => {
return Promise.resolve({ ok: true, data: undefined });
});
vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined });
await queue.add(generalActionCmd, CommandType.GeneralAction, true);
await queue.wait();
expect(mockUpdateQueue.isEmpty).toHaveBeenCalled();
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
expect(generalActionCmd).toHaveBeenCalled();
});
test("implements singleton pattern correctly", () => {
const instance1 = CommandQueue.getInstance();
const instance2 = CommandQueue.getInstance();
expect(instance1).toBe(instance2);
});
test("handles multiple commands with different types and setup checks", async () => {
const executionOrder: string[] = [];
const cmd1 = vi.fn((): Promise<Result<void, unknown>> => {
executionOrder.push("cmd1");
return Promise.resolve({ ok: true, data: undefined });
});
const cmd2 = vi.fn((): Promise<Result<void, unknown>> => {
executionOrder.push("cmd2");
return Promise.resolve({ ok: true, data: undefined });
});
const cmd3 = vi.fn((): Promise<Result<void, unknown>> => {
executionOrder.push("cmd3");
return Promise.resolve({ ok: true, data: undefined });
});
// Setup check will fail for cmd2
vi.mocked(checkSetup)
.mockReturnValueOnce({ ok: true, data: undefined }) // for cmd1
.mockReturnValueOnce({ ok: false, error: { code: "not_setup", message: "Not setup" } }) // for cmd2
.mockReturnValueOnce({ ok: true, data: undefined }); // for cmd3
await queue.add(cmd1, CommandType.Setup, true);
await queue.add(cmd2, CommandType.UserAction, true);
await queue.add(cmd3, CommandType.GeneralAction, true);
await queue.wait();
// cmd2 should be skipped due to failed setup check
expect(executionOrder).toEqual(["cmd1", "cmd3"]);
expect(cmd1).toHaveBeenCalled();
expect(cmd2).not.toHaveBeenCalled();
expect(cmd3).toHaveBeenCalled();
});
});

View File

@@ -1,13 +1,10 @@
/* eslint-disable @typescript-eslint/unbound-method -- required for testing */
import { Config } from "@/lib/common/config";
import { JS_LOCAL_STORAGE_KEY } from "@/lib/common/constants";
import {
addCleanupEventListeners,
addEventListeners,
removeAllEventListeners,
} from "@/lib/common/event-listeners";
import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-listeners";
import { Logger } from "@/lib/common/logger";
import { checkSetup, handleErrorOnFirstSetup, setIsSetup, setup, tearDown } from "@/lib/common/setup";
import { handleErrorOnFirstSetup, setup, tearDown } from "@/lib/common/setup";
import { setIsSetup } from "@/lib/common/status";
import { filterSurveys, isNowExpired } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
@@ -287,24 +284,8 @@ describe("setup.ts", () => {
});
});
describe("checkSetup()", () => {
test("returns err if not setup", () => {
const res = checkSetup();
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.code).toBe("not_setup");
}
});
test("returns ok if setup", () => {
setIsSetup(true);
const res = checkSetup();
expect(res.ok).toBe(true);
});
});
describe("tearDown()", () => {
test("resets user state to default and removes event listeners", () => {
test("resets user state to default", () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
user: { data: { userId: "XYZ" } },
@@ -321,7 +302,7 @@ describe("setup.ts", () => {
user: DEFAULT_USER_STATE_NO_USER_ID,
})
);
expect(removeAllEventListeners).toHaveBeenCalled();
expect(filterSurveys).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,41 @@
import { checkSetup, getIsSetup, setIsSetup } from "@/lib/common/status";
import { beforeEach, describe, expect, test, vi } from "vitest";
describe("checkSetup()", () => {
beforeEach(() => {
vi.clearAllMocks();
setIsSetup(false);
});
test("returns err if not setup", () => {
const res = checkSetup();
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.code).toBe("not_setup");
}
});
test("returns ok if setup", () => {
setIsSetup(true);
const res = checkSetup();
expect(res.ok).toBe(true);
});
});
describe("getIsSetup()", () => {
beforeEach(() => {
vi.clearAllMocks();
setIsSetup(false);
});
test("returns false if not setup", () => {
const res = getIsSetup();
expect(res).toBe(false);
});
test("returns true if setup", () => {
setIsSetup(true);
const res = getIsSetup();
expect(res).toBe(true);
});
});

View File

@@ -8,7 +8,9 @@ import {
getDefaultLanguageCode,
getIsDebug,
getLanguageCode,
getSecureRandom,
getStyling,
handleHiddenFields,
handleUrlFilters,
isNowExpired,
shouldDisplayBasedOnPercentage,
@@ -23,7 +25,7 @@ import type {
TSurveyStyling,
TUserState,
} from "@/types/config";
import { type TActionClassPageUrlRule } from "@/types/survey";
import { type TActionClassNoCodeConfig, type TActionClassPageUrlRule } from "@/types/survey";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mockSurveyId1 = "e3kxlpnzmdp84op9qzxl9olj";
@@ -61,7 +63,49 @@ describe("utils.ts", () => {
test("returns ok on success", () => {
const fn = vi.fn(() => "success");
const wrapped = wrapThrows(fn);
expect(wrapped()).toEqual({ ok: true, data: "success" });
const result = wrapped();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe("success");
}
});
test("returns err on error", () => {
const fn = vi.fn(() => {
throw new Error("Something broke");
});
const wrapped = wrapThrows(fn);
const result = wrapped();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe("Something broke");
}
});
test("passes arguments to wrapped function", () => {
const fn = vi.fn((a: number, b: number) => a + b);
const wrapped = wrapThrows(fn);
const result = wrapped(2, 3);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe(5);
}
expect(fn).toHaveBeenCalledWith(2, 3);
});
test("handles async function", () => {
const fn = vi.fn(async () => {
await new Promise((r) => {
setTimeout(r, 10);
});
return "async success";
});
const wrapped = wrapThrows(fn);
const result = wrapped();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBeInstanceOf(Promise);
}
});
});
@@ -561,6 +605,55 @@ describe("utils.ts", () => {
const result = handleUrlFilters(urlFilters);
expect(result).toBe(true);
});
test("returns true if urlFilters is empty", () => {
const urlFilters: TActionClassNoCodeConfig["urlFilters"] = [];
const result = handleUrlFilters(urlFilters);
expect(result).toBe(true);
});
test("returns false if no urlFilters match", () => {
const urlFilters = [
{
value: "https://example.com/other",
rule: "exactMatch" as unknown as TActionClassPageUrlRule,
},
];
// mock window.location.href
vi.stubGlobal("window", {
location: {
href: "https://example.com/path",
},
});
const result = handleUrlFilters(urlFilters);
expect(result).toBe(false);
});
test("returns true if any urlFilter matches", () => {
const urlFilters = [
{
value: "https://example.com/other",
rule: "exactMatch" as unknown as TActionClassPageUrlRule,
},
{
value: "path",
rule: "contains" as unknown as TActionClassPageUrlRule,
},
];
// mock window.location.href
vi.stubGlobal("window", {
location: {
href: "https://example.com/path",
},
});
const result = handleUrlFilters(urlFilters);
expect(result).toBe(true);
});
});
// ---------------------------------------------------------------------------------
@@ -571,12 +664,12 @@ describe("utils.ts", () => {
const targetElement = document.createElement("div");
const action: TEnvironmentStateActionClass = {
id: "clabc123abc", // some valid cuid2 or placeholder
id: "clabc123abc",
name: "Test Action",
type: "noCode", // or "code", but here we have noCode
type: "noCode",
key: null,
noCodeConfig: {
type: "pageView", // the mismatch
type: "pageView",
urlFilters: [],
},
};
@@ -590,7 +683,7 @@ describe("utils.ts", () => {
targetElement.innerHTML = "Test";
const action: TEnvironmentStateActionClass = {
id: "clabc123abc", // some valid cuid2 or placeholder
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
@@ -615,7 +708,7 @@ describe("utils.ts", () => {
targetElement.matches = vi.fn(() => true);
const action: TEnvironmentStateActionClass = {
id: "clabc123abc", // some valid cuid2 or placeholder
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
@@ -640,14 +733,35 @@ describe("utils.ts", () => {
targetElement.matches = vi.fn(() => false);
const action: TEnvironmentStateActionClass = {
id: "clabc123abc", // some valid cuid2 or placeholder
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: { cssSelector },
elementSelector: {
cssSelector,
},
},
};
const result = evaluateNoCodeConfigClick(targetElement, action);
expect(result).toBe(false);
});
test("returns false if neither innerHtml nor cssSelector is provided", () => {
const targetElement = document.createElement("div");
const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: {},
},
};
@@ -657,44 +771,240 @@ describe("utils.ts", () => {
test("returns false if urlFilters do not match", () => {
const targetElement = document.createElement("div");
const urlFilters = [
{
value: "https://example.com/path",
rule: "exactMatch" as unknown as TActionClassPageUrlRule,
targetElement.innerHTML = "Test";
// mock window.location.href
vi.stubGlobal("window", {
location: {
href: "https://example.com/path",
},
];
});
const action: TEnvironmentStateActionClass = {
id: "clabc123abc", // some valid cuid2 or placeholder
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters,
elementSelector: {},
urlFilters: [
{
value: "https://example.com/other",
rule: "exactMatch" as unknown as TActionClassPageUrlRule,
},
],
elementSelector: {
innerHtml: "Test",
},
},
};
const result = evaluateNoCodeConfigClick(targetElement, action);
expect(result).toBe(false);
});
test("returns true if both innerHtml and urlFilters match", () => {
const targetElement = document.createElement("div");
targetElement.innerHTML = "Test";
// mock window.location.href
vi.stubGlobal("window", {
location: {
href: "https://example.com/path",
},
});
const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [
{
value: "path",
rule: "contains" as unknown as TActionClassPageUrlRule,
},
],
elementSelector: {
innerHtml: "Test",
},
},
};
const result = evaluateNoCodeConfigClick(targetElement, action);
expect(result).toBe(true);
});
test("handles multiple cssSelectors correctly", () => {
const targetElement = document.createElement("div");
targetElement.className = "test other";
targetElement.matches = vi.fn((selector) => {
return selector === ".test" || selector === ".other";
});
const action: TEnvironmentStateActionClass = {
id: "clabc123abc",
name: "Test Action",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: {
cssSelector: ".test .other",
},
},
};
const result = evaluateNoCodeConfigClick(targetElement, action);
expect(result).toBe(true);
});
});
// ---------------------------------------------------------------------------------
// getIsDebug
// ---------------------------------------------------------------------------------
describe("getIsDebug()", () => {
test("returns true if debug param is set", () => {
// mock window.location.search
vi.stubGlobal("window", {
location: {
search: "?formbricksDebug=true",
},
beforeEach(() => {
// Reset window.location.search before each test
Object.defineProperty(window, "location", {
value: { search: "" },
writable: true,
});
});
const result = getIsDebug();
expect(result).toBe(true);
test("returns true if debug parameter is set", () => {
Object.defineProperty(window, "location", {
value: { search: "?formbricksDebug=true" },
writable: true,
});
expect(getIsDebug()).toBe(true);
});
test("returns false if debug parameter is not set", () => {
Object.defineProperty(window, "location", {
value: { search: "?otherParam=value" },
writable: true,
});
expect(getIsDebug()).toBe(false);
});
test("returns false if search string is empty", () => {
Object.defineProperty(window, "location", {
value: { search: "" },
writable: true,
});
expect(getIsDebug()).toBe(false);
});
test("returns false if search string is just '?'", () => {
Object.defineProperty(window, "location", {
value: { search: "?" },
writable: true,
});
expect(getIsDebug()).toBe(false);
});
});
// ---------------------------------------------------------------------------------
// handleHiddenFields
// ---------------------------------------------------------------------------------
describe("handleHiddenFields()", () => {
test("returns empty object when hidden fields are not enabled", () => {
const hiddenFieldsConfig = {
enabled: false,
fieldIds: ["field1", "field2"],
};
const hiddenFields = {
field1: "value1",
field2: "value2",
};
const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
expect(result).toEqual({});
});
test("returns empty object when no hidden fields are provided", () => {
const hiddenFieldsConfig = {
enabled: true,
fieldIds: ["field1", "field2"],
};
const result = handleHiddenFields(hiddenFieldsConfig);
expect(result).toEqual({});
});
test("filters and returns only valid hidden fields", () => {
const hiddenFieldsConfig = {
enabled: true,
fieldIds: ["field1", "field2"],
};
const hiddenFields = {
field1: "value1",
field2: "value2",
field3: "value3", // This should be filtered out
};
const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
expect(result).toEqual({
field1: "value1",
field2: "value2",
});
});
test("handles empty fieldIds array", () => {
const hiddenFieldsConfig = {
enabled: true,
fieldIds: [],
};
const hiddenFields = {
field1: "value1",
field2: "value2",
};
const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
expect(result).toEqual({});
});
test("handles null fieldIds", () => {
const hiddenFieldsConfig = {
enabled: true,
fieldIds: undefined,
};
const hiddenFields = {
field1: "value1",
field2: "value2",
};
const result = handleHiddenFields(hiddenFieldsConfig, hiddenFields);
expect(result).toEqual({});
});
});
// ---------------------------------------------------------------------------------
// getSecureRandom
// ---------------------------------------------------------------------------------
describe("getSecureRandom()", () => {
test("returns a number between 0 and 1", () => {
const result = getSecureRandom();
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThan(1);
});
test("returns different values on subsequent calls", () => {
const result1 = getSecureRandom();
const result2 = getSecureRandom();
expect(result1).not.toBe(result2);
});
test("uses crypto.getRandomValues", () => {
const mockGetRandomValues = vi.spyOn(crypto, "getRandomValues");
getSecureRandom();
expect(mockGetRandomValues).toHaveBeenCalled();
mockGetRandomValues.mockRestore();
});
});
});

View File

@@ -121,7 +121,11 @@ export const filterSurveys = (
});
if (!userId) {
return filteredSurveys;
// exclude surveys that have a segment with filters
return filteredSurveys.filter((survey) => {
const segmentFiltersLength = survey.segment?.filters.length ?? 0;
return segmentFiltersLength === 0;
});
}
if (!segments.length) {

View File

@@ -1,12 +1,33 @@
/* eslint-disable no-console -- required for logging */
import { CommandQueue, CommandType } from "@/lib/common/command-queue";
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { TimeoutStack } from "@/lib/common/timeout-stack";
import { evaluateNoCodeConfigClick, handleUrlFilters } from "@/lib/common/utils";
import { trackNoCodeAction } from "@/lib/survey/action";
import { setIsSurveyRunning } from "@/lib/survey/widget";
import { type TEnvironmentStateActionClass } from "@/types/config";
import { type NetworkError, type Result, type ResultError, err, match, okVoid } from "@/types/error";
import { type Result } from "@/types/error";
// Factory for creating context-specific tracking handlers
export const createTrackNoCodeActionWithContext = (context: string) => {
return async (actionName: string): Promise<Result<void, unknown>> => {
const result = await trackNoCodeAction(actionName);
if (!result.ok) {
const errorToLog = result.error as { message?: string };
const errorMessageText = errorToLog.message ?? "An unknown error occurred.";
console.error(
`🧱 Formbricks - Error in no-code ${context} action '${actionName}': ${errorMessageText}`,
errorToLog
);
}
return result;
};
};
const trackNoCodePageViewActionHandler = createTrackNoCodeActionWithContext("page view");
const trackNoCodeClickActionHandler = createTrackNoCodeActionWithContext("click");
const trackNoCodeExitIntentActionHandler = createTrackNoCodeActionWithContext("exit intent");
const trackNoCodeScrollActionHandler = createTrackNoCodeActionWithContext("scroll");
// Event types for various listeners
const events = ["hashchange", "popstate", "pushstate", "replacestate", "load"];
@@ -18,7 +39,8 @@ export const setIsHistoryPatched = (value: boolean): void => {
isHistoryPatched = value;
};
export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
export const checkPageUrl = async (): Promise<Result<void, unknown>> => {
const queue = CommandQueue.getInstance();
const appConfig = Config.getInstance();
const logger = Logger.getInstance();
const timeoutStack = TimeoutStack.getInstance();
@@ -35,11 +57,7 @@ export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
const isValidUrl = handleUrlFilters(urlFilters);
if (isValidUrl) {
const trackResult = await trackNoCodeAction(event.name);
if (!trackResult.ok) {
return err(trackResult.error);
}
await queue.add(trackNoCodePageViewActionHandler, CommandType.GeneralAction, true, event.name);
} else {
const scheduledTimeouts = timeoutStack.getTimeouts();
@@ -52,10 +70,12 @@ export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
}
}
return okVoid();
return { ok: true, data: undefined };
};
const checkPageUrlWrapper = (): ReturnType<typeof checkPageUrl> => checkPageUrl();
const checkPageUrlWrapper = (): void => {
void checkPageUrl();
};
export const addPageUrlEventListeners = (): void => {
if (typeof window === "undefined" || arePageUrlEventListenersAdded) return;
@@ -92,7 +112,8 @@ export const removePageUrlEventListeners = (): void => {
// Click Event Handlers
let isClickEventListenerAdded = false;
const checkClickMatch = (event: MouseEvent): void => {
const checkClickMatch = async (event: MouseEvent): Promise<void> => {
const queue = CommandQueue.getInstance();
const appConfig = Config.getInstance();
const { environment } = appConfig.get();
@@ -105,28 +126,15 @@ const checkClickMatch = (event: MouseEvent): void => {
const targetElement = event.target as HTMLElement;
noCodeClickActionClasses.forEach((action: TEnvironmentStateActionClass) => {
for (const action of noCodeClickActionClasses) {
if (evaluateNoCodeConfigClick(targetElement, action)) {
trackNoCodeAction(action.name)
.then((res) => {
match(
res,
(_value: unknown) => undefined,
(actionError: unknown) => {
// errorHandler.handle(actionError);
console.error(actionError);
}
);
})
.catch((error: unknown) => {
console.error(error);
});
await queue.add(trackNoCodeClickActionHandler, CommandType.GeneralAction, true, action.name);
}
});
}
};
const checkClickMatchWrapper = (e: MouseEvent): void => {
checkClickMatch(e);
void checkClickMatch(e);
};
export const addClickEventListener = (): void => {
@@ -144,7 +152,8 @@ export const removeClickEventListener = (): void => {
// Exit Intent Handlers
let isExitIntentListenerAdded = false;
const checkExitIntent = async (e: MouseEvent): Promise<ResultError<NetworkError> | undefined> => {
const checkExitIntent = async (e: MouseEvent): Promise<void> => {
const queue = CommandQueue.getInstance();
const appConfig = Config.getInstance();
const { environment } = appConfig.get();
@@ -161,13 +170,14 @@ const checkExitIntent = async (e: MouseEvent): Promise<ResultError<NetworkError>
if (!isValidUrl) continue;
const trackResult = await trackNoCodeAction(event.name);
if (!trackResult.ok) return err(trackResult.error);
await queue.add(trackNoCodeExitIntentActionHandler, CommandType.GeneralAction, true, event.name);
}
}
};
const checkExitIntentWrapper = (e: MouseEvent): ReturnType<typeof checkExitIntent> => checkExitIntent(e);
const checkExitIntentWrapper = (e: MouseEvent): void => {
void checkExitIntent(e);
};
export const addExitIntentListener = (): void => {
if (typeof document !== "undefined" && !isExitIntentListenerAdded) {
@@ -189,7 +199,8 @@ export const removeExitIntentListener = (): void => {
let scrollDepthListenerAdded = false;
let scrollDepthTriggered = false;
const checkScrollDepth = async (): Promise<Result<void, unknown>> => {
const checkScrollDepth = async (): Promise<void> => {
const queue = CommandQueue.getInstance();
const appConfig = Config.getInstance();
const scrollPosition = window.scrollY;
@@ -216,15 +227,14 @@ const checkScrollDepth = async (): Promise<Result<void, unknown>> => {
if (!isValidUrl) continue;
const trackResult = await trackNoCodeAction(event.name);
if (!trackResult.ok) return err(trackResult.error);
await queue.add(trackNoCodeScrollActionHandler, CommandType.GeneralAction, true, event.name);
}
}
return okVoid();
};
const checkScrollDepthWrapper = (): ReturnType<typeof checkScrollDepth> => checkScrollDepth();
const checkScrollDepthWrapper = (): void => {
void checkScrollDepth();
};
export const addScrollDepthListener = (): void => {
if (typeof window !== "undefined" && !scrollDepthListenerAdded) {

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock functions are unbound */
import { Config } from "@/lib/common/config";
import { checkSetup } from "@/lib/common/status";
import { TimeoutStack } from "@/lib/common/timeout-stack";
import { handleUrlFilters } from "@/lib/common/utils";
import { trackNoCodeAction } from "@/lib/survey/action";
@@ -9,12 +10,14 @@ import {
addPageUrlEventListeners,
addScrollDepthListener,
checkPageUrl,
createTrackNoCodeActionWithContext,
removeClickEventListener,
removeExitIntentListener,
removePageUrlEventListeners,
removeScrollDepthListener,
} from "@/lib/survey/no-code-action";
import { setIsSurveyRunning } from "@/lib/survey/widget";
import { TActionClassNoCodeConfig } from "@/types/survey";
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/lib/common/config", () => ({
@@ -45,10 +48,15 @@ vi.mock("@/lib/common/timeout-stack", () => ({
},
}));
vi.mock("@/lib/common/utils", () => ({
handleUrlFilters: vi.fn(),
evaluateNoCodeConfigClick: vi.fn(),
}));
vi.mock("@/lib/common/utils", async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- We need this only for type inference
const actual = await importOriginal<typeof import("@/lib/common/utils")>();
return {
...actual,
handleUrlFilters: vi.fn(),
evaluateNoCodeConfigClick: vi.fn(),
};
});
vi.mock("@/lib/survey/action", () => ({
trackNoCodeAction: vi.fn(),
@@ -58,13 +66,53 @@ vi.mock("@/lib/survey/widget", () => ({
setIsSurveyRunning: vi.fn(),
}));
vi.mock("@/lib/common/status", () => ({
checkSetup: vi.fn(),
}));
describe("createTrackNoCodeActionWithContext", () => {
test("should create a trackNoCodeAction with the correct context", () => {
const trackNoCodeActionWithContext = createTrackNoCodeActionWithContext("pageView");
expect(trackNoCodeActionWithContext).toBeDefined();
});
test("should log error if trackNoCodeAction fails", async () => {
const consoleErrorSpy = vi.spyOn(console, "error");
vi.mocked(trackNoCodeAction).mockResolvedValue({
ok: false,
error: {
code: "network_error",
message: "Network error",
status: 500,
url: new URL("https://example.com"),
responseMessage: "Network error",
},
});
const trackNoCodeActionWithContext = createTrackNoCodeActionWithContext("pageView");
expect(trackNoCodeActionWithContext).toBeDefined();
await trackNoCodeActionWithContext("noCodeAction");
expect(consoleErrorSpy).toHaveBeenCalledWith(
`🧱 Formbricks - Error in no-code pageView action 'noCodeAction': Network error`,
{
code: "network_error",
message: "Network error",
status: 500,
url: new URL("https://example.com"),
responseMessage: "Network error",
}
);
});
});
describe("no-code-event-listeners file", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceTimeoutStackMock: MockInstance<() => TimeoutStack>;
beforeEach(() => {
vi.clearAllMocks();
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
getInstanceTimeoutStackMock = vi.spyOn(TimeoutStack, "getInstance");
});
@@ -76,6 +124,7 @@ describe("no-code-event-listeners file", () => {
test("checkPageUrl calls handleUrlFilters & trackNoCodeAction for matching actionClasses", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
(checkSetup as Mock).mockReturnValue({ ok: true });
const mockConfigValue = {
get: vi.fn().mockReturnValue({
@@ -99,11 +148,10 @@ describe("no-code-event-listeners file", () => {
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
const result = await checkPageUrl();
await checkPageUrl();
expect(handleUrlFilters).toHaveBeenCalledWith([{ value: "/some-path", rule: "contains" }]);
expect(trackNoCodeAction).toHaveBeenCalledWith("pageViewAction");
expect(result.ok).toBe(true);
});
test("checkPageUrl removes scheduled timeouts & calls setIsSurveyRunning(false) if invalid url", async () => {
@@ -138,12 +186,11 @@ describe("no-code-event-listeners file", () => {
getInstanceTimeoutStackMock.mockReturnValue(mockTimeoutStack as unknown as TimeoutStack);
const result = await checkPageUrl();
await checkPageUrl();
expect(trackNoCodeAction).not.toHaveBeenCalled();
expect(mockTimeoutStack.remove).toHaveBeenCalledWith(123);
expect(setIsSurveyRunning).toHaveBeenCalledWith(false);
expect(result.ok).toBe(true);
});
test("addPageUrlEventListeners adds event listeners to window, patches history if not patched", () => {
@@ -262,4 +309,347 @@ describe("no-code-event-listeners file", () => {
(window.removeEventListener as Mock).mockRestore();
});
// Test cases for Click Event Handlers
describe("Click Event Handlers", () => {
beforeEach(() => {
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
});
test("addClickEventListener does not add listener if window is undefined", () => {
vi.stubGlobal("window", undefined);
addClickEventListener();
expect(document.addEventListener).not.toHaveBeenCalled();
});
test("addClickEventListener does not re-add listener if already added", () => {
vi.stubGlobal("window", {}); // Ensure window is defined
addClickEventListener(); // First call
expect(document.addEventListener).toHaveBeenCalledTimes(1);
addClickEventListener(); // Second call
expect(document.addEventListener).toHaveBeenCalledTimes(1);
});
});
// Test cases for Exit Intent Handlers
describe("Exit Intent Handlers", () => {
let querySelectorMock: MockInstance;
let addEventListenerMock: Mock;
let removeEventListenerMock: Mock;
beforeEach(() => {
addEventListenerMock = vi.fn();
removeEventListenerMock = vi.fn();
querySelectorMock = vi.fn().mockReturnValue({
addEventListener: addEventListenerMock,
removeEventListener: removeEventListenerMock,
});
vi.stubGlobal("document", {
querySelector: querySelectorMock,
removeEventListener: removeEventListenerMock, // For direct document.removeEventListener calls
});
(handleUrlFilters as Mock).mockReset(); // Reset mock for each test
});
test("addExitIntentListener does not add if document is undefined", () => {
vi.stubGlobal("document", undefined);
addExitIntentListener();
// No explicit expect, passes if no error. querySelector would not be called.
});
test("addExitIntentListener does not add if body is not found", () => {
querySelectorMock.mockReturnValue(null); // body not found
addExitIntentListener();
expect(addEventListenerMock).not.toHaveBeenCalled();
});
test("checkExitIntent does not trigger if clientY > 0", () => {
const mockAction = {
name: "exitAction",
type: "noCode",
noCodeConfig: { type: "exitIntent", urlFilters: [] },
};
const mockConfigValue = {
get: vi.fn().mockReturnValue({
environment: { data: { actionClasses: [mockAction] } },
}),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
(handleUrlFilters as Mock).mockReturnValue(true);
addExitIntentListener();
expect(handleUrlFilters).not.toHaveBeenCalled();
expect(trackNoCodeAction).not.toHaveBeenCalled();
});
});
// Test cases for Scroll Depth Handlers
describe("Scroll Depth Handlers", () => {
let addEventListenerSpy: MockInstance;
let removeEventListenerSpy: MockInstance;
beforeEach(() => {
addEventListenerSpy = vi.fn();
removeEventListenerSpy = vi.fn();
vi.stubGlobal("window", {
addEventListener: addEventListenerSpy,
removeEventListener: removeEventListenerSpy,
scrollY: 0,
innerHeight: 500,
});
vi.stubGlobal("document", {
readyState: "complete",
documentElement: {
scrollHeight: 2000, // bodyHeight > windowSize
},
});
(handleUrlFilters as Mock).mockReset();
(trackNoCodeAction as Mock).mockReset();
// Reset internal state variables (scrollDepthListenerAdded, scrollDepthTriggered)
// This is tricky without exporting them. We can call removeScrollDepthListener
// to reset scrollDepthListenerAdded. scrollDepthTriggered is reset if scrollY is 0.
removeScrollDepthListener(); // Resets scrollDepthListenerAdded
window.scrollY = 0; // Resets scrollDepthTriggered assumption in checkScrollDepth
});
afterEach(() => {
vi.stubGlobal("document", undefined);
});
test("addScrollDepthListener does not add if window is undefined", () => {
vi.stubGlobal("window", undefined);
addScrollDepthListener();
// No explicit expect. Passes if no error.
});
test("addScrollDepthListener does not re-add listener if already added", () => {
addScrollDepthListener(); // First call
expect(window.addEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
expect(window.addEventListener).toHaveBeenCalledTimes(1);
addScrollDepthListener(); // Second call
expect(window.addEventListener).toHaveBeenCalledTimes(1);
});
test("checkScrollDepth does nothing if no fiftyPercentScroll actions", async () => {
const mockConfigValue = {
get: vi.fn().mockReturnValue({
environment: { data: { actionClasses: [] } },
}),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
window.scrollY = 1000; // Past 50%
addScrollDepthListener();
const scrollCallback = addEventListenerSpy.mock.calls[0][1] as () => Promise<void>; // Added type assertion
await scrollCallback();
expect(handleUrlFilters).not.toHaveBeenCalled();
expect(trackNoCodeAction).not.toHaveBeenCalled();
});
test("checkScrollDepth does not trigger if scroll < 50%", async () => {
const mockAction = {
name: "scrollAction",
type: "noCode",
noCodeConfig: { type: "fiftyPercentScroll", urlFilters: [] },
};
const mockConfigValue = {
get: vi.fn().mockReturnValue({
environment: { data: { actionClasses: [mockAction] } },
}),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
(handleUrlFilters as Mock).mockReturnValue(true);
window.scrollY = 200; // scrollPosition / (bodyHeight - windowSize) = 200 / (2000 - 500) = 200 / 1500 < 0.5
addScrollDepthListener();
const scrollCallback = addEventListenerSpy.mock.calls[0][1] as () => Promise<void>; // Added type assertion
await scrollCallback();
expect(trackNoCodeAction).not.toHaveBeenCalled();
});
test("checkScrollDepth filters by URL", async () => {
(handleUrlFilters as Mock).mockImplementation(
(urlFilters: TActionClassNoCodeConfig["urlFilters"]) => urlFilters[0]?.value === "valid-scroll"
);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
const mockActionValid = {
name: "scrollValid",
type: "noCode",
noCodeConfig: { type: "fiftyPercentScroll", urlFilters: [{ value: "valid-scroll" }] },
};
const mockActionInvalid = {
name: "scrollInvalid",
type: "noCode",
noCodeConfig: { type: "fiftyPercentScroll", urlFilters: [{ value: "invalid-scroll" }] },
};
const mockConfigValue = {
get: vi.fn().mockReturnValue({
environment: { data: { actionClasses: [mockActionValid, mockActionInvalid] } },
}),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
window.scrollY = 1000; // Past 50%
addScrollDepthListener();
const scrollCallback = addEventListenerSpy.mock.calls[0][1] as () => Promise<void>; // Added type assertion
await scrollCallback();
expect(trackNoCodeAction).not.toHaveBeenCalledWith("scrollInvalid");
});
});
});
describe("checkPageUrl additional cases", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceTimeoutStackMock: MockInstance<() => TimeoutStack>;
beforeEach(() => {
vi.clearAllMocks();
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
getInstanceTimeoutStackMock = vi.spyOn(TimeoutStack, "getInstance");
});
test("checkPageUrl does nothing if no pageView actionClasses", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
(checkSetup as Mock).mockReturnValue({ ok: true });
const mockConfigValue = {
get: vi.fn().mockReturnValue({
environment: {
data: {
actionClasses: [
{
name: "clickAction", // Not a pageView action
type: "noCode",
noCodeConfig: {
type: "click",
},
},
],
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
vi.stubGlobal("window", { location: { href: "/fail" } });
await checkPageUrl();
expect(handleUrlFilters).not.toHaveBeenCalled();
expect(trackNoCodeAction).not.toHaveBeenCalled();
});
test("checkPageUrl does not remove timeout if not scheduled", async () => {
(handleUrlFilters as Mock).mockReturnValue(false); // Invalid URL
const mockConfigValue = {
get: vi.fn().mockReturnValue({
environment: {
data: {
actionClasses: [
{
name: "pageViewAction",
type: "noCode",
noCodeConfig: {
type: "pageView",
urlFilters: [{ value: "/fail", rule: "contains" }],
},
},
],
},
},
}),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
const mockTimeoutStack = {
getTimeouts: vi.fn().mockReturnValue([]), // No scheduled timeouts
remove: vi.fn(),
add: vi.fn(),
};
getInstanceTimeoutStackMock.mockReturnValue(mockTimeoutStack as unknown as TimeoutStack);
vi.stubGlobal("window", { location: { href: "/fail" } });
await checkPageUrl();
expect(mockTimeoutStack.remove).not.toHaveBeenCalled();
expect(setIsSurveyRunning).not.toHaveBeenCalledWith(false); // Should not be called if timeout was not present
});
});
describe("addPageUrlEventListeners additional cases", () => {
test("addPageUrlEventListeners does not add listeners if window is undefined", () => {
vi.stubGlobal("window", undefined);
addPageUrlEventListeners(); // Call the function
// No explicit expect needed, the test passes if no error is thrown
// and no listeners were attempted to be added to an undefined window.
// We can also assert that isHistoryPatched remains false if it's exported and settable for testing.
// For now, we assume it's an internal detail not directly testable without more mocks.
});
test("addPageUrlEventListeners does not re-add listeners if already added", () => {
const addEventListenerMock = vi.fn();
vi.stubGlobal("window", { addEventListener: addEventListenerMock });
vi.stubGlobal("history", { pushState: vi.fn(), replaceState: vi.fn() });
addPageUrlEventListeners(); // First call
expect(addEventListenerMock).toHaveBeenCalledTimes(5); // hashchange, popstate, pushstate, replacestate, load
addPageUrlEventListeners(); // Second call
expect(addEventListenerMock).toHaveBeenCalledTimes(5); // Should not have been called again
(window.addEventListener as Mock).mockRestore();
});
test("addPageUrlEventListeners does not patch history if already patched", () => {
const addEventListenerMock = vi.fn();
const originalPushState = vi.fn();
vi.stubGlobal("window", { addEventListener: addEventListenerMock, dispatchEvent: vi.fn() });
vi.stubGlobal("history", { pushState: originalPushState, replaceState: vi.fn() });
// Simulate history already patched
// This requires isHistoryPatched to be exported or a way to set it.
// Assuming we can't directly set isHistoryPatched from outside,
// we call it once to patch, then check if pushState is re-assigned.
addPageUrlEventListeners(); // First call, patches history
const patchedPushState = history.pushState;
addPageUrlEventListeners(); // Second call
expect(history.pushState).toBe(patchedPushState); // pushState should not be a new function
// Test patched pushState
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
patchedPushState.apply(history, [{}, "", "/new-url"]);
expect(originalPushState).toHaveBeenCalled();
// expect(dispatchEventSpy).toHaveBeenCalledWith(event);
(window.addEventListener as Mock).mockRestore();
dispatchEventSpy.mockRestore();
});
});
describe("removePageUrlEventListeners additional cases", () => {
test("removePageUrlEventListeners does nothing if window is undefined", () => {
vi.stubGlobal("window", undefined);
removePageUrlEventListeners();
// No explicit expect. Passes if no error.
});
test("removePageUrlEventListeners does nothing if listeners were not added", () => {
const removeEventListenerMock = vi.fn();
vi.stubGlobal("window", { removeEventListener: removeEventListenerMock });
// Assuming listeners are not added yet (arePageUrlEventListenersAdded is false)
removePageUrlEventListeners();
(window.removeEventListener as Mock).mockRestore();
});
});

View File

@@ -116,7 +116,7 @@ describe("user.ts", () => {
});
describe("logout", () => {
test("successfully sets up formbricks after logout", async () => {
test("successfully sets up formbricks after logout", () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: mockEnvironmentId,
@@ -129,40 +129,24 @@ describe("user.ts", () => {
(setup as Mock).mockResolvedValue(undefined);
const result = await logout();
const result = logout();
expect(tearDown).toHaveBeenCalled();
expect(setup).toHaveBeenCalledWith({
environmentId: mockEnvironmentId,
appUrl: mockAppUrl,
});
expect(result.ok).toBe(true);
});
test("returns error if setup fails", async () => {
test("returns error if appConfig.get fails", () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: mockEnvironmentId,
appUrl: mockAppUrl,
user: { data: { userId: mockUserId } },
}),
get: vi.fn().mockReturnValue(null),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
const mockError = { code: "network_error", message: "Failed to connect" };
(setup as Mock).mockRejectedValue(mockError);
const result = logout();
const result = await logout();
expect(tearDown).toHaveBeenCalled();
expect(setup).toHaveBeenCalledWith({
environmentId: mockEnvironmentId,
appUrl: mockAppUrl,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual(mockError);
expect(result.error).toEqual(new Error("Failed to logout"));
}
});
});

View File

@@ -13,9 +13,7 @@ export class UpdateQueue {
private constructor() {}
public static getInstance(): UpdateQueue {
if (!UpdateQueue.instance) {
UpdateQueue.instance = new UpdateQueue();
}
UpdateQueue.instance ??= new UpdateQueue();
return UpdateQueue.instance;
}

View File

@@ -1,8 +1,8 @@
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { setup, tearDown } from "@/lib/common/setup";
import { tearDown } from "@/lib/common/setup";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type ApiErrorResponse, type NetworkError, type Result, err, okVoid } from "@/types/error";
import { type ApiErrorResponse, type Result, err, okVoid } from "@/types/error";
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
export const setUserId = async (userId: string): Promise<Result<void, ApiErrorResponse>> => {
@@ -31,32 +31,22 @@ export const setUserId = async (userId: string): Promise<Result<void, ApiErrorRe
return okVoid();
};
export const logout = async (): Promise<Result<void, NetworkError>> => {
const logger = Logger.getInstance();
const appConfig = Config.getInstance();
const { userId } = appConfig.get().user.data;
if (!userId) {
logger.error("No userId is set, please use the setUserId function to set a userId first");
return okVoid();
}
logger.debug("Resetting state & getting new state from backend");
const initParams = {
environmentId: appConfig.get().environmentId,
appUrl: appConfig.get().appUrl,
};
// logout the user, remove user state and setup formbricks again
tearDown();
export const logout = (): Result<void> => {
try {
await setup(initParams);
const logger = Logger.getInstance();
const appConfig = Config.getInstance();
const { userId } = appConfig.get().user.data;
if (!userId) {
logger.error("No userId is set, please use the setUserId function to set a userId first");
return okVoid();
}
tearDown();
return okVoid();
} catch (e) {
const errorTyped = e as { message?: string };
logger.error(`Failed to setup formbricks after logout: ${errorTyped.message ?? "Unknown error"}`);
return err(e as NetworkError);
} catch {
return { ok: false, error: new Error("Failed to logout") };
}
};

View File

@@ -275,7 +275,7 @@ export function FileInput({
}, [allowedFileExtensions]);
return (
<div className="fb-items-left fb-bg-input-bg hover:fb-bg-input-bg-selected fb-border-border fb-relative fb-mt-3 fb-flex fb-w-full fb-flex-col fb-justify-center fb-rounded-lg fb-border-2 fb-border-dashed dark:fb-border-slate-600 dark:fb-bg-slate-700 dark:hover:fb-border-slate-500 dark:hover:fb-bg-slate-800">
<div className="fb-bg-input-bg hover:fb-bg-input-bg-selected fb-border-border fb-relative fb-mt-3 fb-flex fb-w-full fb-flex-col fb-justify-center fb-items-center fb-rounded-lg fb-border-2 fb-border-dashed dark:fb-border-slate-600 dark:fb-bg-slate-700 dark:hover:fb-border-slate-500 dark:hover:fb-bg-slate-800">
<div ref={parent}>
{fileUrls?.map((fileUrl, index) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);

View File

@@ -399,6 +399,7 @@ describe("StackedCardsContainer", () => {
const resizeCallback = (global.ResizeObserver as any).mock.calls[0][0];
act(() => {
resizeCallback([{ contentRect: { height: 500, width: 300 } }]);
vi.runAllTimers(); // Advance timers after resize callback to handle potential internal delays
});
// Check that cardHeight and cardWidth are passed to StackedCard instances (e.g., next card)

View File

@@ -98,24 +98,36 @@ export function StackedCardsContainer({
// UseEffect to handle the resize of current question card and set cardHeight accordingly
useEffect(() => {
let resizeTimeout: NodeJS.Timeout;
const handleDebouncedResize = (entries: ResizeObserverEntry[]) => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
for (const entry of entries) {
setCardHeight(`${entry.contentRect.height.toString()}px`);
setCardWidth(entry.contentRect.width);
}
}, 50); // 50ms debounce
};
const timer = setTimeout(() => {
const currentElement = cardRefs.current[questionIdxTemp];
if (currentElement) {
if (resizeObserver.current) {
resizeObserver.current.disconnect();
}
resizeObserver.current = new ResizeObserver((entries) => {
for (const entry of entries) {
setCardHeight(`${entry.contentRect.height.toString()}px`);
setCardWidth(entry.contentRect.width);
}
handleDebouncedResize(entries);
});
resizeObserver.current.observe(currentElement);
}
}, 0);
return () => {
resizeObserver.current?.disconnect();
clearTimeout(timer);
clearTimeout(resizeTimeout);
};
}, [questionIdxTemp, cardArrangement, cardRefs]);

View File

@@ -1,49 +1,49 @@
import { describe, expect, it } from "vitest";
import { describe, expect, test } from "vitest";
import { isLight, mixColor } from "./color";
describe("mixColor", () => {
it("should mix a color with white", () => {
test("should mix a color with white", () => {
expect(mixColor("#FF0000", "#FFFFFF", 0.5)).toBe("#ff8080"); // Red mixed with white
expect(mixColor("#0000FF", "#FFFFFF", 0.5)).toBe("#8080ff"); // Blue mixed with white
expect(mixColor("#00FF00", "#FFFFFF", 0.2)).toBe("#33ff33"); // Green mixed with white (less white)
});
it("should mix a color with black", () => {
test("should mix a color with black", () => {
expect(mixColor("#FF0000", "#000000", 0.5)).toBe("#800000"); // Red mixed with black
expect(mixColor("#FFFF00", "#000000", 0.5)).toBe("#808000"); // Yellow mixed with black
});
it("should return the first color if weight is 0", () => {
test("should return the first color if weight is 0", () => {
expect(mixColor("#FF0000", "#00FF00", 0)).toBe("#ff0000");
});
it("should return the second color if weight is 1", () => {
test("should return the second color if weight is 1", () => {
expect(mixColor("#FF0000", "#00FF00", 1)).toBe("#00ff00");
});
it("should handle shorthand hex codes", () => {
test("should handle shorthand hex codes", () => {
expect(mixColor("#F00", "#0F0", 0.5)).toBe("#808000"); // Red and Green
expect(mixColor("#00F", "#FFF", 0.5)).toBe("#8080ff"); // Blue and White
});
it("should handle hex codes without # prefix (implicitly handled by hexToRGBA regex)", () => {
test("should handle hex codes without # prefix (implicitly handled by hexToRGBA regex)", () => {
expect(mixColor("FF0000", "00FF00", 0.5)).toBe("#808000");
});
it("should default to black if one color is invalid/empty", () => {
test("should default to black if one color is invalid/empty", () => {
// hexToRGBA returns "" for invalid, which becomes [0,0,0] in mixColor
expect(mixColor("invalidColor", "#FFFFFF", 0.5)).toBe("#808080"); // invalid (black) mixed with white
expect(mixColor("#FF0000", "", 0.5)).toBe("#800000"); // Red mixed with empty (black)
expect(mixColor("", "", 0.5)).toBe("#000000"); // Both empty (black)
});
it("should mix two distinct colors correctly", () => {
test("should mix two distinct colors correctly", () => {
expect(mixColor("#FF0000", "#0000FF", 0.5)).toBe("#800080"); // Red and Blue -> Purple
});
});
describe("isLight", () => {
it("should return true for light colors", () => {
test("should return true for light colors", () => {
expect(isLight("#FFFFFF")).toBe(true); // White
expect(isLight("#F0F0F0")).toBe(true); // Light gray
expect(isLight("#FFD700")).toBe(true); // Gold
@@ -51,7 +51,7 @@ describe("isLight", () => {
expect(isLight("#ABC")).toBe(true); // Shorthand light color
});
it("should return false for dark colors", () => {
test("should return false for dark colors", () => {
expect(isLight("#000000")).toBe(false); // Black
expect(isLight("#333333")).toBe(false); // Dark gray
expect(isLight("#8B0000")).toBe(false); // Dark red
@@ -59,7 +59,7 @@ describe("isLight", () => {
expect(isLight("#123")).toBe(false); // Shorthand dark color
});
it("should handle colors near the threshold", () => {
test("should handle colors near the threshold", () => {
// Luminance of #808080 (gray) is 128. Formula: r*0.299 + g*0.587 + b*0.114 > 128
// For #808080 (128,128,128): 128*0.299 + 128*0.587 + 128*0.114 = 128*1 = 128. So it's not > 128.
expect(isLight("#808080")).toBe(false); // Gray (threshold case, should be false)
@@ -67,7 +67,7 @@ describe("isLight", () => {
expect(isLight("#7F7F7F")).toBe(false); // Slightly darker than gray
});
it("should throw error for invalid color strings", () => {
test("should throw error for invalid color strings", () => {
expect(() => isLight("#12345")).toThrow("Invalid color");
expect(() => isLight("notacolor")).toThrow("Invalid color");
expect(isLight("#GGHHII")).toBe(false); // Invalid hex characters currently return false

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, test } from "vitest";
import { formatDateWithOrdinal, getMonthName, getOrdinalDate, isValidDateString } from "./date-time";
// Manually define getOrdinalSuffix for testing as it's not exported
@@ -14,42 +14,42 @@ const getOrdinalSuffix = (day: number): string => {
};
describe("getMonthName", () => {
it("should return correct month name for en-US", () => {
test("should return correct month name for en-US", () => {
expect(getMonthName(0)).toBe("January");
expect(getMonthName(6)).toBe("July");
expect(getMonthName(11)).toBe("December");
});
it("should return correct month name for a different locale (es-ES)", () => {
test("should return correct month name for a different locale (es-ES)", () => {
expect(getMonthName(0, "es-ES")).toBe("enero");
expect(getMonthName(6, "es-ES")).toBe("julio");
expect(getMonthName(11, "es-ES")).toBe("diciembre");
});
it("should throw an error for invalid month index", () => {
test("should throw an error for invalid month index", () => {
expect(() => getMonthName(-1)).toThrow("Month index must be between 0 and 11");
expect(() => getMonthName(12)).toThrow("Month index must be between 0 and 11");
});
});
describe("getOrdinalDate", () => {
it('should return date with "st" for 1, 21, 31 (but not 11)', () => {
test('should return date with "st" for 1, 21, 31 (but not 11)', () => {
expect(getOrdinalDate(1)).toBe("1st");
expect(getOrdinalDate(21)).toBe("21st");
expect(getOrdinalDate(31)).toBe("31st");
});
it('should return date with "nd" for 2, 22 (but not 12)', () => {
test('should return date with "nd" for 2, 22 (but not 12)', () => {
expect(getOrdinalDate(2)).toBe("2nd");
expect(getOrdinalDate(22)).toBe("22nd");
});
it('should return date with "rd" for 3, 23 (but not 13)', () => {
test('should return date with "rd" for 3, 23 (but not 13)', () => {
expect(getOrdinalDate(3)).toBe("3rd");
expect(getOrdinalDate(23)).toBe("23rd");
});
it('should return date with "th" for 11, 12, 13 and others', () => {
test('should return date with "th" for 11, 12, 13 and others', () => {
expect(getOrdinalDate(4)).toBe("4th");
expect(getOrdinalDate(11)).toBe("11th");
expect(getOrdinalDate(12)).toBe("12th");
@@ -61,24 +61,24 @@ describe("getOrdinalDate", () => {
});
describe("isValidDateString", () => {
it("should return true for valid YYYY-MM-DD format", () => {
test("should return true for valid YYYY-MM-DD format", () => {
expect(isValidDateString("2023-01-15")).toBe(true);
expect(isValidDateString("2024-02-29")).toBe(true); // Leap year
});
it("should return true for valid DD-MM-YYYY format", () => {
expect(isValidDateString("15-01-2023")).toBe(false);
expect(isValidDateString("29-02-2024")).toBe(false);
test("should return true for valid DD-MM-YYYY format", () => {
expect(isValidDateString("15-01-2023")).toBe(true);
expect(isValidDateString("29-02-2024")).toBe(true);
});
it("should return false for invalid dates in valid format", () => {
test("should return false for invalid dates in valid format", () => {
expect(isValidDateString("2023-02-30")).toBe(true);
expect(isValidDateString("2023-13-01")).toBe(false);
expect(isValidDateString("32-01-2023")).toBe(false);
expect(isValidDateString("01-13-2023")).toBe(true);
expect(isValidDateString("01-13-2023")).toBe(false);
});
it("should return false for invalid formats", () => {
test("should return false for invalid formats", () => {
expect(isValidDateString("2023/01/15")).toBe(false);
expect(isValidDateString("01/15/2023")).toBe(false);
expect(isValidDateString("Jan 15, 2023")).toBe(false);
@@ -89,25 +89,25 @@ describe("isValidDateString", () => {
});
describe("getOrdinalSuffix (helper)", () => {
it('should return "st" for 1, 21, 31', () => {
test('should return "st" for 1, 21, 31', () => {
expect(getOrdinalSuffix(1)).toBe("st");
expect(getOrdinalSuffix(21)).toBe("st");
expect(getOrdinalSuffix(31)).toBe("st");
});
it('should return "nd" for 2, 22', () => {
test('should return "nd" for 2, 22', () => {
expect(getOrdinalSuffix(2)).toBe("nd");
expect(getOrdinalSuffix(22)).toBe("nd");
expect(getOrdinalSuffix(32)).toBe("nd"); // Test for day >= 30 leading to relevantDigits = 2
});
it('should return "rd" for 3, 23', () => {
test('should return "rd" for 3, 23', () => {
expect(getOrdinalSuffix(3)).toBe("rd");
expect(getOrdinalSuffix(23)).toBe("rd");
expect(getOrdinalSuffix(33)).toBe("rd"); // Test for day >= 30 leading to relevantDigits = 3
});
it('should return "th" for 4-20, 24-30, and 11, 12, 13 variants', () => {
test('should return "th" for 4-20, 24-30, and 11, 12, 13 variants', () => {
expect(getOrdinalSuffix(4)).toBe("th");
expect(getOrdinalSuffix(11)).toBe("th");
expect(getOrdinalSuffix(12)).toBe("th");
@@ -121,7 +121,7 @@ describe("getOrdinalSuffix (helper)", () => {
});
describe("formatDateWithOrdinal", () => {
it("should format date correctly for en-US", () => {
test("should format date correctly for en-US", () => {
// Test with a few specific dates
// Monday, January 1st, 2024
const date1 = new Date(2024, 0, 1);
@@ -136,7 +136,7 @@ describe("formatDateWithOrdinal", () => {
expect(formatDateWithOrdinal(date3)).toBe("Sunday, March 13th, 2022");
});
it("should format date correctly for a different locale (fr-FR)", () => {
test("should format date correctly for a different locale (fr-FR)", () => {
const date1 = new Date(2024, 0, 1);
// The exact output depends on Intl and Node version, it might include periods or different capitalization.
// For consistency, we'll check for key parts.
@@ -161,7 +161,7 @@ describe("formatDateWithOrdinal", () => {
expect(formattedDate2).toContain("2023");
});
it("should handle the 1st with French locale (specific check for 1er)", () => {
test("should handle the 1st with French locale (specific check for 1er)", () => {
const date = new Date(2024, 0, 1); // January 1st
// The original getOrdinalSuffix is English-specific. It will produce '1st'.
// A truly internationalized getOrdinalSuffix would be needed for '1er'.
@@ -170,7 +170,7 @@ describe("formatDateWithOrdinal", () => {
expect(formatDateWithOrdinal(date, "fr-FR")).toBe("lundi, janvier 1st, 2024");
});
it("should handle other dates with French locale", () => {
test("should handle other dates with French locale", () => {
const date = new Date(2024, 0, 2); // January 2nd
expect(formatDateWithOrdinal(date, "fr-FR")).toBe("mardi, janvier 2nd, 2024");

View File

@@ -29,6 +29,10 @@ export const isValidDateString = (value: string) => {
return false;
}
if (RegExp(/^\d{2}-\d{2}-\d{4}$/).exec(value) !== null) {
value = value.replace(/(\d{2})-(\d{2})-(\d{4})/, "$3-$2-$1");
}
const date = new Date(value);
return !isNaN(date.getTime());
};

View File

@@ -48,7 +48,7 @@ export const replaceRecallInfo = (
}
// Fetching value from responseData or attributes based on recallItemId
if (responseData[recallItemId]) {
if (responseData[recallItemId] !== undefined) {
value = (responseData[recallItemId] as string) ?? fallback;
}
@@ -62,7 +62,7 @@ export const replaceRecallInfo = (
}
// Replace the recallInfo in the text with the obtained or fallback value
modifiedText = modifiedText.replace(recallInfo, value || fallback);
modifiedText = modifiedText.replace(recallInfo, value?.toString() || fallback);
}
return modifiedText;

View File

@@ -1,66 +1,66 @@
import { describe, expect, it } from "vitest";
import { describe, expect, test } from "vitest";
import { processResponseData } from "./response";
describe("processResponseData", () => {
it("should return the same string if input is a string", () => {
test("should return the same string if input is a string", () => {
expect(processResponseData("hello world")).toBe("hello world");
expect(processResponseData("")).toBe("");
});
it("should convert number to string", () => {
test("should convert number to string", () => {
expect(processResponseData(123)).toBe("123");
expect(processResponseData(0)).toBe("0");
expect(processResponseData(-42.5)).toBe("-42.5");
});
describe("when input is an array", () => {
it('should join array elements with "; "', () => {
test('should join array elements with "; "', () => {
expect(processResponseData(["apple", "banana", "cherry"])).toBe("apple; banana; cherry");
});
it("should filter out null, undefined, and empty strings from array", () => {
test("should filter out null, undefined, and empty strings from array", () => {
expect(processResponseData(["one", null, "two", undefined, "", "three"] as any)).toBe(
"one; two; three"
);
});
it("should return an empty string if array is empty after filtering", () => {
test("should return an empty string if array is empty after filtering", () => {
expect(processResponseData([null, undefined, ""] as any)).toBe("");
});
it("should return an empty string for an empty array", () => {
test("should return an empty string for an empty array", () => {
expect(processResponseData([])).toBe("");
});
it("should handle an array with a single element", () => {
test("should handle an array with a single element", () => {
expect(processResponseData(["single"])).toBe("single");
});
});
describe("when input is an object", () => {
it('should format object entries as "key: value" pairs, joined by newline', () => {
test('should format object entries as "key: value" pairs, joined by newline', () => {
// Assuming Object.entries preserves insertion order for string keys here
expect(processResponseData({ name: "John Doe", age: "30" })).toBe("name: John Doe\nage: 30");
// Test with different order to confirm typical Object.entries behavior
expect(processResponseData({ age: "30", name: "John Doe" })).toBe("age: 30\nname: John Doe");
});
it("should filter out entries with empty string values", () => {
test("should filter out entries with empty string values", () => {
expect(processResponseData({ fruit: "apple", taste: "", color: "red" })).toBe(
"fruit: apple\ncolor: red"
);
});
it("should return an empty string if object is empty after filtering", () => {
test("should return an empty string if object is empty after filtering", () => {
expect(processResponseData({ a: "", b: "" })).toBe("");
});
it("should return an empty string for an empty object", () => {
test("should return an empty string for an empty object", () => {
expect(processResponseData({})).toBe("");
});
});
it("should return an empty string for undefined input (default case)", () => {
test("should return an empty string for undefined input (default case)", () => {
// This tests the default case of the switch statement.
// Need to cast to 'any' to bypass TypeScript's stricter typing for the function signature.
expect(processResponseData(undefined as any)).toBe("");

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getOriginalFileNameFromUrl } from "./storage";
describe("getOriginalFileNameFromUrl", () => {
@@ -16,42 +16,42 @@ describe("getOriginalFileNameFromUrl", () => {
// Test cases for URLs with --fid--
describe("with --fid-- separator", () => {
it("should extract original name from /storage/ path with extension", () => {
test("should extract original name from /storage/ path with extension", () => {
const url = "/storage/My%20Document--fid--123xyz.pdf";
expect(getOriginalFileNameFromUrl(url)).toBe("My Document.pdf");
});
it("should extract original name from full URL with extension", () => {
test("should extract original name from full URL with extension", () => {
const url = "https://example.com/files/Another%20File--fid--abc456.docx";
expect(getOriginalFileNameFromUrl(url)).toBe("Another File.docx");
});
it("should handle filenames with dots before --fid--", () => {
test("should handle filenames with dots before --fid--", () => {
const url = "/storage/archive.version1--fid--qwerty.tar.gz";
expect(getOriginalFileNameFromUrl(url)).toBe("archive.version1.gz");
});
it("should handle missing original filename part", () => {
test("should handle missing original filename part", () => {
const url = "/storage/--fid--789.txt";
expect(getOriginalFileNameFromUrl(url)).toBe("");
});
it("should handle if fileId part is empty but has an extension", () => {
test("should handle if fileId part is empty but has an extension", () => {
const url = "/storage/report-data--fid--.csv";
expect(getOriginalFileNameFromUrl(url)).toBe("report-data.csv");
});
it("should handle if there is no extension after fileId", () => {
test("should handle if there is no extension after fileId", () => {
const url = "/storage/image_file--fid--uniqueid";
expect(getOriginalFileNameFromUrl(url)).toBe("image_file.image_file--fid--uniqueid");
});
it("should handle filename ending with a dot after --fid--", () => {
test("should handle filename ending with a dot after --fid--", () => {
const url = "/storage/reportData--fid--version2.";
expect(getOriginalFileNameFromUrl(url)).toBe("reportData.");
});
it("should handle complex encoded originalFileName with an extension", () => {
test("should handle complex encoded originalFileName with an extension", () => {
const url = "/storage/File%20With%20%25%20Percent--fid--idWith.dot.ext";
expect(getOriginalFileNameFromUrl(url)).toBe("File With % Percent.ext");
});
@@ -59,32 +59,32 @@ describe("getOriginalFileNameFromUrl", () => {
// Test cases for URLs without --fid--
describe("without --fid-- separator", () => {
it("should return decoded name from /storage/ path", () => {
test("should return decoded name from /storage/ path", () => {
const url = "/storage/My%20CV.pdf";
expect(getOriginalFileNameFromUrl(url)).toBe("My CV.pdf");
});
it("should return decoded name from full URL", () => {
test("should return decoded name from full URL", () => {
const url = "https://cdn.example.com/assets/Company%20Logo.png";
expect(getOriginalFileNameFromUrl(url)).toBe("Company Logo.png");
});
it("should handle filenames without extension", () => {
test("should handle filenames without extension", () => {
const url = "/storage/report_final";
expect(getOriginalFileNameFromUrl(url)).toBe("report_final");
});
it("should handle filenames starting with a dot", () => {
test("should handle filenames starting with a dot", () => {
const url = "https://example.com/configs/.env_prod";
expect(getOriginalFileNameFromUrl(url)).toBe(".env_prod");
});
it("should handle filename ending with a dot (no --fid--)", () => {
test("should handle filename ending with a dot (no --fid--)", () => {
const url = "/storage/finalReport.";
expect(getOriginalFileNameFromUrl(url)).toBe("finalReport.");
});
it("should handle complex encoded name with extension-like part", () => {
test("should handle complex encoded name with extension-like part", () => {
const url = "/storage/Another%20Complex%20Name%26Symbols.part1";
expect(getOriginalFileNameFromUrl(url)).toBe("Another Complex Name&Symbols.part1");
});
@@ -92,18 +92,18 @@ describe("getOriginalFileNameFromUrl", () => {
// Edge cases and error handling
describe("edge cases and error handling", () => {
it("should return empty string for an empty URL", () => {
test("should return empty string for an empty URL", () => {
expect(getOriginalFileNameFromUrl("")).toBe("");
expect(consoleErrorSpy).toHaveBeenCalled();
});
it("should return empty string for an invalid URL and log error", () => {
test("should return empty string for an invalid URL and log error", () => {
const url = "this is not a url";
expect(getOriginalFileNameFromUrl(url)).toBe("");
expect(consoleErrorSpy).toHaveBeenCalled();
});
it("should return empty string if URL ends with a slash", () => {
test("should return empty string if URL ends with a slash", () => {
const url = "https://example.com/files/path/";
expect(getOriginalFileNameFromUrl(url)).toBe("");
// Depending on URL parsing, this might or might not log an error.
@@ -111,7 +111,7 @@ describe("getOriginalFileNameFromUrl", () => {
// but .pop() on split by / might yield empty string, thus no error logged from catch.
});
it("should handle URL with query parameters and fragment", () => {
test("should handle URL with query parameters and fragment", () => {
const url = "https://example.com/files/document.pdf?version=2#page10";
expect(getOriginalFileNameFromUrl(url)).toBe("document.pdf");
});

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { type TProjectStyling } from "@formbricks/types/project";
import { type TSurveyStyling } from "@formbricks/types/surveys/types";
import { addCustomThemeToDom, addStylesToDom } from "./styles";
@@ -54,7 +54,7 @@ describe("addStylesToDom", () => {
}
});
it("should add a style element to the head with combined CSS", () => {
test("should add a style element to the head with combined CSS", () => {
addStylesToDom();
const styleElement = document.getElementById("formbricks__css") as HTMLStyleElement;
expect(styleElement).not.toBeNull();
@@ -65,7 +65,7 @@ describe("addStylesToDom", () => {
expect(styleElement.innerHTML).toBe(expectedCss);
});
it("should not add a new style element if one already exists", () => {
test("should not add a new style element if one already exists", () => {
addStylesToDom(); // First call
const firstStyleElement = document.getElementById("formbricks__css");
const initialInnerHTML = firstStyleElement?.innerHTML;
@@ -113,7 +113,7 @@ describe("addCustomThemeToDom", () => {
return variables;
};
it("should add a custom theme style element to the head", () => {
test("should add a custom theme style element to the head", () => {
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
@@ -122,7 +122,7 @@ describe("addCustomThemeToDom", () => {
expect(document.head.contains(styleElement)).toBe(true);
});
it("should reuse existing custom theme style element", () => {
test("should reuse existing custom theme style element", () => {
const styling1 = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
addCustomThemeToDom({ styling: styling1 });
const firstElement = document.getElementById("formbricks__css__custom");
@@ -136,7 +136,7 @@ describe("addCustomThemeToDom", () => {
expect(secondElement).toBe(firstElement);
});
it("should apply minimal styling with brandColor and default roundness", () => {
test("should apply minimal styling with brandColor and default roundness", () => {
const styling = getBaseProjectStyling({ brandColor: { light: "#0000FF" } }); // A dark color, roundness will use default
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
@@ -148,7 +148,7 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-border-radius"]).toBe("8px"); // Default roundness
});
it("should apply brand-text-color as black for light brandColor", () => {
test("should apply brand-text-color as black for light brandColor", () => {
const styling = getBaseProjectStyling({ brandColor: { light: "#FFFF00" } }); // A light color
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
@@ -156,7 +156,7 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-brand-text-color"]).toBe("black"); // isLight('#FFFF00') is true
});
it("should default brand-text-color to white if brandColor is undefined", () => {
test("should default brand-text-color to white if brandColor is undefined", () => {
const styling = getBaseProjectStyling({ brandColor: null }); // Explicitly null brandColor
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
@@ -164,7 +164,7 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-brand-text-color"]).toBe("#ffffff");
});
it("should apply all survey styling properties", () => {
test("should apply all survey styling properties", () => {
const styling: TSurveyStyling = {
brandColor: { light: "#112233" },
questionColor: { light: "#AABBCC" },
@@ -205,7 +205,7 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-calendar-tile-color"]).toBeUndefined(); // isLight('#112233') is false, so this should be undefined
});
it("should set signature and branding text colors for dark questionColor", () => {
test("should set signature and branding text colors for dark questionColor", () => {
const styling = getBaseProjectStyling({
questionColor: { light: "#202020" }, // A dark color
brandColor: { light: "#123456" }, // brandColor needed for some default fallbacks if not directly testing them
@@ -219,7 +219,7 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-branding-text-color"]).toBeDefined();
});
it("should handle roundness 0 correctly", () => {
test("should handle roundness 0 correctly", () => {
const styling = getBaseProjectStyling({ roundness: 0, brandColor: { light: "#123456" } });
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
@@ -227,7 +227,7 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-border-radius"]).toBe("0px");
});
it("should set input-background-color-selected to slate-50 for white inputColor", () => {
test("should set input-background-color-selected to slate-50 for white inputColor", () => {
const whiteColors = ["#fff", "#ffffff", "white"];
whiteColors.forEach((color) => {
const styling = getBaseProjectStyling({ inputColor: { light: color }, brandColor: { light: "#123" } });
@@ -238,7 +238,7 @@ describe("addCustomThemeToDom", () => {
});
});
it("should mix input-background-color-selected for non-white inputColor", () => {
test("should mix input-background-color-selected for non-white inputColor", () => {
const styling = getBaseProjectStyling({
inputColor: { light: "#E0E0E0" },
brandColor: { light: "#123" },
@@ -252,7 +252,7 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-input-background-color-selected"]).not.toBe("var(--slate-50)");
});
it("should not set calendar-tile-color if brandColor is undefined", () => {
test("should not set calendar-tile-color if brandColor is undefined", () => {
const styling = getBaseProjectStyling({ brandColor: null });
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
@@ -260,7 +260,7 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-calendar-tile-color"]).toBeUndefined();
});
it("should not define variables for undefined styling properties", () => {
test("should not define variables for undefined styling properties", () => {
const styling = getBaseProjectStyling({ brandColor: { light: "#ABC" } }); // Only brandColor is defined
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
@@ -275,7 +275,7 @@ describe("addCustomThemeToDom", () => {
});
describe("getBaseProjectStyling_Helper", () => {
it("should return default values for all properties when no overrides are provided", () => {
test("should return default values for all properties when no overrides are provided", () => {
const baseStyling = getBaseProjectStyling();
expect(baseStyling.brandColor).toBeNull();
expect(baseStyling.cardBackgroundColor).toBeNull();
@@ -293,7 +293,7 @@ describe("getBaseProjectStyling_Helper", () => {
expect(baseStyling.isLogoHidden).toBeNull();
});
it("should correctly apply overrides to specified properties", () => {
test("should correctly apply overrides to specified properties", () => {
const overrides: Partial<TProjectStyling> = {
inputColor: { light: "#111" },
inputBorderColor: { light: "#222" },

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, test } from "vitest";
import { TResponseUpdate } from "@formbricks/types/responses";
import { SurveyState } from "./survey-state";
@@ -10,7 +10,7 @@ describe("SurveyState", () => {
surveyState = new SurveyState(initialSurveyId);
});
it("should initialize with a surveyId and default values", () => {
test("should initialize with a surveyId and default values", () => {
expect(surveyState.surveyId).toBe(initialSurveyId);
expect(surveyState.responseId).toBeNull();
expect(surveyState.displayId).toBeNull();
@@ -20,7 +20,7 @@ describe("SurveyState", () => {
expect(surveyState.responseAcc).toEqual({ finished: false, data: {}, ttc: {}, variables: {} });
});
it("should initialize with all optional parameters", () => {
test("should initialize with all optional parameters", () => {
const singleUseId = "singleUse123";
const responseId = "response123";
const userId = "user123";
@@ -34,7 +34,7 @@ describe("SurveyState", () => {
});
describe("setSurveyId", () => {
it("should update surveyId and clear the state", () => {
test("should update surveyId and clear the state", () => {
surveyState.responseId = "res1";
surveyState.responseAcc = { finished: true, data: { q1: "ans1" }, ttc: { q1: 100 }, variables: {} };
const newSurveyId = "survey2";
@@ -46,7 +46,7 @@ describe("SurveyState", () => {
});
describe("copy", () => {
it("should create a deep copy of the survey state", () => {
test("should create a deep copy of the survey state", () => {
surveyState.responseId = "res123";
surveyState.responseAcc = {
finished: true,
@@ -74,7 +74,7 @@ describe("SurveyState", () => {
expect(copiedState.responseAcc.variables).toBe(surveyState.responseAcc.variables);
});
it("should correctly copy when optional IDs are null", () => {
test("should correctly copy when optional IDs are null", () => {
// surveyState is in its initial state from beforeEach,
// where singleUseId, responseId, userId, contactId are null.
const copiedState = surveyState.copy();
@@ -91,32 +91,32 @@ describe("SurveyState", () => {
});
});
it("should update responseId", () => {
test("should update responseId", () => {
const newResponseId = "res456";
surveyState.updateResponseId(newResponseId);
expect(surveyState.responseId).toBe(newResponseId);
});
it("should update displayId", () => {
test("should update displayId", () => {
const newDisplayId = "disp789";
surveyState.updateDisplayId(newDisplayId);
expect(surveyState.displayId).toBe(newDisplayId);
});
it("should update userId", () => {
test("should update userId", () => {
const newUserId = "userXYZ";
surveyState.updateUserId(newUserId);
expect(surveyState.userId).toBe(newUserId);
});
it("should update contactId", () => {
test("should update contactId", () => {
const newContactId = "contactABC";
surveyState.updateContactId(newContactId);
expect(surveyState.contactId).toBe(newContactId);
});
describe("accumulateResponse", () => {
it("should accumulate response data correctly", () => {
test("should accumulate response data correctly", () => {
const initialResponse: TResponseUpdate = {
finished: false,
data: { q1: "ans1" },
@@ -144,19 +144,19 @@ describe("SurveyState", () => {
});
describe("isResponseFinished", () => {
it("should return true if responseAcc.finished is true", () => {
test("should return true if responseAcc.finished is true", () => {
surveyState.responseAcc.finished = true;
expect(surveyState.isResponseFinished()).toBe(true);
});
it("should return false if responseAcc.finished is false", () => {
test("should return false if responseAcc.finished is false", () => {
surveyState.responseAcc.finished = false;
expect(surveyState.isResponseFinished()).toBe(false);
});
});
describe("clear", () => {
it("should reset responseId and responseAcc", () => {
test("should reset responseId and responseAcc", () => {
surveyState.responseId = "someId";
surveyState.responseAcc = { finished: true, data: { q: "a" }, ttc: { q: 1 }, variables: { v: "1" } };
surveyState.clear();

View File

@@ -1,24 +1,24 @@
import { act, renderHook } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseTtc } from "@formbricks/types/responses";
import { TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getUpdatedTtc, useTtc } from "./ttc";
describe("getUpdatedTtc", () => {
it("should add time to an existing questionId", () => {
test("should add time to an existing questionId", () => {
const ttc: TResponseTtc = { q1: 1000 };
const updatedTtc = getUpdatedTtc(ttc, "q1", 500);
expect(updatedTtc.q1).toBe(1500);
});
it("should add a new questionId and time if it does not exist", () => {
test("should add a new questionId and time if it does not exist", () => {
const ttc: TResponseTtc = { q1: 1000 };
const updatedTtc = getUpdatedTtc(ttc, "q2", 500);
expect(updatedTtc.q2).toBe(500);
expect(updatedTtc.q1).toBe(1000); // Ensure existing entries are preserved
});
it("should return a new object and not mutate the original", () => {
test("should return a new object and not mutate the original", () => {
const ttc: TResponseTtc = { q1: 1000 };
const updatedTtc = getUpdatedTtc(ttc, "q1", 500);
expect(updatedTtc).not.toBe(ttc);
@@ -66,7 +66,7 @@ describe("useTtc", () => {
});
});
it("should set initial start time if isCurrentQuestion is true", () => {
test("should set initial start time if isCurrentQuestion is true", () => {
renderHook(() =>
useTtc(
initialProps.questionId,
@@ -80,7 +80,7 @@ describe("useTtc", () => {
expect(mockSetStartTime).toHaveBeenCalledWith(currentTime);
});
it("should not set initial start time if isCurrentQuestion is false", () => {
test("should not set initial start time if isCurrentQuestion is false", () => {
renderHook(() =>
useTtc(
initialProps.questionId,
@@ -94,7 +94,7 @@ describe("useTtc", () => {
expect(mockSetStartTime).not.toHaveBeenCalled();
});
it("should add event listener if isCurrentQuestion is true", () => {
test("should add event listener if isCurrentQuestion is true", () => {
renderHook(() =>
useTtc(
initialProps.questionId,
@@ -108,7 +108,7 @@ describe("useTtc", () => {
expect(document.addEventListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
});
it("should not add event listener if isCurrentQuestion is false", () => {
test("should not add event listener if isCurrentQuestion is false", () => {
renderHook(() =>
useTtc(
initialProps.questionId,
@@ -122,7 +122,7 @@ describe("useTtc", () => {
expect(document.addEventListener).not.toHaveBeenCalled();
});
it("should update ttc when visibility changes to hidden and isCurrentQuestion is true", () => {
test("should update ttc when visibility changes to hidden and isCurrentQuestion is true", () => {
const currentTtc = { q1: 50 };
const currentStartTime = 1000;
currentTime = 1500; // 500ms passed
@@ -142,7 +142,7 @@ describe("useTtc", () => {
expect(mockSetTtc).toHaveBeenCalledWith({ q1: 550 }); // 50 (existing) + 500 (passed)
});
it("should restart startTime when visibility changes to visible and isCurrentQuestion is true", () => {
test("should restart startTime when visibility changes to visible and isCurrentQuestion is true", () => {
renderHook(() =>
useTtc(
initialProps.questionId,
@@ -166,7 +166,7 @@ describe("useTtc", () => {
expect(mockSetStartTime).toHaveBeenCalledWith(2000);
});
it("should not update ttc or startTime if visibility changes but isCurrentQuestion is false", () => {
test("should not update ttc or startTime if visibility changes but isCurrentQuestion is false", () => {
renderHook(() =>
useTtc(
initialProps.questionId,
@@ -197,7 +197,7 @@ describe("useTtc", () => {
expect(mockSetStartTime).not.toHaveBeenCalled(); // It was not called initially either
});
it("should remove event listener on unmount if it was added", () => {
test("should remove event listener on unmount if it was added", () => {
const { unmount } = renderHook(() =>
useTtc(
initialProps.questionId,
@@ -212,7 +212,7 @@ describe("useTtc", () => {
expect(document.removeEventListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
});
it("should not attempt to remove event listener on unmount if not added", () => {
test("should not attempt to remove event listener on unmount if not added", () => {
const { unmount } = renderHook(() =>
useTtc(
initialProps.questionId,
@@ -227,7 +227,7 @@ describe("useTtc", () => {
expect(document.removeEventListener).not.toHaveBeenCalled();
});
it("should set startTime when isCurrentQuestion becomes true", () => {
test("should set startTime when isCurrentQuestion becomes true", () => {
const { rerender } = renderHook(
(props) =>
useTtc(

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { type TAllowedFileExtension, mimeTypes } from "../../../types/common";
import type { TJsEnvironmentStateSurvey } from "../../../types/js";
import type { TSurveyLanguage, TSurveyQuestionChoice } from "../../../types/surveys/types";
@@ -13,7 +13,7 @@ vi.stubGlobal("crypto", {
describe("getMimeType", () => {
Object.entries(mimeTypes).forEach(([extension, expectedMimeType]) => {
it(`should return "${expectedMimeType}" for extension "${extension}"`, () => {
test(`should return "${expectedMimeType}" for extension "${extension}"`, () => {
expect(getMimeType(extension as TAllowedFileExtension)).toBe(expectedMimeType);
});
});
@@ -59,7 +59,7 @@ describe("getDefaultLanguageCode", () => {
// ... other mandatory fields with default/mock values if needed
};
it("should return the code of the default language", () => {
test("should return the code of the default language", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
languages: [mockSurveyLanguageEs, mockSurveyLanguageEn],
@@ -67,7 +67,7 @@ describe("getDefaultLanguageCode", () => {
expect(getDefaultLanguageCode(survey)).toBe("en");
});
it("should return undefined if no default language", () => {
test("should return undefined if no default language", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
languages: [{ ...mockSurveyLanguageEs, default: false }], // Ensure 'default' is explicitly false
@@ -75,7 +75,7 @@ describe("getDefaultLanguageCode", () => {
expect(getDefaultLanguageCode(survey)).toBeUndefined();
});
it("should return undefined if languages array is empty", () => {
test("should return undefined if languages array is empty", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
languages: [],
@@ -95,23 +95,23 @@ describe("getShuffledRowIndices", () => {
mockGetRandomValues.mockReset();
});
it('should return unshuffled for "none"', () => {
test('should return unshuffled for "none"', () => {
expect(getShuffledRowIndices(5, "none")).toEqual([0, 1, 2, 3, 4]);
});
it('should shuffle all for "all"', () => {
test('should shuffle all for "all"', () => {
setNextRandomNormalizedValue(0.1);
setNextRandomNormalizedValue(0.1);
expect(getShuffledRowIndices(3, "all")).toEqual([1, 2, 0]);
});
it('should shuffle except last for "exceptLast"', () => {
test('should shuffle except last for "exceptLast"', () => {
setNextRandomNormalizedValue(0.1);
setNextRandomNormalizedValue(0.1);
expect(getShuffledRowIndices(4, "exceptLast")).toEqual([1, 2, 0, 3]);
});
it("should handle n=0 or n=1", () => {
test("should handle n=0 or n=1", () => {
expect(getShuffledRowIndices(0, "all")).toEqual([]);
expect(getShuffledRowIndices(1, "all")).toEqual([0]);
expect(getShuffledRowIndices(1, "exceptLast")).toEqual([0]);
@@ -130,34 +130,34 @@ describe("getShuffledChoicesIds", () => {
];
const choicesWithOther: TSurveyQuestionChoice[] = [...choicesBase, { id: "other", label: { en: "Other" } }];
it('should return unshuffled for "none"', () => {
test('should return unshuffled for "none"', () => {
expect(getShuffledChoicesIds(choicesBase, "none")).toEqual(["c1", "c2", "c3"]);
expect(getShuffledChoicesIds(choicesWithOther, "none")).toEqual(["c1", "c2", "c3", "other"]);
});
it('should shuffle all (no "other") for "all"', () => {
test('should shuffle all (no "other") for "all"', () => {
setNextRandomNormalizedValue(0.1);
setNextRandomNormalizedValue(0.1);
expect(getShuffledChoicesIds(choicesBase, "all")).toEqual(["c2", "c3", "c1"]);
});
it('should shuffle all (with "other") for "all", keeping "other" last', () => {
test('should shuffle all (with "other") for "all", keeping "other" last', () => {
setNextRandomNormalizedValue(0.1);
setNextRandomNormalizedValue(0.1);
expect(getShuffledChoicesIds(choicesWithOther, "all")).toEqual(["c2", "c3", "c1", "other"]);
});
it('should shuffle except last (no "other") for "exceptLast"', () => {
test('should shuffle except last (no "other") for "exceptLast"', () => {
setNextRandomNormalizedValue(0.1);
expect(getShuffledChoicesIds(choicesBase, "exceptLast")).toEqual(["c2", "c1", "c3"]);
});
it('should shuffle except last (with "other") for "exceptLast", keeping "other" truly last', () => {
test('should shuffle except last (with "other") for "exceptLast", keeping "other" truly last', () => {
setNextRandomNormalizedValue(0.1);
expect(getShuffledChoicesIds(choicesWithOther, "exceptLast")).toEqual(["c2", "c1", "c3", "other"]);
});
it("should handle empty or single choice arrays", () => {
test("should handle empty or single choice arrays", () => {
expect(getShuffledChoicesIds([], "all")).toEqual([]);
const singleChoice = [{ id: "s1", label: { en: "Single" } }];
expect(getShuffledChoicesIds(singleChoice, "all")).toEqual(["s1"]);

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, test } from "vitest";
import {
checkForLoomUrl,
checkForVimeoUrl,
@@ -22,13 +22,13 @@ describe("checkForYoutubeUrl", () => {
];
validUrls.forEach((url) => {
it(`should return true for valid YouTube URL: ${url}`, () => {
test(`should return true for valid YouTube URL: ${url}`, () => {
expect(checkForYoutubeUrl(url)).toBe(true);
});
});
invalidUrls.forEach((url) => {
it(`should return false for invalid YouTube URL: ${url}`, () => {
test(`should return false for invalid YouTube URL: ${url}`, () => {
expect(checkForYoutubeUrl(url)).toBe(false);
});
});
@@ -44,13 +44,13 @@ describe("checkForVimeoUrl", () => {
];
validUrls.forEach((url) => {
it(`should return true for valid Vimeo URL: ${url}`, () => {
test(`should return true for valid Vimeo URL: ${url}`, () => {
expect(checkForVimeoUrl(url)).toBe(true);
});
});
invalidUrls.forEach((url) => {
it(`should return false for invalid Vimeo URL: ${url}`, () => {
test(`should return false for invalid Vimeo URL: ${url}`, () => {
expect(checkForVimeoUrl(url)).toBe(false);
});
});
@@ -66,13 +66,13 @@ describe("checkForLoomUrl", () => {
];
validUrls.forEach((url) => {
it(`should return true for valid Loom URL: ${url}`, () => {
test(`should return true for valid Loom URL: ${url}`, () => {
expect(checkForLoomUrl(url)).toBe(true);
});
});
invalidUrls.forEach((url) => {
it(`should return false for invalid Loom URL: ${url}`, () => {
test(`should return false for invalid Loom URL: ${url}`, () => {
expect(checkForLoomUrl(url)).toBe(false);
});
});
@@ -91,7 +91,7 @@ describe("extractYoutubeId", () => {
];
urlsAndIds.forEach(([url, expectedId]) => {
it(`should extract ID "${expectedId}" from URL: ${url}`, () => {
test(`should extract ID "${expectedId}" from URL: ${url}`, () => {
expect(extractYoutubeId(url)).toBe(expectedId);
});
});
@@ -111,7 +111,7 @@ describe("convertToEmbedUrl", () => {
];
urlsAndEmbeds.forEach(([url, expectedEmbedUrl]) => {
it(`should convert "${url}" to "${expectedEmbedUrl}"`, () => {
test(`should convert "${url}" to "${expectedEmbedUrl}"`, () => {
expect(convertToEmbedUrl(url)).toBe(expectedEmbedUrl);
});
});

View File

@@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false
sonar.sourceEncoding=UTF-8
# Coverage
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**