mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-19 09:21:30 -05:00
Compare commits
1 Commits
main
...
fix-lingo-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89bedc589a |
@@ -2,16 +2,21 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
|
||||
const ZGetResponsesDownloadUrlAction = z.object({
|
||||
@@ -92,3 +97,68 @@ export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
|
||||
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks if survey follow-ups are enabled for the given organization.
|
||||
*
|
||||
* @param {string} organizationId The ID of the organization to check.
|
||||
* @returns {Promise<void>} A promise that resolves if the permission is granted.
|
||||
* @throws {ResourceNotFoundError} If the organization is not found.
|
||||
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
|
||||
*/
|
||||
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
}
|
||||
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
|
||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user?.id ?? "",
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.id),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { followUps } = parsedInput;
|
||||
|
||||
const oldSurvey = await getSurvey(parsedInput.id);
|
||||
|
||||
if (parsedInput.recaptcha?.enabled) {
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
}
|
||||
|
||||
if (followUps?.length) {
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
// Context for audit log
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.oldObject = oldSurvey;
|
||||
|
||||
const newSurvey = await updateSurvey(parsedInput);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||
|
||||
return newSurvey;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
import { updateSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyStatusDropdownProps {
|
||||
environment: TEnvironment;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { getIsActiveCustomerAction } from "./actions";
|
||||
|
||||
interface ChatwootWidgetProps {
|
||||
chatwootBaseUrl: string;
|
||||
@@ -13,18 +12,6 @@ interface ChatwootWidgetProps {
|
||||
|
||||
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
|
||||
|
||||
interface ChatwootInstance {
|
||||
setUser: (
|
||||
userId: string,
|
||||
userInfo: {
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
) => void;
|
||||
setCustomAttributes: (attributes: Record<string, unknown>) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const ChatwootWidget = ({
|
||||
userEmail,
|
||||
userName,
|
||||
@@ -33,14 +20,15 @@ export const ChatwootWidget = ({
|
||||
chatwootBaseUrl,
|
||||
}: ChatwootWidgetProps) => {
|
||||
const userSetRef = useRef(false);
|
||||
const customerStatusSetRef = useRef(false);
|
||||
|
||||
const getChatwoot = useCallback((): ChatwootInstance | null => {
|
||||
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
|
||||
}, []);
|
||||
|
||||
const setUserInfo = useCallback(() => {
|
||||
const $chatwoot = getChatwoot();
|
||||
const $chatwoot = (
|
||||
globalThis as unknown as {
|
||||
$chatwoot: {
|
||||
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
|
||||
};
|
||||
}
|
||||
).$chatwoot;
|
||||
if (userId && $chatwoot && !userSetRef.current) {
|
||||
$chatwoot.setUser(userId, {
|
||||
email: userEmail,
|
||||
@@ -48,19 +36,7 @@ export const ChatwootWidget = ({
|
||||
});
|
||||
userSetRef.current = true;
|
||||
}
|
||||
}, [userId, userEmail, userName, getChatwoot]);
|
||||
|
||||
const setCustomerStatus = useCallback(async () => {
|
||||
if (customerStatusSetRef.current) return;
|
||||
const $chatwoot = getChatwoot();
|
||||
if (!$chatwoot) return;
|
||||
|
||||
const response = await getIsActiveCustomerAction();
|
||||
if (response?.data !== undefined) {
|
||||
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
|
||||
}
|
||||
customerStatusSetRef.current = true;
|
||||
}, [getChatwoot]);
|
||||
}, [userId, userEmail, userName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatwootWebsiteToken) return;
|
||||
@@ -89,19 +65,23 @@ export const ChatwootWidget = ({
|
||||
const handleChatwootReady = () => setUserInfo();
|
||||
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
|
||||
|
||||
const handleChatwootOpen = () => setCustomerStatus();
|
||||
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
|
||||
|
||||
// Check if Chatwoot is already ready
|
||||
if (getChatwoot()) {
|
||||
if (
|
||||
(
|
||||
globalThis as unknown as {
|
||||
$chatwoot: {
|
||||
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
|
||||
};
|
||||
}
|
||||
).$chatwoot
|
||||
) {
|
||||
setUserInfo();
|
||||
}
|
||||
|
||||
return () => {
|
||||
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
|
||||
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
|
||||
|
||||
const $chatwoot = getChatwoot();
|
||||
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
|
||||
if ($chatwoot) {
|
||||
$chatwoot.reset();
|
||||
}
|
||||
@@ -110,18 +90,8 @@ export const ChatwootWidget = ({
|
||||
scriptElement?.remove();
|
||||
|
||||
userSetRef.current = false;
|
||||
customerStatusSetRef.current = false;
|
||||
};
|
||||
}, [
|
||||
chatwootBaseUrl,
|
||||
chatwootWebsiteToken,
|
||||
userId,
|
||||
userEmail,
|
||||
userName,
|
||||
setUserInfo,
|
||||
setCustomerStatus,
|
||||
getChatwoot,
|
||||
]);
|
||||
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
|
||||
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
|
||||
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
||||
|
||||
const organizations = await getOrganizationsByUserId(ctx.user.id);
|
||||
return organizations.some((organization) => {
|
||||
const stripe = organization.billing.stripe;
|
||||
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
|
||||
const isActiveSubscription =
|
||||
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
|
||||
return isPaidPlan && isActiveSubscription;
|
||||
});
|
||||
});
|
||||
@@ -808,7 +808,6 @@ checksums:
|
||||
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
||||
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
||||
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
|
||||
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
|
||||
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
|
||||
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
|
||||
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
|
||||
@@ -1633,13 +1632,13 @@ checksums:
|
||||
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
|
||||
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
|
||||
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
|
||||
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
|
||||
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
|
||||
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
|
||||
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
|
||||
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
|
||||
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
|
||||
environments/surveys/edit/spam_protection_threshold_heading: 29f9a8b00c5bcbb43aedc48138a5cf9c
|
||||
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
|
||||
environments/surveys/edit/star: 0586c1c76e8a0367c0a7b93adf598cb7
|
||||
environments/surveys/edit/starts_with: f6673c17475708313c6a0f245b561781
|
||||
environments/surveys/edit/state: 118de561d4525b14f9bb29ac9e86161d
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { type Instrumentation } from "next";
|
||||
import { isExpectedError } from "@formbricks/types/errors";
|
||||
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
|
||||
|
||||
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
||||
const [error] = args;
|
||||
|
||||
// Skip expected business-logic errors (AuthorizationError, ResourceNotFoundError, etc.)
|
||||
// These are handled gracefully in the UI and don't need server-side Sentry reporting
|
||||
if (error instanceof Error && isExpectedError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.captureRequestError(...args);
|
||||
};
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
|
||||
export const register = async () => {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
|
||||
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
|
||||
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
|
||||
"no_triggers": "Keine Trigger",
|
||||
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
|
||||
"please_enter_a_url": "Bitte gib eine URL ein",
|
||||
"response_created": "Antwort erstellt",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "Umfrage maximal anzeigen von",
|
||||
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
|
||||
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
|
||||
"shrink_preview": "Vorschau verkleinern",
|
||||
"simple": "Einfach",
|
||||
"six_points": "6 Punkte",
|
||||
"smiley": "Smiley",
|
||||
"spam_protection_note": "Spamschutz funktioniert nicht für Umfragen, die mit den iOS-, React Native- und Android-SDKs angezeigt werden. Es wird die Umfrage unterbrechen.",
|
||||
"spam_protection_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.",
|
||||
"spam_protection_threshold_heading": "Antwortschwelle",
|
||||
"shrink_preview": "Vorschau verkleinern",
|
||||
"star": "Stern",
|
||||
"starts_with": "Fängt an mit",
|
||||
"state": "Bundesland",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "Yay! We are able to ping the webhook!",
|
||||
"endpoint_pinged_error": "Unable to ping the webhook!",
|
||||
"learn_to_verify": "Learn how to verify webhook signatures",
|
||||
"no_triggers": "No Triggers",
|
||||
"please_check_console": "Please check the console for more details",
|
||||
"please_enter_a_url": "Please enter a URL",
|
||||
"response_created": "Response Created",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "Show survey maximum of",
|
||||
"show_survey_to_users": "Show survey to % of users",
|
||||
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
|
||||
"shrink_preview": "Shrink Preview",
|
||||
"simple": "Simple",
|
||||
"six_points": "6 points",
|
||||
"smiley": "Smiley",
|
||||
"spam_protection_note": "Spam protection does not work for surveys displayed with the iOS, React Native, and Android SDKs. It will break the survey.",
|
||||
"spam_protection_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.",
|
||||
"spam_protection_threshold_heading": "Response threshold",
|
||||
"shrink_preview": "Shrink Preview",
|
||||
"star": "Star",
|
||||
"starts_with": "Starts with",
|
||||
"state": "State",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
|
||||
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
|
||||
"learn_to_verify": "Aprende a verificar las firmas de webhook",
|
||||
"no_triggers": "Sin activadores",
|
||||
"please_check_console": "Por favor, consulta la consola para más detalles",
|
||||
"please_enter_a_url": "Por favor, introduce una URL",
|
||||
"response_created": "Respuesta creada",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "Mostrar encuesta un máximo de",
|
||||
"show_survey_to_users": "Mostrar encuesta al % de usuarios",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo",
|
||||
"shrink_preview": "Contraer vista previa",
|
||||
"simple": "Simple",
|
||||
"six_points": "6 puntos",
|
||||
"smiley": "Emoticono",
|
||||
"spam_protection_note": "La protección contra spam no funciona para encuestas mostradas con los SDK de iOS, React Native y Android. Romperá la encuesta.",
|
||||
"spam_protection_threshold_description": "Establece un valor entre 0 y 1, las respuestas por debajo de este valor serán rechazadas.",
|
||||
"spam_protection_threshold_heading": "Umbral de respuesta",
|
||||
"shrink_preview": "Contraer vista previa",
|
||||
"star": "Estrella",
|
||||
"starts_with": "Comienza con",
|
||||
"state": "Estado",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
|
||||
"endpoint_pinged_error": "Impossible de pinger le webhook !",
|
||||
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
|
||||
"no_triggers": "Aucun déclencheur",
|
||||
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
|
||||
"please_enter_a_url": "Veuillez entrer une URL.",
|
||||
"response_created": "Réponse créée",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "Afficher le maximum du sondage de",
|
||||
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
|
||||
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
|
||||
"shrink_preview": "Réduire l'aperçu",
|
||||
"simple": "Simple",
|
||||
"six_points": "6 points",
|
||||
"smiley": "Sourire",
|
||||
"spam_protection_note": "La protection contre le spam ne fonctionne pas pour les enquêtes affichées avec les SDK iOS, React Native et Android. Cela cassera l'enquête.",
|
||||
"spam_protection_threshold_description": "Définir une valeur entre 0 et 1, les réponses en dessous de cette valeur seront rejetées.",
|
||||
"spam_protection_threshold_heading": "Seuil de réponse",
|
||||
"shrink_preview": "Réduire l'aperçu",
|
||||
"star": "Étoile",
|
||||
"starts_with": "Commence par",
|
||||
"state": "État",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
|
||||
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
|
||||
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
|
||||
"no_triggers": "Nincsenek Triggerek",
|
||||
"please_check_console": "További részletekért nézze meg a konzolt",
|
||||
"please_enter_a_url": "Adjon meg egy URL-t",
|
||||
"response_created": "Válasz létrehozva",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "Kérdőív megjelenítése legfeljebb:",
|
||||
"show_survey_to_users": "Kérdőív megjelenítése a felhasználók ennyi százalékának",
|
||||
"show_to_x_percentage_of_targeted_users": "Megjelenítés a célzott felhasználók {percentage}%-ának",
|
||||
"shrink_preview": "Előnézet összecsukása",
|
||||
"simple": "Egyszerű",
|
||||
"six_points": "6 pont",
|
||||
"smiley": "Hangulatjel",
|
||||
"spam_protection_note": "A szemét elleni védekezés nem működik az iOS, React Native és Android SDK-kkal megjelenített kérdőíveknél. El fogja rontani a kérdőívet.",
|
||||
"spam_protection_threshold_description": "Állítsa az értéket 0 és 1 közé, az ezen érték alatt lévő válaszok elutasításra kerülnek.",
|
||||
"spam_protection_threshold_heading": "Válasz küszöbszintje",
|
||||
"shrink_preview": "Előnézet összecsukása",
|
||||
"star": "Csillag",
|
||||
"starts_with": "Ezzel kezdődik",
|
||||
"state": "Állapot",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "成功!Webhook に ping できました。",
|
||||
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
|
||||
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
|
||||
"no_triggers": "トリガーなし",
|
||||
"please_check_console": "詳細はコンソールを確認してください",
|
||||
"please_enter_a_url": "URL を入力してください",
|
||||
"response_created": "回答作成",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "フォームの最大表示回数",
|
||||
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
|
||||
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
|
||||
"shrink_preview": "プレビューを縮小",
|
||||
"simple": "シンプル",
|
||||
"six_points": "6点",
|
||||
"smiley": "スマイリー",
|
||||
"spam_protection_note": "スパム対策は、iOS、React Native、およびAndroid SDKで表示されるフォームでは機能しません。フォームが壊れます。",
|
||||
"spam_protection_threshold_description": "値を0から1の間で設定してください。この値より低い回答は拒否されます。",
|
||||
"spam_protection_threshold_heading": "回答のしきい値",
|
||||
"shrink_preview": "プレビューを縮小",
|
||||
"star": "星",
|
||||
"starts_with": "で始まる",
|
||||
"state": "都道府県",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
|
||||
"endpoint_pinged_error": "Kan de webhook niet pingen!",
|
||||
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
|
||||
"no_triggers": "Geen triggers",
|
||||
"please_check_console": "Controleer de console voor meer details",
|
||||
"please_enter_a_url": "Voer een URL in",
|
||||
"response_created": "Reactie gemaakt",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "Toon onderzoek maximaal",
|
||||
"show_survey_to_users": "Enquête tonen aan % van de gebruikers",
|
||||
"show_to_x_percentage_of_targeted_users": "Toon aan {percentage}% van de getargete gebruikers",
|
||||
"shrink_preview": "Voorbeeld invouwen",
|
||||
"simple": "Eenvoudig",
|
||||
"six_points": "6 punten",
|
||||
"smiley": "Smiley",
|
||||
"spam_protection_note": "Spambeveiliging werkt niet voor enquêtes die worden weergegeven met de iOS-, React Native- en Android SDK's. Het zal de enquête breken.",
|
||||
"spam_protection_threshold_description": "Stel een waarde in tussen 0 en 1, reacties onder deze waarde worden afgewezen.",
|
||||
"spam_protection_threshold_heading": "Reactiedrempel",
|
||||
"shrink_preview": "Voorbeeld invouwen",
|
||||
"star": "Ster",
|
||||
"starts_with": "Begint met",
|
||||
"state": "Staat",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
|
||||
"endpoint_pinged_error": "Não consegui pingar o webhook!",
|
||||
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
|
||||
"no_triggers": "Nenhum Gatilho",
|
||||
"please_check_console": "Por favor, verifica o console para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira uma URL",
|
||||
"response_created": "Resposta Criada",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "Mostrar no máximo",
|
||||
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
|
||||
"shrink_preview": "Recolher prévia",
|
||||
"simple": "Simples",
|
||||
"six_points": "6 pontos",
|
||||
"smiley": "Sorridente",
|
||||
"spam_protection_note": "A proteção contra spam não funciona para pesquisas exibidas com os SDKs iOS, React Native e Android. Isso vai quebrar a pesquisa.",
|
||||
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo desse valor serão rejeitadas.",
|
||||
"spam_protection_threshold_heading": "Limite de resposta",
|
||||
"shrink_preview": "Recolher prévia",
|
||||
"star": "Estrela",
|
||||
"starts_with": "Começa com",
|
||||
"state": "Estado",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
|
||||
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
|
||||
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
|
||||
"no_triggers": "Sem Acionadores",
|
||||
"please_check_console": "Por favor, verifique a consola para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira um URL",
|
||||
"response_created": "Resposta Criada",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "Mostrar inquérito máximo de",
|
||||
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
|
||||
"shrink_preview": "Reduzir pré-visualização",
|
||||
"simple": "Simples",
|
||||
"six_points": "6 pontos",
|
||||
"smiley": "Sorridente",
|
||||
"spam_protection_note": "A proteção contra spam não funciona para inquéritos exibidos com os SDKs iOS, React Native e Android. Isso irá quebrar o inquérito.",
|
||||
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo deste valor serão rejeitadas.",
|
||||
"spam_protection_threshold_heading": "Limite de resposta",
|
||||
"shrink_preview": "Reduzir pré-visualização",
|
||||
"star": "Estrela",
|
||||
"starts_with": "Começa com",
|
||||
"state": "Estado",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
|
||||
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
|
||||
"learn_to_verify": "Află cum să verifici semnăturile webhook",
|
||||
"no_triggers": "Fără declanșatori",
|
||||
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
|
||||
"please_enter_a_url": "Vă rugăm să introduceți un URL",
|
||||
"response_created": "Răspuns creat",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "Afișează sondajul de maxim",
|
||||
"show_survey_to_users": "Afișați sondajul la % din utilizatori",
|
||||
"show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați",
|
||||
"shrink_preview": "Restrânge previzualizarea",
|
||||
"simple": "Simplu",
|
||||
"six_points": "6 puncte",
|
||||
"smiley": "Smiley",
|
||||
"spam_protection_note": "Protecția împotriva spamului nu funcționează pentru sondajele afișate folosind SDK-urile iOS, React Native și Android. Va întrerupe sondajul.",
|
||||
"spam_protection_threshold_description": "Setați valoarea între 0 și 1, răspunsurile sub această valoare vor fi respinse.",
|
||||
"spam_protection_threshold_heading": "Pragul răspunsurilor",
|
||||
"shrink_preview": "Restrânge previzualizarea",
|
||||
"star": "Stea",
|
||||
"starts_with": "Începe cu",
|
||||
"state": "Stare",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
|
||||
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
|
||||
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
|
||||
"no_triggers": "Нет триггеров",
|
||||
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
|
||||
"please_enter_a_url": "Пожалуйста, введите URL",
|
||||
"response_created": "Ответ создан",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "Показать опрос максимум",
|
||||
"show_survey_to_users": "Показать опрос % пользователей",
|
||||
"show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей",
|
||||
"shrink_preview": "Свернуть предпросмотр",
|
||||
"simple": "Простой",
|
||||
"six_points": "6 баллов",
|
||||
"smiley": "Смайлик",
|
||||
"spam_protection_note": "Защита от спама не работает для опросов, отображаемых с помощью SDK iOS, React Native и Android. Это приведёт к сбою опроса.",
|
||||
"spam_protection_threshold_description": "Установите значение от 0 до 1, ответы ниже этого значения будут отклонены.",
|
||||
"spam_protection_threshold_heading": "Порог ответа",
|
||||
"shrink_preview": "Свернуть предпросмотр",
|
||||
"star": "Звезда",
|
||||
"starts_with": "Начинается с",
|
||||
"state": "Состояние",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
|
||||
"endpoint_pinged_error": "Kunde inte nå webhooken!",
|
||||
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
|
||||
"no_triggers": "Inga utlösare",
|
||||
"please_check_console": "Vänligen kontrollera konsolen för mer information",
|
||||
"please_enter_a_url": "Vänligen ange en URL",
|
||||
"response_created": "Svar skapat",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "Visa enkät maximalt",
|
||||
"show_survey_to_users": "Visa enkät för % av användare",
|
||||
"show_to_x_percentage_of_targeted_users": "Visa för {percentage}% av målgruppens användare",
|
||||
"shrink_preview": "Minimera förhandsgranskning",
|
||||
"simple": "Enkel",
|
||||
"six_points": "6 poäng",
|
||||
"smiley": "Smiley",
|
||||
"spam_protection_note": "Spamskydd fungerar inte för enkäter som visas med iOS, React Native och Android SDK:er. Det kommer att bryta enkäten.",
|
||||
"spam_protection_threshold_description": "Ställ in värde mellan 0 och 1, svar under detta värde kommer att avvisas.",
|
||||
"spam_protection_threshold_heading": "Svarströskel",
|
||||
"shrink_preview": "Minimera förhandsgranskning",
|
||||
"star": "Stjärna",
|
||||
"starts_with": "Börjar med",
|
||||
"state": "Delstat",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
|
||||
"endpoint_pinged_error": "无法 ping 该 webhook!",
|
||||
"learn_to_verify": "了解如何验证 webhook 签名",
|
||||
"no_triggers": "无触发器",
|
||||
"please_check_console": "请查看控制台以获取更多详情",
|
||||
"please_enter_a_url": "请输入一个 URL",
|
||||
"response_created": "创建 响应",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "显示 调查 最大 一次",
|
||||
"show_survey_to_users": "显示 问卷 给 % 的 用户",
|
||||
"show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户",
|
||||
"shrink_preview": "收起预览",
|
||||
"simple": "简单",
|
||||
"six_points": "6 分",
|
||||
"smiley": "笑脸",
|
||||
"spam_protection_note": "垃圾 邮件 保护 对于 与 iOS 、 React Native 和 Android SDK 一起 显示 的 调查 无效 。 它 将 破坏 调查。",
|
||||
"spam_protection_threshold_description": "设置 值 在 0 和 1 之间,响应 低于 此 值 将 被 拒绝。",
|
||||
"spam_protection_threshold_heading": "响应 阈值",
|
||||
"shrink_preview": "收起预览",
|
||||
"star": "星",
|
||||
"starts_with": "以...开始",
|
||||
"state": "状态",
|
||||
|
||||
@@ -855,7 +855,6 @@
|
||||
"endpoint_pinged": "耶!我們能夠 ping Webhook!",
|
||||
"endpoint_pinged_error": "無法 ping Webhook!",
|
||||
"learn_to_verify": "了解如何驗證 webhook 簽章",
|
||||
"no_triggers": "無觸發條件",
|
||||
"please_check_console": "請檢查主控台以取得更多詳細資料",
|
||||
"please_enter_a_url": "請輸入網址",
|
||||
"response_created": "已建立回應",
|
||||
@@ -1706,13 +1705,13 @@
|
||||
"show_survey_maximum_of": "最多顯示問卷",
|
||||
"show_survey_to_users": "將問卷顯示給 % 的使用者",
|
||||
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
|
||||
"shrink_preview": "收合預覽",
|
||||
"simple": "簡單",
|
||||
"six_points": "6 分",
|
||||
"smiley": "表情符號",
|
||||
"spam_protection_note": "垃圾郵件保護不適用於使用 iOS、React Native 和 Android SDK 顯示的問卷。它會破壞問卷。",
|
||||
"spam_protection_threshold_description": "設置值在 0 和 1 之間,低於此值的回應將被拒絕。",
|
||||
"spam_protection_threshold_heading": "回應閾值",
|
||||
"shrink_preview": "收合預覽",
|
||||
"star": "星形",
|
||||
"starts_with": "開頭為",
|
||||
"state": "州/省",
|
||||
|
||||
@@ -9,33 +9,21 @@ import { timeSince } from "@/lib/time";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
|
||||
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
|
||||
let surveyNames: string[];
|
||||
|
||||
if (webhook.surveyIds.length === 0) {
|
||||
surveyNames = allSurveys.map((survey) => survey.name);
|
||||
const allSurveyNames = allSurveys.map((survey) => survey.name);
|
||||
return <p className="text-slate-400">{allSurveyNames.join(", ")}</p>;
|
||||
} else {
|
||||
surveyNames = webhook.surveyIds
|
||||
.map((surveyId) => {
|
||||
const survey = allSurveys.find((s) => s.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
const selectedSurveyNames = webhook.surveyIds.map((surveyId) => {
|
||||
const survey = allSurveys.find((survey) => survey.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
});
|
||||
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
|
||||
}
|
||||
|
||||
if (surveyNames.length === 0) {
|
||||
return <p className="text-slate-400">-</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="truncate text-slate-400" title={surveyNames.join(", ")}>
|
||||
{surveyNames.join(", ")}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => {
|
||||
if (webhook.triggers.length === 0) {
|
||||
return <p className="text-slate-400">{t("environments.integrations.webhooks.no_triggers")}</p>;
|
||||
return <p className="text-slate-400">No Triggers</p>;
|
||||
} else {
|
||||
let cleanedTriggers = webhook.triggers.map((trigger) => {
|
||||
if (trigger === "responseCreated") {
|
||||
|
||||
@@ -11,7 +11,7 @@ export const TagsLoading = () => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<ProjectConfigNavigation activeId="tags" loading />
|
||||
<ProjectConfigNavigation activeId="tags" />
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title={t("environments.workspace.tags.manage_tags")}
|
||||
|
||||
@@ -26,27 +26,22 @@ import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { getProject } from "./lib/project";
|
||||
|
||||
/**
|
||||
* Checks if survey follow-ups can be added for the given organization.
|
||||
* Grandfathers existing follow-ups (allows keeping them even if the org lost access).
|
||||
* Only throws when new follow-ups are being added.
|
||||
* Checks if survey follow-ups are enabled for the given organization.
|
||||
*
|
||||
* @param { string } organizationId The ID of the organization to check.
|
||||
* @returns { Promise<void> } A promise that resolves if the permission is granted.
|
||||
* @throws { ResourceNotFoundError } If the organization is not found.
|
||||
* @throws { OperationNotAllowedError } If survey follow-ups are not enabled for the organization.
|
||||
*/
|
||||
const checkSurveyFollowUpsPermission = async (
|
||||
organizationId: string,
|
||||
newFollowUpIds: string[],
|
||||
oldFollowUpIds: Set<string>
|
||||
): Promise<void> => {
|
||||
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
|
||||
if (isSurveyFollowUpsEnabled) return;
|
||||
|
||||
for (const id of newFollowUpIds) {
|
||||
if (!oldFollowUpIds.has(id)) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,19 +71,14 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
}
|
||||
|
||||
if (survey.followUps?.length) {
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = survey.id;
|
||||
const oldObject = await getSurvey(survey.id);
|
||||
|
||||
if (survey.followUps.length) {
|
||||
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
|
||||
await checkSurveyFollowUpsPermission(
|
||||
organizationId,
|
||||
survey.followUps.map((f) => f.id),
|
||||
oldFollowUpIds
|
||||
);
|
||||
}
|
||||
|
||||
await checkExternalUrlsPermission(organizationId, survey, oldObject);
|
||||
|
||||
// Use the draft version that skips validation
|
||||
@@ -126,19 +116,14 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
}
|
||||
|
||||
if (parsedInput.followUps?.length) {
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||
const oldObject = await getSurvey(parsedInput.id);
|
||||
|
||||
if (parsedInput.followUps?.length) {
|
||||
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
|
||||
await checkSurveyFollowUpsPermission(
|
||||
organizationId,
|
||||
parsedInput.followUps.map((f) => f.id),
|
||||
oldFollowUpIds
|
||||
);
|
||||
}
|
||||
|
||||
// Check external URLs permission (with grandfathering)
|
||||
await checkExternalUrlsPermission(organizationId, parsedInput, oldObject);
|
||||
const result = await updateSurvey(parsedInput);
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { closeSurvey, preloadSurveysScript } from "@/lib/survey/widget";
|
||||
import { closeSurvey } from "@/lib/survey/widget";
|
||||
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
|
||||
import { sendUpdatesToBackend } from "@/lib/user/update";
|
||||
import {
|
||||
@@ -316,9 +316,6 @@ export const setup = async (
|
||||
addEventListeners();
|
||||
addCleanupEventListeners();
|
||||
|
||||
// Preload surveys script so it's ready when a survey triggers
|
||||
preloadSurveysScript(configInput.appUrl);
|
||||
|
||||
setIsSetup(true);
|
||||
logger.debug("Set up complete");
|
||||
|
||||
|
||||
@@ -70,12 +70,6 @@ vi.mock("@/lib/survey/no-code-action", () => ({
|
||||
checkPageUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// 9) Mock survey widget
|
||||
vi.mock("@/lib/survey/widget", () => ({
|
||||
closeSurvey: vi.fn(),
|
||||
preloadSurveysScript: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("setup.ts", () => {
|
||||
let getInstanceConfigMock: MockInstance<() => Config>;
|
||||
let getInstanceLoggerMock: MockInstance<() => Logger>;
|
||||
|
||||
@@ -67,8 +67,6 @@ describe("widget-file", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
// @ts-expect-error -- cleaning up mock
|
||||
delete window.formbricksSurveys;
|
||||
|
||||
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
|
||||
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
|
||||
@@ -466,214 +464,6 @@ describe("widget-file", () => {
|
||||
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("loadFormbricksSurveysExternally and waitForSurveysGlobal", () => {
|
||||
const scriptLoadMockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
contactId: "contact_abc",
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
// Helper to get the script element passed to document.head.appendChild
|
||||
const getAppendedScript = (): Record<string, unknown> => {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- accessing mock for test assertions
|
||||
const appendChildMock = vi.mocked(document.head.appendChild);
|
||||
for (const call of appendChildMock.mock.calls) {
|
||||
const el = call[0] as unknown as Record<string, unknown>;
|
||||
if (typeof el.src === "string" && el.src.includes("surveys.umd.cjs")) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
throw new Error("No script element for surveys.umd.cjs was appended to document.head");
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock return values that may have been overridden by previous tests
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
// Test onerror first so surveysLoadPromise is reset to null for subsequent tests
|
||||
test("rejects when script fails to load (onerror) and allows retry", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const renderPromise = widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
const scriptEl = getAppendedScript();
|
||||
|
||||
expect(scriptEl.src).toBe("https://fake.app/js/surveys.umd.cjs");
|
||||
expect(scriptEl.async).toBe(true);
|
||||
|
||||
// Simulate network error
|
||||
(scriptEl.onerror as (error: unknown) => void)("Network error");
|
||||
|
||||
// renderWidget catches the error internally — it resolves, not rejects
|
||||
await renderPromise;
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load Formbricks Surveys library:", "Network error");
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("rejects when script loads but surveys global never becomes available (timeout)", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- suppress console.error in test
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.useFakeTimers();
|
||||
|
||||
const renderPromise = widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
const scriptEl = getAppendedScript();
|
||||
|
||||
// Script loaded but window.formbricksSurveys is never set
|
||||
(scriptEl.onload as () => void)();
|
||||
|
||||
// Advance past the 10s timeout (polls every 200ms)
|
||||
await vi.advanceTimersByTimeAsync(10001);
|
||||
|
||||
await renderPromise;
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to load Formbricks Surveys library:",
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("resolves after polling when surveys global becomes available and applies stored nonce", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// Set nonce before surveys load to test nonce application
|
||||
window.__formbricksNonce = "test-nonce-123";
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
const renderPromise = widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
const scriptEl = getAppendedScript();
|
||||
|
||||
// Simulate script loaded
|
||||
(scriptEl.onload as () => void)();
|
||||
|
||||
// Set the global after script "loads" — simulates browser finishing execution
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
|
||||
// Advance one polling interval for waitForSurveysGlobal to find it
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
await renderPromise;
|
||||
|
||||
// Run remaining timers for survey.delay setTimeout
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(window.formbricksSurveys.setNonce).toHaveBeenCalledWith("test-nonce-123");
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
contactId: "contact_abc",
|
||||
})
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
delete window.__formbricksNonce;
|
||||
});
|
||||
|
||||
test("deduplicates concurrent calls (returns cached promise)", async () => {
|
||||
getInstanceConfigMock.mockReturnValue(scriptLoadMockConfig as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// After the previous successful test, surveysLoadPromise holds a resolved promise.
|
||||
// Calling renderWidget again (without formbricksSurveys on window, but with cached promise)
|
||||
// should reuse the cached promise rather than creating a new script element.
|
||||
// @ts-expect-error -- cleaning up mock to force dedup path
|
||||
delete window.formbricksSurveys;
|
||||
|
||||
const appendChildSpy = vi.spyOn(document.head, "appendChild");
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
// No new script element should have been appended (dedup via early return or cached promise)
|
||||
const scriptAppendCalls = appendChildSpy.mock.calls.filter((call: unknown[]) => {
|
||||
const el = call[0] as Record<string, unknown> | undefined;
|
||||
return typeof el?.src === "string" && el.src.includes("surveys.umd.cjs");
|
||||
});
|
||||
expect(scriptAppendCalls.length).toBe(0);
|
||||
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
test("preloadSurveysScript adds a preload link and deduplicates subsequent calls", () => {
|
||||
const createElementSpy = vi.spyOn(document, "createElement");
|
||||
const appendChildSpy = vi.spyOn(document.head, "appendChild");
|
||||
|
||||
widget.preloadSurveysScript("https://fake.app");
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalledWith("link");
|
||||
expect(appendChildSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const linkEl = createElementSpy.mock.results[0].value as Record<string, string>;
|
||||
expect(linkEl.rel).toBe("preload");
|
||||
expect(linkEl.as).toBe("script");
|
||||
expect(linkEl.href).toBe("https://fake.app/js/surveys.umd.cjs");
|
||||
|
||||
// Second call should be a no-op (deduplication)
|
||||
widget.preloadSurveysScript("https://fake.app");
|
||||
expect(appendChildSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
|
||||
|
||||
@@ -106,15 +106,7 @@ export const renderWidget = async (
|
||||
const overlay = projectOverwrites.overlay ?? project.overlay;
|
||||
const placement = projectOverwrites.placement ?? project.placement;
|
||||
const isBrandingEnabled = project.inAppSurveyBranding;
|
||||
|
||||
let formbricksSurveys: TFormbricksSurveys;
|
||||
try {
|
||||
formbricksSurveys = await loadFormbricksSurveysExternally();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load surveys library: ${String(error)}`);
|
||||
setIsSurveyRunning(false);
|
||||
return;
|
||||
}
|
||||
const formbricksSurveys = await loadFormbricksSurveysExternally();
|
||||
|
||||
const recaptchaSiteKey = config.get().environment.data.recaptchaSiteKey;
|
||||
const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled);
|
||||
@@ -227,87 +219,30 @@ export const removeWidgetContainer = (): void => {
|
||||
document.getElementById(CONTAINER_ID)?.remove();
|
||||
};
|
||||
|
||||
const SURVEYS_LOAD_TIMEOUT_MS = 10000;
|
||||
const SURVEYS_POLL_INTERVAL_MS = 200;
|
||||
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
|
||||
const config = Config.getInstance();
|
||||
|
||||
type TFormbricksSurveys = typeof globalThis.window.formbricksSurveys;
|
||||
|
||||
let surveysLoadPromise: Promise<TFormbricksSurveys> | null = null;
|
||||
|
||||
const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = (): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
resolve(globalThis.window.formbricksSurveys);
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
// Apply stored nonce if it was set before surveys package loaded
|
||||
const storedNonce = globalThis.window.__formbricksNonce;
|
||||
if (storedNonce) {
|
||||
globalThis.window.formbricksSurveys.setNonce(storedNonce);
|
||||
}
|
||||
resolve(globalThis.window.formbricksSurveys);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - startTime >= SURVEYS_LOAD_TIMEOUT_MS) {
|
||||
reject(new Error("Formbricks Surveys library did not become available within timeout"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(check, SURVEYS_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
check();
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library: ${error as string}`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
return Promise.resolve(globalThis.window.formbricksSurveys);
|
||||
}
|
||||
|
||||
if (surveysLoadPromise) {
|
||||
return surveysLoadPromise;
|
||||
}
|
||||
|
||||
surveysLoadPromise = new Promise<TFormbricksSurveys>((resolve, reject: (error: unknown) => void) => {
|
||||
const config = Config.getInstance();
|
||||
const script = document.createElement("script");
|
||||
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
waitForSurveysGlobal()
|
||||
.then(resolve)
|
||||
.catch((error: unknown) => {
|
||||
surveysLoadPromise = null;
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library`));
|
||||
});
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
surveysLoadPromise = null;
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(new Error(`Failed to load Formbricks Surveys library`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return surveysLoadPromise;
|
||||
};
|
||||
|
||||
let isPreloaded = false;
|
||||
|
||||
export const preloadSurveysScript = (appUrl: string): void => {
|
||||
// Don't preload if already loaded or already preloading
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) return;
|
||||
if (isPreloaded) return;
|
||||
|
||||
isPreloaded = true;
|
||||
const link = document.createElement("link");
|
||||
link.rel = "preload";
|
||||
link.as = "script";
|
||||
link.href = `${appUrl}/js/surveys.umd.cjs`;
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user