Compare commits

..

17 Commits

Author SHA1 Message Date
Dhruwang Jariwala 74b679403d fix: multi-lang toggle covering arabic text (backport #7657) (#7660)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-02 18:49:29 +05:30
Dhruwang Jariwala 4a5404557b fix: multilang button overflow (backport #7656) (#7659)
Co-authored-by: Niels Kaspers <kaspersniels@gmail.com>
2026-04-02 18:49:13 +05:30
Dhruwang Jariwala fbb529d066 fix: prevent language switch from breaking survey orientation and resetting language on auto-save (backport #7654) (#7658) 2026-04-02 18:48:57 +05:30
Dhruwang Jariwala e07b6fb3d1 fix: resolve language code case mismatch in link survey rendering (backport #7624) (#7625)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 17:35:18 +05:30
Dhruwang Jariwala 3996f89f75 fix: prevent auto-save from overwriting survey status during publish (backport) (#7622) 2026-03-30 16:31:39 +05:30
Dhruwang Jariwala 86f852ee4b fix: sync segment state after auto-save to prevent stale reference on publish (backport) (#7623)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2026-03-30 16:31:28 +05:30
Dhruwang Jariwala 5bf884d529 fix: handle 404 race condition in Stripe webhook reconciliation (backport #7584) (#7603) 2026-03-27 11:15:26 +05:30
Dhruwang Jariwala 351cd75e57 fix: prevent duplicate hobby subscriptions from race condition (backport #7597) (#7602)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 11:15:11 +05:30
Dhruwang Jariwala 2e36f0c590 fix: prevent multi-language survey buttons from falling back to English (backport #7559) (#7601)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 11:15:00 +05:30
Anshuman Pandey ce576b33ac fix: [Backport]backports indirect segment activity (#7569)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-23 18:35:39 +05:30
Anshuman Pandey 25fd161578 fix: [Backport]backports segment isNotIn fix (#7567) 2026-03-23 18:35:07 +05:30
Anshuman Pandey fac7369b3c fix: [Backport] backports welcome card image bug fix (#7545) 2026-03-20 11:52:51 +01:00
Anshuman Pandey 913ab98d62 fix: [Backport] backports the sdk initialization issues (#7536) 2026-03-19 14:33:20 +01:00
Anshuman Pandey 717a172ce0 fix: [Backport] backports sentry improvement and loading page fix (#7534) 2026-03-19 18:29:13 +05:30
pandeymangg 8c935f20c2 backports sentry improvement a loading page fix 2026-03-19 16:45:44 +05:30
Dhruwang Jariwala a10404ba1d fix: fixes race between setUserId and trigger (#7498) [Backport to release/4.8] (#7514)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2026-03-18 15:09:38 +05:30
Dhruwang Jariwala 39788ce0e1 fix: pre-strip style attributes before DOMPurify to prevent CSP violations (#7489) [Backport to release/4.8] (#7513) 2026-03-18 15:09:02 +05:30
58 changed files with 517 additions and 1035 deletions
@@ -64,17 +64,15 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
@@ -83,12 +81,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
projectId: parsedInput.projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { project } = useEnvironment();
const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,6 +128,7 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
});
if (result?.data) {
+13 -1
View File
@@ -1,7 +1,19 @@
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 = Sentry.captureRequestError;
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 register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") {
+3 -1
View File
@@ -84,7 +84,9 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
if (!surveyLanguages?.length || !languageCode) return "default";
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
const language = surveyLanguages.find(
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
);
return language?.default ? "default" : language?.language.code || "default";
};
@@ -217,7 +217,7 @@ describe("utils", () => {
});
describe("logApiError", () => {
test("logs API error details with method and path", () => {
test("logs API error details", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
@@ -228,7 +228,7 @@ describe("utils", () => {
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "POST" });
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
@@ -238,11 +238,9 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context including method and path
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123",
method: "POST",
path: "/api/v2/management/surveys",
error,
});
@@ -277,8 +275,6 @@ describe("utils", () => {
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "",
method: "GET",
path: "/api/test",
error,
});
@@ -289,7 +285,7 @@ describe("utils", () => {
logger.withContext = originalWithContext;
});
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
test("log API error details with SENTRY_DSN set", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
@@ -299,23 +295,11 @@ describe("utils", () => {
// Mock Sentry's captureException method
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
// Capture the scope mock for tag verification
const scopeSetTagMock = vi.fn();
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
const mockScope = {
setTag: scopeSetTagMock,
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "DELETE" });
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
@@ -325,60 +309,20 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context including method and path
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
error,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Verify Sentry scope tags include method and path
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();
// Restore the original method
logger.withContext = originalWithContext;
});
test("does not send to Sentry for non-internal_server_error types", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
vi.mocked(Sentry.captureException).mockClear();
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/v2/management/surveys");
mockRequest.headers.set("x-request-id", "456");
const error: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
};
logApiError(mockRequest, error);
// Verify Sentry.captureException was NOT called for non-500 errors
expect(Sentry.captureException).not.toHaveBeenCalled();
// But structured logging should still happen
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
});
});
+1 -8
View File
@@ -6,18 +6,13 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
// Use Sentry scope to add correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setTag("method", method);
scope.setTag("path", path);
scope.setLevel("error");
scope.setExtra("originalError", error);
@@ -29,8 +24,6 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
logger
.withContext({
correlationId,
method,
path,
error,
})
.error("API V2 Error Details");
+4 -10
View File
@@ -106,10 +106,7 @@ describe("billing actions", () => {
});
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -128,10 +125,7 @@ describe("billing actions", () => {
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -145,7 +139,7 @@ describe("billing actions", () => {
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -165,7 +159,7 @@ describe("billing actions", () => {
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
+2 -2
View File
@@ -216,7 +216,7 @@ export const startHobbyAction = authenticatedActionClient
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
@@ -248,7 +248,7 @@ export const startProTrialAction = authenticatedActionClient
}
await createProTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
@@ -150,7 +150,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
await handleSetupCheckoutCompleted(event.data.object, stripe);
}
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await syncOrganizationBillingFromStripe(organizationId, {
id: event.id,
created: event.created,
@@ -1905,7 +1905,7 @@ describe("organization-billing", () => {
items: [{ price: "price_hobby_monthly", quantity: 1 }],
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
{ idempotencyKey: "ensure-hobby-subscription-org_1-0" }
);
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
@@ -1974,7 +1974,7 @@ describe("organization-billing", () => {
],
});
await reconcileCloudStripeSubscriptionsForOrganization("org_1", "evt_123");
await reconcileCloudStripeSubscriptionsForOrganization("org_1");
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
@@ -458,18 +458,21 @@ const resolvePendingChangeEffectiveAt = (
const ensureHobbySubscription = async (
organizationId: string,
customerId: string,
idempotencySuffix: string
subscriptionCount: number
): Promise<void> => {
if (!stripeClient) return;
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
// Include subscriptionCount so the key is stable across concurrent calls (same
// count → same key → Stripe deduplicates) but changes after a cancellation
// (count increases → new key → allows legitimate re-creation).
await stripeClient.subscriptions.create(
{
customer: customerId,
items: hobbyItems,
metadata: { organizationId },
},
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${subscriptionCount}` }
);
};
@@ -1264,8 +1267,7 @@ export const findOrganizationIdByStripeCustomerId = async (customerId: string):
};
export const reconcileCloudStripeSubscriptionsForOrganization = async (
organizationId: string,
idempotencySuffix = "reconcile"
organizationId: string
): Promise<void> => {
const client = stripeClient;
if (!IS_FORMBRICKS_CLOUD || !client) return;
@@ -1313,11 +1315,26 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
);
await Promise.all(
hobbySubscriptions.map(({ subscription }) =>
client.subscriptions.cancel(subscription.id, {
prorate: false,
})
)
hobbySubscriptions.map(async ({ subscription }) => {
try {
await client.subscriptions.cancel(subscription.id, {
prorate: false,
});
} catch (err) {
if (
err instanceof Stripe.errors.StripeInvalidRequestError &&
err.statusCode === 404 &&
err.code === "resource_missing"
) {
logger.warn(
{ subscriptionId: subscription.id, organizationId },
"Subscription already deleted, skipping cancel"
);
return;
}
throw err;
}
})
);
return;
}
@@ -1327,12 +1344,14 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
const freshSubscriptions = await client.subscriptions.list({
customer: customerId,
status: "active",
limit: 1,
status: "all",
limit: 20,
});
if (freshSubscriptions.data.length === 0) {
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
const freshActive = freshSubscriptions.data.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.has(sub.status));
if (freshActive.length === 0) {
await ensureHobbySubscription(organizationId, customerId, freshSubscriptions.data.length);
}
}
};
@@ -1340,6 +1359,6 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await syncOrganizationBillingFromStripe(organizationId);
};
@@ -97,13 +97,14 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
);
const ZUpdateSegmentAction = z.object({
environmentId: ZId,
segmentId: ZId,
data: ZSegmentUpdateInput,
});
export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action(
withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId);
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
@@ -4,7 +4,7 @@ import { UsersIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
import {
Dialog,
@@ -15,23 +15,63 @@ import {
DialogTitle,
} from "@/modules/ui/components/dialog";
import { SegmentActivityTab } from "./segment-activity-tab";
import { TSegmentActivitySummary } from "./segment-activity-utils";
interface EditSegmentModalProps {
environmentId: string;
open: boolean;
setOpen: (open: boolean) => void;
currentSegment: TSegmentWithSurveyNames;
currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
}
const SegmentSettingsTab = ({
activitySummary,
contactAttributeKeys,
currentSegment,
environmentId,
isContactsEnabled,
isReadOnly,
segments,
setOpen,
}: Pick<
EditSegmentModalProps,
| "activitySummary"
| "contactAttributeKeys"
| "currentSegment"
| "environmentId"
| "isContactsEnabled"
| "isReadOnly"
| "segments"
| "setOpen"
>) => {
if (!isContactsEnabled) {
return null;
}
return (
<SegmentSettings
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
initialSegment={currentSegment}
segments={segments}
setOpen={setOpen}
isReadOnly={isReadOnly}
/>
);
};
export const EditSegmentModal = ({
environmentId,
open,
setOpen,
currentSegment,
activitySummary,
contactAttributeKeys,
segments,
isContactsEnabled,
@@ -40,31 +80,25 @@ export const EditSegmentModal = ({
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(0);
const SettingsTab = () => {
if (isContactsEnabled) {
return (
<SegmentSettings
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
initialSegment={currentSegment}
segments={segments}
setOpen={setOpen}
isReadOnly={isReadOnly}
/>
);
}
return null;
};
const tabs = [
{
title: t("common.activity"),
children: <SegmentActivityTab currentSegment={currentSegment} />,
children: <SegmentActivityTab currentSegment={currentSegment} activitySummary={activitySummary} />,
},
{
title: t("common.settings"),
children: <SettingsTab />,
children: (
<SegmentSettingsTab
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys}
currentSegment={currentSegment}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
segments={segments}
setOpen={setOpen}
/>
),
},
];
@@ -1,19 +1,20 @@
"use client";
import { useTranslation } from "react-i18next";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
import { TSegmentActivitySummary } from "./segment-activity-utils";
interface SegmentActivityTabProps {
currentSegment: TSegmentWithSurveyNames;
currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
}
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentActivityTabProps) => {
const { t } = useTranslation();
const { activeSurveys, inactiveSurveys } = currentSegment;
const { activeSurveys, inactiveSurveys } = activitySummary;
return (
<div className="grid grid-cols-3 pb-2">
@@ -22,20 +23,20 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
<Label className="text-slate-500">{t("common.active_surveys")}</Label>
{!activeSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{activeSurveys?.map((survey, index) => (
<p className="text-sm text-slate-900" key={index + survey}>
{survey}
</p>
{activeSurveys?.map((surveyName) => (
<div className="py-0.5" key={surveyName}>
<p className="text-sm text-slate-900">{surveyName}</p>
</div>
))}
</div>
<div>
<Label className="text-slate-500">{t("common.inactive_surveys")}</Label>
{!inactiveSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{inactiveSurveys?.map((survey, index) => (
<p className="text-sm text-slate-900" key={index + survey}>
{survey}
</p>
{inactiveSurveys?.map((surveyName) => (
<div className="py-0.5" key={surveyName}>
<p className="text-sm text-slate-900">{surveyName}</p>
</div>
))}
</div>
</div>
@@ -0,0 +1,99 @@
import { TBaseFilters, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
type TSurveySummary = Pick<TSurvey, "id" | "name" | "status">;
type TReferencingSegmentSurveyGroup = {
segmentId: string;
segmentTitle: string;
surveys: TSurveySummary[];
};
export type TSegmentActivitySummary = {
activeSurveys: string[];
inactiveSurveys: string[];
};
export const doesSegmentReferenceSegment = (filters: TBaseFilters, targetSegmentId: string): boolean => {
for (const filter of filters) {
const { resource } = filter;
if (Array.isArray(resource)) {
if (doesSegmentReferenceSegment(resource, targetSegmentId)) {
return true;
}
continue;
}
if (resource.root.type === "segment" && resource.root.segmentId === targetSegmentId) {
return true;
}
}
return false;
};
export const getReferencingSegments = (
segments: TSegmentWithSurveyRefs[],
targetSegmentId: string
): TSegmentWithSurveyRefs[] =>
segments.filter(
(segment) =>
segment.id !== targetSegmentId && doesSegmentReferenceSegment(segment.filters, targetSegmentId)
);
export const buildSegmentActivitySummary = (
directSurveys: TSurveySummary[],
indirectSurveyGroups: TReferencingSegmentSurveyGroup[]
): TSegmentActivitySummary => {
const surveyMap = new Map<string, TSurveySummary>();
for (const survey of directSurveys) {
surveyMap.set(survey.id, survey);
}
for (const segment of indirectSurveyGroups) {
for (const survey of segment.surveys) {
if (!surveyMap.has(survey.id)) {
surveyMap.set(survey.id, survey);
}
}
}
const surveys = Array.from(surveyMap.values());
return {
activeSurveys: surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name),
inactiveSurveys: surveys
.filter((survey) => survey.status === "draft" || survey.status === "paused")
.map((survey) => survey.name),
};
};
export const buildSegmentActivitySummaryFromSegments = (
currentSegment: TSegmentWithSurveyRefs,
segments: TSegmentWithSurveyRefs[]
): TSegmentActivitySummary => {
const activeSurveyMap = new Map(currentSegment.activeSurveys.map((s) => [s.id, s.name]));
const inactiveSurveyMap = new Map(currentSegment.inactiveSurveys.map((s) => [s.id, s.name]));
const allDirectIds = new Set([...activeSurveyMap.keys(), ...inactiveSurveyMap.keys()]);
const referencingSegments = getReferencingSegments(segments, currentSegment.id);
for (const segment of referencingSegments) {
for (const survey of segment.activeSurveys) {
if (!allDirectIds.has(survey.id) && !activeSurveyMap.has(survey.id)) {
activeSurveyMap.set(survey.id, survey.name);
}
}
for (const survey of segment.inactiveSurveys) {
if (!allDirectIds.has(survey.id) && !inactiveSurveyMap.has(survey.id)) {
inactiveSurveyMap.set(survey.id, survey.name);
}
}
}
return {
activeSurveys: Array.from(activeSurveyMap.values()),
inactiveSurveys: Array.from(inactiveSurveyMap.values()),
};
};
@@ -6,7 +6,7 @@ import { type Dispatch, type SetStateAction, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import type { TBaseFilter, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import type { TBaseFilter, TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { ZSegmentFilters } from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
@@ -16,18 +16,21 @@ import { Button } from "@/modules/ui/components/button";
import { ConfirmDeleteSegmentModal } from "@/modules/ui/components/confirm-delete-segment-modal";
import { Input } from "@/modules/ui/components/input";
import { AddFilterModal } from "./add-filter-modal";
import { TSegmentActivitySummary } from "./segment-activity-utils";
import { SegmentEditor } from "./segment-editor";
interface TSegmentSettingsTabProps {
activitySummary: TSegmentActivitySummary;
environmentId: string;
setOpen: (open: boolean) => void;
initialSegment: TSegmentWithSurveyNames;
initialSegment: TSegmentWithSurveyRefs;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isReadOnly: boolean;
}
export function SegmentSettings({
activitySummary,
environmentId,
initialSegment,
setOpen,
@@ -38,7 +41,7 @@ export function SegmentSettings({
const router = useRouter();
const { t } = useTranslation();
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [segment, setSegment] = useState<TSegmentWithSurveyNames>(initialSegment);
const [segment, setSegment] = useState<TSegmentWithSurveyRefs>(initialSegment);
const [isUpdatingSegment, setIsUpdatingSegment] = useState(false);
const [isDeletingSegment, setIsDeletingSegment] = useState(false);
@@ -75,6 +78,7 @@ export function SegmentSettings({
try {
setIsUpdatingSegment(true);
const data = await updateSegmentAction({
environmentId,
segmentId: segment.id,
data: {
title: segment.title,
@@ -256,9 +260,9 @@ export function SegmentSettings({
{isDeleteSegmentModalOpen ? (
<ConfirmDeleteSegmentModal
activitySummary={activitySummary}
onDelete={handleDeleteSegment}
open={isDeleteSegmentModalOpen}
segment={initialSegment}
setOpen={setIsDeleteSegmentModalOpen}
/>
) : null}
@@ -4,10 +4,10 @@ import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { TFunction } from "i18next";
import { UsersIcon } from "lucide-react";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyNames>[] => {
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = {
export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyRefs>[] => {
const titleColumn: ColumnDef<TSegmentWithSurveyRefs> = {
id: "title",
accessorKey: "title",
header: t("common.title"),
@@ -28,7 +28,7 @@ export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWit
},
};
const updatedAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
const updatedAtColumn: ColumnDef<TSegmentWithSurveyRefs> = {
id: "updatedAt",
accessorKey: "updatedAt",
header: t("common.updated_at"),
@@ -41,7 +41,7 @@ export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWit
},
};
const createdAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
const createdAtColumn: ColumnDef<TSegmentWithSurveyRefs> = {
id: "createdAt",
accessorKey: "createdAt",
header: t("common.created_at"),
@@ -1,46 +0,0 @@
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment } from "@formbricks/types/segment";
import { getSurveysBySegmentId } from "@/lib/survey/service";
import { SegmentTableDataRow } from "./segment-table-data-row";
type TSegmentTableDataRowProps = {
currentSegment: TSegment;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
};
export const SegmentTableDataRowContainer = async ({
currentSegment,
segments,
contactAttributeKeys,
isContactsEnabled,
isReadOnly,
}: TSegmentTableDataRowProps) => {
const surveys = await getSurveysBySegmentId(currentSegment.id);
const activeSurveys = surveys?.length
? surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name)
: [];
const inactiveSurveys = surveys?.length
? surveys.filter((survey) => ["draft", "paused"].includes(survey.status)).map((survey) => survey.name)
: [];
const filteredSegments = segments.filter((segment) => segment.id !== currentSegment.id);
return (
<SegmentTableDataRow
currentSegment={{
...currentSegment,
activeSurveys,
inactiveSurveys,
}}
segments={filteredSegments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
/>
);
};
@@ -4,11 +4,13 @@ import { format, formatDistanceToNow } from "date-fns";
import { UsersIcon } from "lucide-react";
import { useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { EditSegmentModal } from "./edit-segment-modal";
import { TSegmentActivitySummary } from "./segment-activity-utils";
type TSegmentTableDataRowProps = {
currentSegment: TSegmentWithSurveyNames;
currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
@@ -17,6 +19,7 @@ type TSegmentTableDataRowProps = {
export const SegmentTableDataRow = ({
currentSegment,
activitySummary,
contactAttributeKeys,
segments,
isContactsEnabled,
@@ -62,6 +65,7 @@ export const SegmentTableDataRow = ({
open={isEditSegmentModalOpen}
setOpen={setIsEditSegmentModalOpen}
currentSegment={currentSegment}
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys}
segments={segments}
isContactsEnabled={isContactsEnabled}
@@ -4,13 +4,15 @@ import { Header, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { EditSegmentModal } from "./edit-segment-modal";
import { buildSegmentActivitySummaryFromSegments } from "./segment-activity-utils";
import { generateSegmentTableColumns } from "./segment-table-columns";
interface SegmentTableUpdatedProps {
segments: TSegmentWithSurveyNames[];
segments: TSegmentWithSurveyRefs[];
allSegments: TSegmentWithSurveyRefs[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
@@ -18,16 +20,17 @@ interface SegmentTableUpdatedProps {
export function SegmentTable({
segments,
allSegments,
contactAttributeKeys,
isContactsEnabled,
isReadOnly,
}: SegmentTableUpdatedProps) {
}: Readonly<SegmentTableUpdatedProps>) {
const { t } = useTranslation();
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyRefs | null>(null);
const columns = useMemo(() => {
return generateSegmentTableColumns(t);
}, []);
}, [t]);
const table = useReactTable({
data: segments,
@@ -35,7 +38,7 @@ export function SegmentTable({
getCoreRowModel: getCoreRowModel(),
});
const getHeader = (header: Header<TSegmentWithSurveyNames, unknown>) => {
const getHeader = (header: Header<TSegmentWithSurveyRefs, unknown>) => {
if (header.isPlaceholder) {
return null;
}
@@ -136,6 +139,7 @@ export function SegmentTable({
open={!!editingSegment}
setOpen={(open) => !open && setEditingSegment(null)}
currentSegment={editingSegment}
activitySummary={buildSegmentActivitySummaryFromSegments(editingSegment, allSegments)}
contactAttributeKeys={contactAttributeKeys}
segments={segments}
isContactsEnabled={isContactsEnabled}
@@ -124,7 +124,7 @@ export function TargetingCard({
};
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
const updatedSegment = await updateSegmentAction({ segmentId, data });
const updatedSegment = await updateSegmentAction({ segmentId, environmentId, data });
return updatedSegment?.data as TSegment;
};
@@ -136,7 +136,7 @@ export function TargetingCard({
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
try {
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
const result = await updateSegmentAction({ segmentId: segment.id, data });
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TBaseFilters, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TBaseFilters, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { getSegment } from "../segments";
import { segmentFilterToPrismaQuery } from "./prisma-query";
@@ -270,7 +270,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Mock the getSegment function to return a segment with filters
const mockSegment: TSegmentWithSurveyNames = {
const mockSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId,
filters: nestedFilters,
environmentId: mockEnvironmentId,
@@ -336,7 +336,7 @@ describe("segmentFilterToPrismaQuery", () => {
// Mock getSegment to return null for the non-existent segment
vi.mocked(getSegment).mockResolvedValueOnce(mockSegment);
vi.mocked(getSegment).mockResolvedValueOnce(null as unknown as TSegmentWithSurveyNames);
vi.mocked(getSegment).mockResolvedValueOnce(null as unknown as TSegmentWithSurveyRefs);
const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
@@ -426,7 +426,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Mock the getSegment function to return a segment with filters
const mockSegment: TSegmentWithSurveyNames = {
const mockSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId,
filters: nestedFilters,
environmentId: mockEnvironmentId,
@@ -490,7 +490,7 @@ describe("segmentFilterToPrismaQuery", () => {
test("handle circular references in segment filters", async () => {
// Mock getSegment to simulate a circular reference
const circularSegment: TSegmentWithSurveyNames = {
const circularSegment: TSegmentWithSurveyRefs = {
id: mockSegmentId, // Same ID creates the circular reference
filters: [
{
@@ -550,7 +550,7 @@ describe("segmentFilterToPrismaQuery", () => {
test("handle missing segments in segment filters", async () => {
const nestedSegmentId = "segment-missing-123";
vi.mocked(getSegment).mockResolvedValue(null as unknown as TSegmentWithSurveyNames);
vi.mocked(getSegment).mockResolvedValue(null as unknown as TSegmentWithSurveyRefs);
const filters: TBaseFilters = [
{
@@ -599,7 +599,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Mock the nested segment
const mockNestedSegment: TSegmentWithSurveyNames = {
const mockNestedSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId,
filters: nestedFilters,
environmentId: mockEnvironmentId,
@@ -890,7 +890,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Set up the mocks
const mockCircularSegment: TSegmentWithSurveyNames = {
const mockCircularSegment: TSegmentWithSurveyRefs = {
id: circularSegmentId,
filters: circularFilters,
environmentId: mockEnvironmentId,
@@ -904,7 +904,7 @@ describe("segmentFilterToPrismaQuery", () => {
inactiveSurveys: [],
};
const mockSecondSegment: TSegmentWithSurveyNames = {
const mockSecondSegment: TSegmentWithSurveyRefs = {
id: secondSegmentId,
filters: secondFilters,
environmentId: mockEnvironmentId,
@@ -922,7 +922,7 @@ describe("segmentFilterToPrismaQuery", () => {
vi.mocked(getSegment)
.mockResolvedValueOnce(mockCircularSegment) // First call for circularSegmentId
.mockResolvedValueOnce(mockSecondSegment) // Third call for secondSegmentId
.mockResolvedValueOnce(null as unknown as TSegmentWithSurveyNames); // Fourth call for non-existent-segment
.mockResolvedValueOnce(null as unknown as TSegmentWithSurveyRefs); // Fourth call for non-existent-segment
// Complex filters with mixed error conditions
const filters: TBaseFilters = [
@@ -361,7 +361,7 @@ const buildSegmentFilterWhereClause = async (
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
const { root, qualifier } = filter;
const { segmentId } = root;
if (segmentPath.has(segmentId)) {
@@ -382,7 +382,22 @@ const buildSegmentFilterWhereClause = async (
const newPath = new Set(segmentPath);
newPath.add(segmentId);
return processFilters(segment.filters, newPath, environmentId, deviceType);
const nestedWhereClause = await processFilters(segment.filters, newPath, environmentId, deviceType);
const hasNestedConditions = Object.keys(nestedWhereClause).length > 0;
if (qualifier.operator === "userIsIn") {
return nestedWhereClause;
}
if (qualifier.operator === "userIsNotIn") {
if (!hasNestedConditions) {
return { id: "__SEGMENT_FILTER_NO_MATCH__" };
}
return { NOT: nestedWhereClause };
}
return {};
};
/**
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { TBaseFilters, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TBaseFilters, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { checkForRecursiveSegmentFilter } from "@/modules/ee/contacts/segments/lib/helper";
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
@@ -77,7 +77,7 @@ describe("checkForRecursiveSegmentFilter", () => {
],
};
vi.mocked(getSegment).mockResolvedValue(referencedSegment as unknown as TSegmentWithSurveyNames);
vi.mocked(getSegment).mockResolvedValue(referencedSegment as unknown as TSegmentWithSurveyRefs);
// Act & Assert
// The function should complete without throwing an error
@@ -8,7 +8,7 @@ import {
TEvaluateSegmentUserData,
TSegmentCreateInput,
TSegmentUpdateInput,
TSegmentWithSurveyNames,
TSegmentWithSurveyRefs,
} from "@formbricks/types/segment";
import { getSurvey } from "@/lib/survey/service";
import { validateInputs } from "@/lib/utils/validate";
@@ -79,10 +79,10 @@ const mockSegmentPrisma = {
surveys: [{ id: surveyId, name: "Test Survey", status: "inProgress" }],
};
const mockSegment: TSegmentWithSurveyNames = {
const mockSegment: TSegmentWithSurveyRefs = {
...mockSegmentPrisma,
surveys: [surveyId],
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -287,7 +287,7 @@ describe("Segment Service Tests", () => {
...mockSegment,
id: clonedSegmentId,
title: "Copy of Test Segment (1)",
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -327,7 +327,7 @@ describe("Segment Service Tests", () => {
const clonedSegment2 = {
...clonedSegment,
title: "Copy of Test Segment (2)",
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -415,7 +415,7 @@ describe("Segment Service Tests", () => {
title: surveyId,
isPrivate: true,
filters: [],
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -487,7 +487,7 @@ describe("Segment Service Tests", () => {
const updatedSegment = {
...mockSegment,
title: "Updated Segment",
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
const updateData: TSegmentUpdateInput = { title: "Updated Segment" };
@@ -531,7 +531,7 @@ describe("Segment Service Tests", () => {
...updatedSegment,
surveys: [newSurveyId],
activeSurveys: [],
inactiveSurveys: ["New Survey"],
inactiveSurveys: [{ id: newSurveyId, name: "New Survey" }],
};
vi.mocked(prisma.segment.update).mockResolvedValue(updatedSegmentPrismaWithSurvey);
@@ -25,7 +25,7 @@ import {
TSegmentPersonFilter,
TSegmentSegmentFilter,
TSegmentUpdateInput,
TSegmentWithSurveyNames,
TSegmentWithSurveyRefs,
ZRelativeDateValue,
ZSegmentCreateInput,
ZSegmentFilters,
@@ -66,14 +66,14 @@ export const selectSegment = {
},
} satisfies Prisma.SegmentSelect;
export const transformPrismaSegment = (segment: PrismaSegment): TSegmentWithSurveyNames => {
export const transformPrismaSegment = (segment: PrismaSegment): TSegmentWithSurveyRefs => {
const activeSurveys = segment.surveys
.filter((survey) => survey.status === "inProgress")
.map((survey) => survey.name);
.map((survey) => ({ id: survey.id, name: survey.name }));
const inactiveSurveys = segment.surveys
.filter((survey) => survey.status !== "inProgress")
.map((survey) => survey.name);
.map((survey) => ({ id: survey.id, name: survey.name }));
return {
...segment,
@@ -83,7 +83,7 @@ export const transformPrismaSegment = (segment: PrismaSegment): TSegmentWithSurv
};
};
export const getSegment = reactCache(async (segmentId: string): Promise<TSegmentWithSurveyNames> => {
export const getSegment = reactCache(async (segmentId: string): Promise<TSegmentWithSurveyRefs> => {
validateInputs([segmentId, ZId]);
try {
const segment = await prisma.segment.findUnique({
@@ -107,7 +107,7 @@ export const getSegment = reactCache(async (segmentId: string): Promise<TSegment
}
});
export const getSegments = reactCache(async (environmentId: string): Promise<TSegmentWithSurveyNames[]> => {
export const getSegments = reactCache(async (environmentId: string): Promise<TSegmentWithSurveyRefs[]> => {
validateInputs([environmentId, ZId]);
try {
const segments = await prisma.segment.findMany({
@@ -47,6 +47,7 @@ export const SegmentsPage = async ({
upgradePromptTitle={t("environments.segments.unlock_segments_title")}
upgradePromptDescription={t("environments.segments.unlock_segments_description")}>
<SegmentTable
allSegments={segments}
segments={filteredSegments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}
+5 -4
View File
@@ -21,6 +21,7 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZDeleteQuotaAction = z.object({
quotaId: ZId,
surveyId: ZId,
});
const checkQuotasEnabled = async (organizationId: string) => {
@@ -36,7 +37,7 @@ const checkQuotasEnabled = async (organizationId: string) => {
export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQuotaAction).action(
withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -48,7 +49,7 @@ export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQu
},
{
type: "projectTeam",
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
@@ -71,7 +72,7 @@ const ZUpdateQuotaAction = z.object({
export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQuotaAction).action(
withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -83,7 +84,7 @@ export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQu
},
{
type: "projectTeam",
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId),
minPermission: "readWrite",
},
],
@@ -85,6 +85,7 @@ export const QuotasCard = ({
setIsDeletingQuota(true);
const deleteQuotaActionResult = await deleteQuotaAction({
quotaId: quotaId,
surveyId: localSurvey.id,
});
if (deleteQuotaActionResult?.data) {
toast.success(t("environments.surveys.edit.quotas.quota_deleted_successfull_toast"));
@@ -10,7 +10,6 @@ import { getUserManagementAccess } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
@@ -32,6 +31,7 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
const ZUpdateInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
data: ZInviteUpdateInput,
});
@@ -39,16 +39,17 @@ export type TUpdateInviteAction = z.infer<typeof ZUpdateInviteAction>;
export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateInviteAction).action(
withAuditLogging("updated", "invite", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organizationId);
const currentUserMembership = await getMembershipByUserIdOrganizationId(
ctx.user.id,
parsedInput.organizationId
);
if (!currentUserMembership) {
throw new AuthenticationError("User not a member of this organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
@@ -67,9 +68,9 @@ export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateI
throw new OperationNotAllowedError("Managers can only invite members");
}
await checkRoleManagementPermission(organizationId);
await checkRoleManagementPermission(parsedInput.organizationId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
@@ -65,7 +65,7 @@ export function EditMembershipRole({
}
if (inviteId) {
await updateInviteAction({ inviteId: inviteId, data: { role } });
await updateInviteAction({ inviteId: inviteId, organizationId, data: { role } });
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
@@ -27,15 +27,14 @@ import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInv
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
});
export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteInviteAction).action(
withAuditLogging("deleted", "invite", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
@@ -43,7 +42,7 @@ export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteI
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
return await deleteInvite(parsedInput.inviteId);
@@ -41,7 +41,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
if (!member && invite) {
// This is an invite
const result = await deleteInviteAction({ inviteId: invite?.id });
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);
@@ -11,7 +11,7 @@ export const TagsLoading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}>
<ProjectConfigNavigation activeId="tags" />
<ProjectConfigNavigation activeId="tags" loading />
</PageHeader>
<SettingsCard
title={t("environments.workspace.tags.manage_tags")}
@@ -129,8 +129,10 @@ export const EditWelcomeCard = ({
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId}
onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => {
if (url?.[0]) {
if (url?.length) {
updateSurvey({ fileUrl: url[0] });
} else {
updateSurvey({ fileUrl: undefined });
}
}}
fileUrl={localSurvey?.welcomeCard?.fileUrl}
@@ -346,8 +346,8 @@ export const MultipleChoiceElementForm = ({
</div>
<div className="mt-2">
<div className="mt-2 flex items-center justify-between space-x-2">
<div className="flex gap-2">
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2">
{specialChoices.map((specialChoice) => {
if (element.choices.some((c) => c.id === specialChoice.id)) return null;
return (
@@ -72,6 +72,7 @@ export const SurveyMenuBar = ({
const [lastAutoSaved, setLastAutoSaved] = useState<Date | null>(null);
const isSuccessfullySavedRef = useRef(false);
const isAutoSavingRef = useRef(false);
const isSurveyPublishingRef = useRef(false);
// Refs for interval-based auto-save (to access current values without re-creating interval)
const localSurveyRef = useRef(localSurvey);
@@ -269,8 +270,8 @@ export const SurveyMenuBar = ({
// Skip if tab is not visible (no computation, no API calls for background tabs)
if (document.hidden) return;
// Skip if already saving (manual or auto)
if (isAutoSavingRef.current || isSurveySavingRef.current) return;
// Skip if already saving, publishing, or auto-saving
if (isAutoSavingRef.current || isSurveySavingRef.current || isSurveyPublishingRef.current) return;
// Check for changes using refs (avoids re-creating interval on every change)
const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current;
@@ -289,10 +290,19 @@ export const SurveyMenuBar = ({
} as unknown as TSurveyDraft);
if (updatedSurveyResponse?.data) {
const savedData = updatedSurveyResponse.data;
// If the segment changed on the server (e.g., private segment was deleted when
// switching from app to link type), update localSurvey to prevent stale segment
// references when publishing
if (!isEqual(localSurveyRef.current.segment, savedData.segment)) {
setLocalSurvey({ ...localSurveyRef.current, segment: savedData.segment });
}
// Update surveyRef (not localSurvey state) to prevent re-renders during auto-save.
// This keeps the UI stable while still tracking that changes have been saved.
// The comparison uses refs, so this prevents unnecessary re-saves.
surveyRef.current = { ...updatedSurveyResponse.data };
surveyRef.current = { ...savedData };
isSuccessfullySavedRef.current = true;
setLastAutoSaved(new Date());
}
@@ -417,11 +427,13 @@ export const SurveyMenuBar = ({
};
const handleSurveyPublish = async () => {
isSurveyPublishingRef.current = true;
setIsSurveyPublishing(true);
const isSurveyValidatedWithZod = validateSurveyWithZod();
if (!isSurveyValidatedWithZod) {
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
return;
}
@@ -429,6 +441,7 @@ export const SurveyMenuBar = ({
try {
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount);
if (!isSurveyValidResult) {
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
return;
}
@@ -445,10 +458,12 @@ export const SurveyMenuBar = ({
if (!publishResult?.data) {
const errorMessage = getFormattedErrorMessage(publishResult);
toast.error(errorMessage);
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
return;
}
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
// Set flag to prevent beforeunload warning during navigation
isSuccessfullySavedRef.current = true;
@@ -456,6 +471,7 @@ export const SurveyMenuBar = ({
} catch (error) {
console.error(error);
toast.error(t("environments.surveys.edit.error_publishing_survey"));
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
}
};
@@ -202,7 +202,7 @@ function getLanguageCode(langParam: string | undefined, survey: TSurvey): string
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.code.toLowerCase() === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
@@ -48,7 +48,7 @@ export function LanguageIndicator({
<button
aria-expanded={showLanguageDropdown}
aria-haspopup="true"
className="relative z-20 flex max-w-[120px] items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
className="relative z-20 flex max-w-20 items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
onClick={toggleDropdown}
tabIndex={-1}
type="button">
@@ -2,7 +2,7 @@
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegmentActivitySummary } from "@/modules/ee/contacts/segments/components/segment-activity-utils";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -15,16 +15,16 @@ import {
} from "@/modules/ui/components/dialog";
interface ConfirmDeleteSegmentModalProps {
activitySummary: TSegmentActivitySummary;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
segment: TSegmentWithSurveyNames;
onDelete: () => Promise<void>;
}
export const ConfirmDeleteSegmentModal = ({
activitySummary,
onDelete,
open,
segment,
setOpen,
}: ConfirmDeleteSegmentModalProps) => {
const { t } = useTranslation();
@@ -32,9 +32,9 @@ export const ConfirmDeleteSegmentModal = ({
await onDelete();
};
const segmentHasSurveys = useMemo(() => {
return segment.activeSurveys.length > 0 || segment.inactiveSurveys.length > 0;
}, [segment.activeSurveys.length, segment.inactiveSurveys.length]);
const allSurveys = useMemo(() => {
return [...activitySummary.activeSurveys, ...activitySummary.inactiveSurveys];
}, [activitySummary.activeSurveys, activitySummary.inactiveSurveys]);
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -46,16 +46,13 @@ export const ConfirmDeleteSegmentModal = ({
</DialogDescription>
</DialogHeader>
{segmentHasSurveys && (
{allSurveys.length > 0 && (
<DialogBody>
<div className="space-y-2">
<p>{t("environments.segments.cannot_delete_segment_used_in_surveys")}</p>
<ol className="my-2 ml-4 list-decimal">
{segment.activeSurveys.map((survey) => (
<li key={survey}>{survey}</li>
))}
{segment.inactiveSurveys.map((survey) => (
<li key={survey}>{survey}</li>
{allSurveys.map((surveyName) => (
<li key={surveyName}>{surveyName}</li>
))}
</ol>
</div>
@@ -69,7 +66,7 @@ export const ConfirmDeleteSegmentModal = ({
<Button variant="secondary" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={segmentHasSurveys}>
<Button variant="destructive" onClick={handleDelete} disabled={allSurveys.length > 0}>
{t("common.delete")}
</Button>
</DialogFooter>
+4 -1
View File
@@ -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 } from "@/lib/survey/widget";
import { closeSurvey, preloadSurveysScript } from "@/lib/survey/widget";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
import { sendUpdatesToBackend } from "@/lib/user/update";
import {
@@ -316,6 +316,9 @@ 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");
@@ -43,17 +43,6 @@ vi.mock("@/lib/common/utils", () => ({
handleHiddenFields: vi.fn(),
}));
const mockUpdateQueue = {
hasPendingWork: vi.fn().mockReturnValue(false),
waitForPendingWork: vi.fn().mockResolvedValue(true),
};
vi.mock("@/lib/user/update-queue", () => ({
UpdateQueue: {
getInstance: vi.fn(() => mockUpdateQueue),
},
}));
describe("widget-file", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceLoggerMock: MockInstance<() => Logger>;
@@ -260,265 +249,4 @@ describe("widget-file", () => {
widget.removeWidgetContainer();
expect(document.getElementById("formbricks-container")).toBeFalsy();
});
test("renderWidget waits for pending identification before rendering", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
const mockConfigValue = {
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(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_abc",
})
);
vi.useRealTimers();
});
test("renderWidget does not wait when no identification is pending", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
const mockConfigValue = {
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(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
test("renderWidget reads contactId after identification wait completes", async () => {
let callCount = 0;
const mockConfigValue = {
get: vi.fn().mockImplementation(() => {
callCount++;
return {
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
// Simulate contactId becoming available after identification
userId: "user_abc",
contactId: callCount > 2 ? "contact_after_identification" : undefined,
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
};
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
vi.advanceTimersByTime(0);
// The contactId passed to renderSurvey should be read after the wait
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_after_identification",
})
);
vi.useRealTimers();
});
test("renderWidget skips survey when identification fails and survey has segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
await widget.renderWidget({
...mockSurvey,
delay: 0,
segment: { id: "seg_1", filters: [{ type: "attribute", value: "plan" }] },
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed. Skipping survey with segment filters."
);
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
});
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
const mockConfigValue = {
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: null,
contactId: null,
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
segment: undefined,
} as unknown as TEnvironmentStateSurvey);
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed but survey has no segment filters. Proceeding."
);
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
});
+84 -19
View File
@@ -106,7 +106,15 @@ export const renderWidget = async (
const overlay = projectOverwrites.overlay ?? project.overlay;
const placement = projectOverwrites.placement ?? project.placement;
const isBrandingEnabled = project.inAppSurveyBranding;
const formbricksSurveys = await loadFormbricksSurveysExternally();
let formbricksSurveys: TFormbricksSurveys;
try {
formbricksSurveys = await loadFormbricksSurveysExternally();
} catch (error) {
logger.error(`Failed to load surveys library: ${String(error)}`);
setIsSurveyRunning(false);
return;
}
const recaptchaSiteKey = config.get().environment.data.recaptchaSiteKey;
const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled);
@@ -219,30 +227,87 @@ export const removeWidgetContainer = (): void => {
document.getElementById(CONTAINER_ID)?.remove();
};
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
const config = Config.getInstance();
const SURVEYS_LOAD_TIMEOUT_MS = 10000;
const SURVEYS_POLL_INTERVAL_MS = 200;
type TFormbricksSurveys = typeof globalThis.window.formbricksSurveys;
let surveysLoadPromise: Promise<TFormbricksSurveys> | null = null;
const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
return new Promise((resolve, reject) => {
// 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 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) {
const storedNonce = globalThis.window.__formbricksNonce;
if (storedNonce) {
globalThis.window.formbricksSurveys.setNonce(storedNonce);
}
resolve(globalThis.window.formbricksSurveys);
};
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);
}
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();
});
};
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);
};
@@ -169,104 +169,4 @@ describe("UpdateQueue", () => {
"Formbricks can't set attributes without a userId! Please set a userId first with the setUserId function"
);
});
test("hasPendingWork returns false when no updates and no flush in flight", () => {
expect(updateQueue.hasPendingWork()).toBe(false);
});
test("hasPendingWork returns true when updates are queued", () => {
updateQueue.updateUserId(mockUserId1);
expect(updateQueue.hasPendingWork()).toBe(true);
});
test("hasPendingWork returns true while processUpdates flush is in flight", () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
// Start processing but don't await — the debounce means the flush is in-flight
void updateQueue.processUpdates();
expect(updateQueue.hasPendingWork()).toBe(true);
});
test("waitForPendingWork returns true immediately when no pending work", async () => {
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(true);
});
test("waitForPendingWork returns true when processUpdates succeeds", async () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
void updateQueue.processUpdates();
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(true);
expect(updateQueue.hasPendingWork()).toBe(false);
expect(sendUpdates).toHaveBeenCalled();
});
test("waitForPendingWork returns false when processUpdates rejects", async () => {
loggerMock.mockReturnValue(mockLogger as unknown as Logger);
(sendUpdates as Mock).mockRejectedValue(new Error("network error"));
updateQueue.updateUserId(mockUserId1);
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally swallowing rejection to avoid unhandled promise
const processPromise = updateQueue.processUpdates().catch(() => {});
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(false);
await processPromise;
});
test("waitForPendingWork returns false when flush hangs past timeout", async () => {
vi.useFakeTimers();
// sendUpdates returns a promise that never resolves, simulating a network hang
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise
(sendUpdates as Mock).mockReturnValue(new Promise(() => {}));
updateQueue.updateUserId(mockUserId1);
void updateQueue.processUpdates();
const resultPromise = updateQueue.waitForPendingWork();
// Advance past the debounce delay (500ms) so the handler fires and hangs on sendUpdates
await vi.advanceTimersByTimeAsync(500);
// Advance past the pending work timeout (5000ms)
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
expect(result).toBe(false);
vi.useRealTimers();
});
test("processUpdates reuses pending flush instead of creating orphaned promises", async () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
// First call creates the flush promise
const firstPromise = updateQueue.processUpdates();
// Second call while first is still pending should not create a new flush
updateQueue.updateAttributes({ name: mockAttributes.name });
const secondPromise = updateQueue.processUpdates();
// Both promises should resolve (second is not orphaned)
await Promise.all([firstPromise, secondPromise]);
expect(updateQueue.hasPendingWork()).toBe(false);
});
});
@@ -1,4 +1,4 @@
import DOMPurify from "isomorphic-dompurify";
import { sanitize } from "isomorphic-dompurify";
import * as React from "react";
import { cn, stripInlineStyles } from "@/lib/utils";
@@ -39,7 +39,7 @@ function Label({
const isHtml = childrenString ? isValidHTML(strippedContent) : false;
const safeHtml =
isHtml && strippedContent
? DOMPurify.sanitize(strippedContent, {
? sanitize(strippedContent, {
ADD_ATTR: ["target"],
FORBID_ATTR: ["style"],
})
+9 -7
View File
@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from "clsx";
import DOMPurify from "isomorphic-dompurify";
import { sanitize } from "isomorphic-dompurify";
import { extendTailwindMerge } from "tailwind-merge";
const twMerge = extendTailwindMerge({
@@ -27,14 +27,16 @@ export function cn(...inputs: ClassValue[]): string {
export const stripInlineStyles = (html: string): string => {
if (!html) return html;
// Use DOMPurify to safely remove style attributes
// This is more secure than regex-based approaches and handles edge cases properly
return DOMPurify.sanitize(html, {
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
// fixed quote delimiters, so no backtracking can occur.
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
return sanitize(preStripped, {
FORBID_ATTR: ["style"],
// Preserve the target attribute (e.g. target="_blank" on links) which is not
// in DOMPurify's default allow-list but is explicitly required downstream.
ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true,
});
};
+1
View File
@@ -4,6 +4,7 @@
"baseUrl": ".",
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2020", "ES2021.String"],
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
-4
View File
@@ -9,7 +9,6 @@
"source": "en",
"targets": [
"ar",
"cs",
"da",
"de",
"es",
@@ -19,12 +18,9 @@
"it",
"ja",
"nl",
"pl",
"pt",
"ro",
"ru",
"sk",
"sr",
"sv",
"uz",
"zh-Hans"
-83
View File
@@ -1,83 +0,0 @@
{
"common": {
"and": "a",
"apply": "použít",
"auto_close_wrapper": "Automaticky uzavřít obal",
"back": "Zpět",
"close_survey": "Zavřít dotazník",
"company_logo": "Logo společnosti",
"finish": "Dokončit",
"language_switch": "Přepínač jazyka",
"next": "Další",
"open_in_new_tab": "Otevřít na nové kartě",
"people_responded": "{count, plural, one {Odpověděla 1 osoba} few {Odpověděly {count} osoby} many {Odpovědělo {count} osoby} other {Odpovědělo {count} osob}}",
"please_retry_now_or_try_again_later": "Zkus to prosím znovu teď nebo to zkus později.",
"powered_by": "Používá technologii",
"privacy_policy": "Zásady ochrany osobních údajů",
"protected_by_reCAPTCHA_and_the_Google": "Chráněno reCAPTCHA a Google",
"question": "Otázka",
"question_video": "Video otázky",
"required": "Povinné",
"respondents_will_not_see_this_card": "Respondenti tuto kartu neuvidí",
"retry": "Zkusit znovu",
"retrying": "Opakuji pokus…",
"select_option": "Vyber možnost",
"select_options": "Vyber možnosti",
"sending_responses": "Odesílám odpovědi…",
"takes_less_than_x_minutes": "{count, plural, one {Zabere méně než 1 minutu} few {Zabere méně než {count} minuty} many {Zabere méně než {count} minuty} other {Zabere méně než {count} minut}}",
"takes_x_minutes": "{count, plural, one {Zabere 1 minutu} few {Zabere {count} minuty} many {Zabere {count} minuty} other {Zabere {count} minut}}",
"takes_x_plus_minutes": "Zabere {count}+ minut",
"terms_of_service": "Smluvní podmínky",
"the_servers_cannot_be_reached_at_the_moment": "Servery momentálně nelze kontaktovat.",
"they_will_be_redirected_immediately": "Budou okamžitě přesměrováni",
"your_feedback_is_stuck": "Vaše zpětná vazba uvízla :("
},
"errors": {
"all_options_must_be_ranked": "Seřaďte prosím všechny možnosti",
"all_rows_must_be_answered": "Odpovězte prosím na všechny řádky",
"file_extension_must_be": "Přípona souboru musí být {extension}",
"file_extension_must_not_be": "Přípona souboru nesmí být {extension}",
"file_input": {
"duplicate_files": "Následující soubory jsou již nahrány: {duplicateNames}. Duplicitní soubory nejsou povoleny.",
"file_size_exceeded": "Následující soubory překračují maximální velikost {maxSizeInMB} MB a byly odstraněny: {fileNames}",
"file_size_exceeded_alert": "Soubor by měl být menší než {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nebyly vybrány žádné platné typy souborů. Vyberte prosím platný typ souboru.",
"only_one_file_can_be_uploaded_at_a_time": "Najednou lze nahrát pouze jeden soubor.",
"placeholder_text": "Klikněte nebo přetáhněte soubory k nahrání",
"upload_failed": "Nahrávání selhalo! Zkuste to prosím znovu.",
"uploading": "Nahrávám...",
"you_can_only_upload_a_maximum_of_files": "Můžete nahrát maximálně {FILE_LIMIT} souborů."
},
"invalid_device_error": {
"message": "Pokud chcete pokračovat v používání tohoto zařízení, zakažte prosím ochranu proti spamu v nastavení průzkumu.",
"title": "Toto zařízení nepodporuje ochranu proti spamu."
},
"invalid_format": "Zadejte prosím platný formát",
"is_between": "Vyberte prosím datum mezi {startDate} a {endDate}",
"is_earlier_than": "Vyberte prosím datum dřívější než {date}",
"is_greater_than": "Zadejte prosím hodnotu větší než {min}",
"is_later_than": "Prosím vyber datum pozdější než {date}",
"is_less_than": "Prosím zadej hodnotu menší než {max}",
"is_not_between": "Prosím vyber datum, které není mezi {startDate} a {endDate}",
"max_length": "Prosím zadej maximálně {max} znaků",
"max_selections": "Prosím vyber maximálně {max} možností",
"max_value": "Prosím zadej hodnotu nejvýše {max}",
"min_length": "Prosím zadej alespoň {min} znaků",
"min_selections": "Prosím vyber alespoň {min} možností",
"min_value": "Prosím zadej hodnotu alespoň {min}",
"minimum_options_ranked": "Prosím seřaď alespoň {min} možností",
"minimum_rows_answered": "Prosím odpověz alespoň na {min} řádků",
"please_enter_a_valid_email_address": "Prosím zadej platnou e-mailovou adresu",
"please_enter_a_valid_phone_number": "Prosím zadej platné telefonní číslo",
"please_enter_a_valid_url": "Prosím zadej platnou URL adresu",
"please_fill_out_this_field": "Prosím vyplň toto pole",
"recaptcha_error": {
"message": "Tvou odpověď se nepodařilo odeslat, protože byla označena jako automatizovaná aktivita. Pokud dýcháš, zkus to prosím znovu.",
"title": "Nepodařilo se nám ověřit, že jsi člověk."
},
"value_must_contain": "Hodnota musí obsahovat {value}",
"value_must_equal": "Hodnota musí být {value}",
"value_must_not_contain": "Hodnota nesmí obsahovat {value}",
"value_must_not_equal": "Hodnota nesmí být {value}"
}
}
-83
View File
@@ -1,83 +0,0 @@
{
"common": {
"and": "i",
"apply": "zastosuj",
"auto_close_wrapper": "Automatyczne zamknięcie okna",
"back": "Wstecz",
"close_survey": "Zamknij ankietę",
"company_logo": "Logo firmy",
"finish": "Zakończ",
"language_switch": "Przełącznik języka",
"next": "Dalej",
"open_in_new_tab": "Otwórz w nowej karcie",
"people_responded": "{count, plural, one {1 osoba odpowiedziała} few {{count} osoby odpowiedziały} many {{count} osób odpowiedziało} other {{count} osób odpowiedziało}}",
"please_retry_now_or_try_again_later": "Spróbuj ponownie teraz lub spróbuj później.",
"powered_by": "Powered by",
"privacy_policy": "Polityka prywatności",
"protected_by_reCAPTCHA_and_the_Google": "Chronione przez reCAPTCHA i Google",
"question": "Pytanie",
"question_video": "Wideo z pytaniem",
"required": "Wymagane",
"respondents_will_not_see_this_card": "Respondenci nie zobaczą tej karty",
"retry": "Spróbuj ponownie",
"retrying": "Ponowna próba…",
"select_option": "Wybierz opcję",
"select_options": "Wybierz opcje",
"sending_responses": "Wysyłanie odpowiedzi…",
"takes_less_than_x_minutes": "{count, plural, one {Zajmie mniej niż 1 minutę} few {Zajmie mniej niż {count} minuty} many {Zajmie mniej niż {count} minut} other {Zajmie mniej niż {count} minut}}",
"takes_x_minutes": "{count, plural, one {Zajmuje 1 minutę} few {Zajmuje {count} minuty} many {Zajmuje {count} minut} other {Zajmuje {count} minuty}}",
"takes_x_plus_minutes": "Zajmuje {count}+ minut",
"terms_of_service": "Regulamin świadczenia usług",
"the_servers_cannot_be_reached_at_the_moment": "Serwery są obecnie niedostępne.",
"they_will_be_redirected_immediately": "Zostaną przekierowani natychmiast",
"your_feedback_is_stuck": "Twoja opinia utknęła :("
},
"errors": {
"all_options_must_be_ranked": "Proszę uszeregować wszystkie opcje",
"all_rows_must_be_answered": "Proszę odpowiedzieć na wszystkie wiersze",
"file_extension_must_be": "Rozszerzenie pliku musi być {extension}",
"file_extension_must_not_be": "Rozszerzenie pliku nie może być {extension}",
"file_input": {
"duplicate_files": "Następujące pliki są już przesłane: {duplicateNames}. Duplikaty plików nie są dozwolone.",
"file_size_exceeded": "Następujące pliki przekraczają maksymalny rozmiar {maxSizeInMB} MB i zostały usunięte: {fileNames}",
"file_size_exceeded_alert": "Plik powinien mieć mniej niż {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nie wybrano prawidłowych typów plików. Proszę wybrać prawidłowy typ pliku.",
"only_one_file_can_be_uploaded_at_a_time": "Można przesłać tylko jeden plik na raz.",
"placeholder_text": "Kliknij lub przeciągnij, aby przesłać pliki",
"upload_failed": "Przesyłanie nie powiodło się! Spróbuj ponownie.",
"uploading": "Przesyłanie...",
"you_can_only_upload_a_maximum_of_files": "Możesz przesłać maksymalnie {FILE_LIMIT} plików."
},
"invalid_device_error": {
"message": "Proszę wyłączyć ochronę przed spamem w ustawieniach ankiety, aby kontynuować korzystanie z tego urządzenia.",
"title": "To urządzenie nie obsługuje ochrony przed spamem."
},
"invalid_format": "Proszę wprowadzić prawidłowy format",
"is_between": "Proszę wybrać datę między {startDate} a {endDate}",
"is_earlier_than": "Proszę wybrać datę wcześniejszą niż {date}",
"is_greater_than": "Proszę wprowadzić wartość większą niż {min}",
"is_later_than": "Wybierz datę późniejszą niż {date}",
"is_less_than": "Wprowadź wartość mniejszą niż {max}",
"is_not_between": "Wybierz datę spoza przedziału od {startDate} do {endDate}",
"max_length": "Wprowadź maksymalnie {max} znaków",
"max_selections": "Wybierz maksymalnie {max} opcji",
"max_value": "Wprowadź wartość nie większą niż {max}",
"min_length": "Wprowadź co najmniej {min} znaków",
"min_selections": "Wybierz co najmniej {min} opcji",
"min_value": "Wprowadź wartość co najmniej {min}",
"minimum_options_ranked": "Uszereguj co najmniej {min} opcji",
"minimum_rows_answered": "Odpowiedz na co najmniej {min} wierszy",
"please_enter_a_valid_email_address": "Wprowadź poprawny adres e-mail",
"please_enter_a_valid_phone_number": "Wprowadź poprawny numer telefonu",
"please_enter_a_valid_url": "Wprowadź poprawny adres URL",
"please_fill_out_this_field": "Wypełnij to pole",
"recaptcha_error": {
"message": "Nie udało się przesłać odpowiedzi, ponieważ została oznaczona jako aktywność automatyczna. Jeśli oddychasz, spróbuj ponownie.",
"title": "Nie mogliśmy zweryfikować, że jesteś człowiekiem."
},
"value_must_contain": "Wartość musi zawierać {value}",
"value_must_equal": "Wartość musi być równa {value}",
"value_must_not_contain": "Wartość nie może zawierać {value}",
"value_must_not_equal": "Wartość nie może być równa {value}"
}
}
-83
View File
@@ -1,83 +0,0 @@
{
"common": {
"and": "a",
"apply": "použiť",
"auto_close_wrapper": "Automatické zatvorenie obálky",
"back": "Späť",
"close_survey": "Zatvoriť prieskum",
"company_logo": "Logo spoločnosti",
"finish": "Dokončiť",
"language_switch": "Prepínač jazyka",
"next": "Ďalej",
"open_in_new_tab": "Otvoriť na novej karte",
"people_responded": "{count, plural, one {1 osoba odpovedala} few {{count} osoby odpovedali} many {{count} osoby odpovedalo} other {{count} osôb odpovedalo}}",
"please_retry_now_or_try_again_later": "Skús to prosím znova teraz alebo neskôr.",
"powered_by": "Poháňané",
"privacy_policy": "Zásady ochrany osobných údajov",
"protected_by_reCAPTCHA_and_the_Google": "Chránené službou reCAPTCHA a Google",
"question": "Otázka",
"question_video": "Video otázka",
"required": "Povinné",
"respondents_will_not_see_this_card": "Respondenti neuvidia túto kartu",
"retry": "Skúsiť znova",
"retrying": "Opakujem pokus…",
"select_option": "Vyber možnosť",
"select_options": "Vyber možnosti",
"sending_responses": "Odosielam odpovede…",
"takes_less_than_x_minutes": "{count, plural, one {Trvá menej ako 1 minútu} few {Trvá menej ako {count} minúty} many {Trvá menej ako {count} minúty} other {Trvá menej ako {count} minút}}",
"takes_x_minutes": "{count, plural, one {Trvá 1 minútu} few {Trvá {count} minúty} many {Trvá {count} minúty} other {Trvá {count} minút}}",
"takes_x_plus_minutes": "Trvá {count}+ minút",
"terms_of_service": "Podmienky používania",
"the_servers_cannot_be_reached_at_the_moment": "Servery momentálne nie sú dostupné.",
"they_will_be_redirected_immediately": "Budú okamžite presmerovaní",
"your_feedback_is_stuck": "Tvoja spätná väzba uviazla :("
},
"errors": {
"all_options_must_be_ranked": "Prosím, zoraď všetky možnosti",
"all_rows_must_be_answered": "Prosím, odpovedz na všetky riadky",
"file_extension_must_be": "Prípona súboru musí byť {extension}",
"file_extension_must_not_be": "Prípona súboru nesmie byť {extension}",
"file_input": {
"duplicate_files": "Nasledujúce súbory sú už nahrané: {duplicateNames}. Duplicitné súbory nie sú povolené.",
"file_size_exceeded": "Nasledujúce súbory prekračujú maximálnu veľkosť {maxSizeInMB} MB a boli odstránené: {fileNames}",
"file_size_exceeded_alert": "Súbor by mal byť menší ako {maxSizeInMB} MB",
"no_valid_file_types_selected": "Neboli vybrané žiadne platné typy súborov. Prosím, vyber platný typ súboru.",
"only_one_file_can_be_uploaded_at_a_time": "Naraz je možné nahrať len jeden súbor.",
"placeholder_text": "Klikni alebo presuň súbory sem",
"upload_failed": "Nahrávanie zlyhalo! Prosím, skús to znova.",
"uploading": "Nahráva sa...",
"you_can_only_upload_a_maximum_of_files": "Môžeš nahrať maximálne {FILE_LIMIT} súborov."
},
"invalid_device_error": {
"message": "Prosím, deaktivuj ochranu proti spamu v nastaveniach prieskumu, aby si mohol/mohla na tomto zariadení pokračovať.",
"title": "Toto zariadenie nepodporuje ochranu proti spamu."
},
"invalid_format": "Prosím, zadaj platný formát",
"is_between": "Prosím, vyber dátum medzi {startDate} a {endDate}",
"is_earlier_than": "Prosím, vyber dátum skôr ako {date}",
"is_greater_than": "Prosím, zadaj hodnotu väčšiu ako {min}",
"is_later_than": "Prosím, vyberte dátum neskorší ako {date}",
"is_less_than": "Prosím, zadajte hodnotu menšiu ako {max}",
"is_not_between": "Prosím, vyberte dátum mimo rozsahu medzi {startDate} a {endDate}",
"max_length": "Prosím, zadajte maximálne {max} znakov",
"max_selections": "Prosím, vyberte maximálne {max} možností",
"max_value": "Prosím, zadajte hodnotu najviac {max}",
"min_length": "Prosím, zadajte aspoň {min} znakov",
"min_selections": "Prosím, vyberte aspoň {min} možností",
"min_value": "Prosím, zadajte hodnotu aspoň {min}",
"minimum_options_ranked": "Prosím, zoraďte aspoň {min} možností",
"minimum_rows_answered": "Prosím, odpovedzte aspoň na {min} riadkov",
"please_enter_a_valid_email_address": "Prosím, zadajte platnú emailovú adresu",
"please_enter_a_valid_phone_number": "Prosím, zadajte platné telefónne číslo",
"please_enter_a_valid_url": "Prosím, zadajte platnú URL adresu",
"please_fill_out_this_field": "Prosím, vyplňte toto pole",
"recaptcha_error": {
"message": "Tvoju odpoveď sa nepodarilo odoslať, pretože bola označená ako automatizovaná aktivita. Ak dýchaš, skús to prosím znova.",
"title": "Nepodarilo sa nám overiť, že si človek."
},
"value_must_contain": "Hodnota musí obsahovať {value}",
"value_must_equal": "Hodnota sa musí rovnať {value}",
"value_must_not_contain": "Hodnota nesmie obsahovať {value}",
"value_must_not_equal": "Hodnota sa nesmie rovnať {value}"
}
}
-83
View File
@@ -1,83 +0,0 @@
{
"common": {
"and": "i",
"apply": "primeni",
"auto_close_wrapper": "Automatski zatvori omot",
"back": "Nazad",
"close_survey": "Zatvori anketu",
"company_logo": "Logo kompanije",
"finish": "Završi",
"language_switch": "Promena jezika",
"next": "Sledeće",
"open_in_new_tab": "Otvori u novoj kartici",
"people_responded": "{count, plural, one {1 osoba je odgovorila} few {{count} osobe su odgovorile} other {{count} osoba je odgovorilo}}",
"please_retry_now_or_try_again_later": "Pokušaj ponovo sada ili pokušaj kasnije.",
"powered_by": "Pokreće",
"privacy_policy": "Politika privatnosti",
"protected_by_reCAPTCHA_and_the_Google": "Zaštićeno pomoću reCAPTCHA i Google",
"question": "Pitanje",
"question_video": "Video pitanje",
"required": "Obavezno",
"respondents_will_not_see_this_card": "Ispitanici neće videti ovu karticu",
"retry": "Pokušaj ponovo",
"retrying": "Pokušavam ponovo…",
"select_option": "Izaberi opciju",
"select_options": "Izaberi opcije",
"sending_responses": "Šaljem odgovore…",
"takes_less_than_x_minutes": "{count, plural, one {Traje manje od 1 minuta} few {Traje manje od {count} minuta} other {Traje manje od {count} minuta}}",
"takes_x_minutes": "{count, plural, one {Traje 1 minut} few {Traje {count} minuta} other {Traje {count} minuta}}",
"takes_x_plus_minutes": "Traje {count}+ minuta",
"terms_of_service": "Uslovi korišćenja",
"the_servers_cannot_be_reached_at_the_moment": "Serveri trenutno nisu dostupni.",
"they_will_be_redirected_immediately": "Biće odmah preusmereni",
"your_feedback_is_stuck": "Tvoj komentar je zapeo :("
},
"errors": {
"all_options_must_be_ranked": "Rangiraj sve opcije",
"all_rows_must_be_answered": "Odgovori na sva pitanja",
"file_extension_must_be": "Ekstenzija fajla mora biti {extension}",
"file_extension_must_not_be": "Ekstenzija fajla ne sme biti {extension}",
"file_input": {
"duplicate_files": "Sledeći fajlovi su već otpremljeni: {duplicateNames}. Duplikati nisu dozvoljeni.",
"file_size_exceeded": "Sledeći fajl(ovi) prelaze maksimalnu veličinu od {maxSizeInMB} MB i uklonjeni su: {fileNames}",
"file_size_exceeded_alert": "Fajl mora biti manji od {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nijedan važeći tip fajla nije izabran. Izaberi važeći tip fajla.",
"only_one_file_can_be_uploaded_at_a_time": "Samo jedan fajl može biti otpremljen odjednom.",
"placeholder_text": "Klikni ili prevuci da otpremiš fajlove",
"upload_failed": "Otpremanje nije uspelo! Pokušaj ponovo.",
"uploading": "Otpremanje...",
"you_can_only_upload_a_maximum_of_files": "Možeš otpremiti maksimalno {FILE_LIMIT} fajlova."
},
"invalid_device_error": {
"message": "Isključi zaštitu od neželjenih poruka u podešavanjima ankete da bi nastavio da koristiš ovaj uređaj.",
"title": "Ovaj uređaj ne podržava zaštitu od neželjenih poruka."
},
"invalid_format": "Unesi važeći format",
"is_between": "Izaberi datum između {startDate} i {endDate}",
"is_earlier_than": "Izaberi datum pre {date}",
"is_greater_than": "Unesi vrednost veću od {min}",
"is_later_than": "Molimo izaberite datum posle {date}",
"is_less_than": "Molimo unesite vrednost manju od {max}",
"is_not_between": "Molimo izaberite datum koji nije između {startDate} i {endDate}",
"max_length": "Molimo unesite najviše {max} karaktera",
"max_selections": "Molimo izaberite najviše {max} opcija",
"max_value": "Molimo unesite vrednost ne veću od {max}",
"min_length": "Molimo unesite najmanje {min} karaktera",
"min_selections": "Molimo izaberite najmanje {min} opcija",
"min_value": "Molimo unesite vrednost od najmanje {min}",
"minimum_options_ranked": "Molimo rangirajte najmanje {min} opcija",
"minimum_rows_answered": "Molimo odgovorite na najmanje {min} redova",
"please_enter_a_valid_email_address": "Molimo unesite validnu email adresu",
"please_enter_a_valid_phone_number": "Molimo unesite validan broj telefona",
"please_enter_a_valid_url": "Molimo unesite validan URL",
"please_fill_out_this_field": "Molimo popunite ovo polje",
"recaptcha_error": {
"message": "Vaš odgovor nije mogao biti poslat jer je označen kao automatska aktivnost. Ako dišete, molimo pokušajte ponovo.",
"title": "Nismo mogli da potvrdimo da ste čovek."
},
"value_must_contain": "Vrednost mora da sadrži {value}",
"value_must_equal": "Vrednost mora biti jednaka {value}",
"value_must_not_contain": "Vrednost ne sme da sadrži {value}",
"value_must_not_equal": "Vrednost ne sme biti jednaka {value}"
}
}
@@ -59,7 +59,7 @@ export function LanguageSwitch({
handleI18nLanguage(calculatedLanguageCode);
if (setDir) {
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "auto";
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "ltr";
setDir?.(calculateDir);
}
@@ -9,12 +9,13 @@ export function RenderSurvey(props: SurveyContainerProps) {
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isRTL = isRTLLanguage(props.survey, props.languageCode);
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "auto");
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "ltr");
useEffect(() => {
const isRTL = isRTLLanguage(props.survey, props.languageCode);
setDir(isRTL ? "rtl" : "auto");
}, [props.languageCode, props.survey]);
setDir(isRTL ? "rtl" : "ltr");
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only recalculate direction when languageCode changes, not on survey auto-save
}, [props.languageCode]);
const close = () => {
if (onFinishedTimeoutRef.current) {
@@ -1,20 +1,28 @@
import { ComponentChildren } from "preact";
import { useEffect } from "preact/hooks";
import { useEffect, useRef } from "preact/hooks";
import { I18nextProvider } from "react-i18next";
import i18n from "../../lib/i18n.config";
export const I18nProvider = ({ language, children }: { language: string; children?: ComponentChildren }) => {
const isFirstRender = useRef(true);
const prevLanguage = useRef(language);
// Set language synchronously on initial render so children get the correct translations immediately.
// This is safe because all translations are pre-loaded (bundled) in i18n.config.ts.
if (i18n.language !== language) {
i18n.changeLanguage(language);
}
// Handle language prop changes after initial render
useEffect(() => {
// On subsequent renders, skip this to avoid overriding language changes made by the user via LanguageSwitch.
if (isFirstRender.current) {
if (i18n.language !== language) {
i18n.changeLanguage(language);
}
isFirstRender.current = false;
}
// Only update language when the prop itself changes, not when i18n was changed internally by user action
useEffect(() => {
if (prevLanguage.current !== language) {
i18n.changeLanguage(language);
prevLanguage.current = language;
}
}, [language]);
// work around for react-i18next not supporting preact
+8 -6
View File
@@ -10,14 +10,16 @@ import DOMPurify from "isomorphic-dompurify";
export const stripInlineStyles = (html: string): string => {
if (!html) return html;
// Use DOMPurify to safely remove style attributes
// This is more secure than regex-based approaches and handles edge cases properly
return DOMPurify.sanitize(html, {
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
// fixed quote delimiters, so no backtracking can occur.
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
return DOMPurify.sanitize(preStripped, {
FORBID_ATTR: ["style"],
// Preserve the target attribute (e.g. target="_blank" on links) which is not
// in DOMPurify's default allow-list but is explicitly required downstream.
ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true,
});
};
+10 -3
View File
@@ -41,9 +41,16 @@ export const getLocalizedValue = (
* This ensures translations are always available, even when called from API routes
*/
export const getTranslations = (languageCode: string): TFunction => {
// "default" is a Formbricks-internal language identifier, not a valid i18next locale.
// When "default" is passed, use the current i18n language (which was already resolved
// to a real locale by the I18nProvider or LanguageSwitch). Calling
// i18n.changeLanguage("default") would cause i18next to fall back to "en", resetting
// the user's selected language (see issue #7515).
const resolvedCode = languageCode === "default" ? i18n.language : languageCode;
// Ensure the language is set (i18n.changeLanguage is synchronous when resources are already loaded)
if (i18n.language !== languageCode) {
i18n.changeLanguage(languageCode);
if (i18n.language !== resolvedCode) {
i18n.changeLanguage(resolvedCode);
}
return i18n.getFixedT(languageCode);
return i18n.getFixedT(resolvedCode);
};
+7 -3
View File
@@ -357,9 +357,13 @@ export const ZSegmentCreateInput = z.object({
export type TSegmentCreateInput = z.infer<typeof ZSegmentCreateInput>;
export type TSegment = z.infer<typeof ZSegment>;
export type TSegmentWithSurveyNames = TSegment & {
activeSurveys: string[];
inactiveSurveys: string[];
export interface TSegmentSurveyReference {
id: string;
name: string;
}
export type TSegmentWithSurveyRefs = TSegment & {
activeSurveys: TSegmentSurveyReference[];
inactiveSurveys: TSegmentSurveyReference[];
};
export const ZSegmentUpdateInput = z