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
40 changed files with 535 additions and 231 deletions
+13 -1
View File
@@ -1,7 +1,19 @@
import * as Sentry from "@sentry/nextjs"; 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"; 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 () => { export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") { 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) => { export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
if (!surveyLanguages?.length || !languageCode) return "default"; 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"; return language?.default ? "default" : language?.language.code || "default";
}; };
+4 -10
View File
@@ -106,10 +106,7 @@ describe("billing actions", () => {
}); });
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1"); expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1"); expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith( expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1"); expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true }); expect(result).toEqual({ success: true });
}); });
@@ -128,10 +125,7 @@ describe("billing actions", () => {
} as any); } as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled(); expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith( expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1"); expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true }); expect(result).toEqual({ success: true });
}); });
@@ -145,7 +139,7 @@ describe("billing actions", () => {
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1"); expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1"); expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_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(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true }); expect(result).toEqual({ success: true });
}); });
@@ -165,7 +159,7 @@ describe("billing actions", () => {
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled(); expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing"); 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(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true }); expect(result).toEqual({ success: true });
}); });
+2 -2
View File
@@ -216,7 +216,7 @@ export const startHobbyAction = authenticatedActionClient
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId); throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
} }
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby"); await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId); await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true }; return { success: true };
}); });
@@ -248,7 +248,7 @@ export const startProTrialAction = authenticatedActionClient
} }
await createProTrialSubscription(parsedInput.organizationId, customerId); await createProTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial"); await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId); await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true }; return { success: true };
}); });
@@ -150,7 +150,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
await handleSetupCheckoutCompleted(event.data.object, stripe); await handleSetupCheckoutCompleted(event.data.object, stripe);
} }
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id); await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await syncOrganizationBillingFromStripe(organizationId, { await syncOrganizationBillingFromStripe(organizationId, {
id: event.id, id: event.id,
created: event.created, created: event.created,
@@ -1905,7 +1905,7 @@ describe("organization-billing", () => {
items: [{ price: "price_hobby_monthly", quantity: 1 }], items: [{ price: "price_hobby_monthly", quantity: 1 }],
metadata: { organizationId: "org_1" }, metadata: { organizationId: "org_1" },
}, },
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" } { idempotencyKey: "ensure-hobby-subscription-org_1-0" }
); );
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({ expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" }, 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.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled(); expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
@@ -458,18 +458,21 @@ const resolvePendingChangeEffectiveAt = (
const ensureHobbySubscription = async ( const ensureHobbySubscription = async (
organizationId: string, organizationId: string,
customerId: string, customerId: string,
idempotencySuffix: string subscriptionCount: number
): Promise<void> => { ): Promise<void> => {
if (!stripeClient) return; if (!stripeClient) return;
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly"); 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( await stripeClient.subscriptions.create(
{ {
customer: customerId, customer: customerId,
items: hobbyItems, items: hobbyItems,
metadata: { organizationId }, 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 ( export const reconcileCloudStripeSubscriptionsForOrganization = async (
organizationId: string, organizationId: string
idempotencySuffix = "reconcile"
): Promise<void> => { ): Promise<void> => {
const client = stripeClient; const client = stripeClient;
if (!IS_FORMBRICKS_CLOUD || !client) return; if (!IS_FORMBRICKS_CLOUD || !client) return;
@@ -1313,11 +1315,26 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
); );
await Promise.all( await Promise.all(
hobbySubscriptions.map(({ subscription }) => hobbySubscriptions.map(async ({ subscription }) => {
client.subscriptions.cancel(subscription.id, { try {
prorate: false, 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; return;
} }
@@ -1327,12 +1344,14 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies. // (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
const freshSubscriptions = await client.subscriptions.list({ const freshSubscriptions = await client.subscriptions.list({
customer: customerId, customer: customerId,
status: "active", status: "all",
limit: 1, limit: 20,
}); });
if (freshSubscriptions.data.length === 0) { const freshActive = freshSubscriptions.data.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.has(sub.status));
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
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> => { export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return; if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId); await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap"); await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await syncOrganizationBillingFromStripe(organizationId); await syncOrganizationBillingFromStripe(organizationId);
}; };
@@ -4,7 +4,7 @@ import { UsersIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; 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 { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
import { import {
Dialog, Dialog,
@@ -15,23 +15,63 @@ import {
DialogTitle, DialogTitle,
} from "@/modules/ui/components/dialog"; } from "@/modules/ui/components/dialog";
import { SegmentActivityTab } from "./segment-activity-tab"; import { SegmentActivityTab } from "./segment-activity-tab";
import { TSegmentActivitySummary } from "./segment-activity-utils";
interface EditSegmentModalProps { interface EditSegmentModalProps {
environmentId: string; environmentId: string;
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
currentSegment: TSegmentWithSurveyNames; currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
segments: TSegment[]; segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[]; contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean; isContactsEnabled: boolean;
isReadOnly: 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 = ({ export const EditSegmentModal = ({
environmentId, environmentId,
open, open,
setOpen, setOpen,
currentSegment, currentSegment,
activitySummary,
contactAttributeKeys, contactAttributeKeys,
segments, segments,
isContactsEnabled, isContactsEnabled,
@@ -40,31 +80,25 @@ export const EditSegmentModal = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(0); 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 = [ const tabs = [
{ {
title: t("common.activity"), title: t("common.activity"),
children: <SegmentActivityTab currentSegment={currentSegment} />, children: <SegmentActivityTab currentSegment={currentSegment} activitySummary={activitySummary} />,
}, },
{ {
title: t("common.settings"), 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"; "use client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment"; import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { convertDateTimeStringShort } from "@/lib/time"; import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { TSegmentActivitySummary } from "./segment-activity-utils";
interface SegmentActivityTabProps { interface SegmentActivityTabProps {
currentSegment: TSegmentWithSurveyNames; currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
} }
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => { export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentActivityTabProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { activeSurveys, inactiveSurveys } = activitySummary;
const { activeSurveys, inactiveSurveys } = currentSegment;
return ( return (
<div className="grid grid-cols-3 pb-2"> <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> <Label className="text-slate-500">{t("common.active_surveys")}</Label>
{!activeSurveys?.length && <p className="text-sm text-slate-900">-</p>} {!activeSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{activeSurveys?.map((survey, index) => ( {activeSurveys?.map((surveyName) => (
<p className="text-sm text-slate-900" key={index + survey}> <div className="py-0.5" key={surveyName}>
{survey} <p className="text-sm text-slate-900">{surveyName}</p>
</p> </div>
))} ))}
</div> </div>
<div> <div>
<Label className="text-slate-500">{t("common.inactive_surveys")}</Label> <Label className="text-slate-500">{t("common.inactive_surveys")}</Label>
{!inactiveSurveys?.length && <p className="text-sm text-slate-900">-</p>} {!inactiveSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{inactiveSurveys?.map((survey, index) => ( {inactiveSurveys?.map((surveyName) => (
<p className="text-sm text-slate-900" key={index + survey}> <div className="py-0.5" key={surveyName}>
{survey} <p className="text-sm text-slate-900">{surveyName}</p>
</p> </div>
))} ))}
</div> </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 toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; 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 { ZSegmentFilters } from "@formbricks/types/segment";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone"; 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 { ConfirmDeleteSegmentModal } from "@/modules/ui/components/confirm-delete-segment-modal";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { AddFilterModal } from "./add-filter-modal"; import { AddFilterModal } from "./add-filter-modal";
import { TSegmentActivitySummary } from "./segment-activity-utils";
import { SegmentEditor } from "./segment-editor"; import { SegmentEditor } from "./segment-editor";
interface TSegmentSettingsTabProps { interface TSegmentSettingsTabProps {
activitySummary: TSegmentActivitySummary;
environmentId: string; environmentId: string;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
initialSegment: TSegmentWithSurveyNames; initialSegment: TSegmentWithSurveyRefs;
segments: TSegment[]; segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[]; contactAttributeKeys: TContactAttributeKey[];
isReadOnly: boolean; isReadOnly: boolean;
} }
export function SegmentSettings({ export function SegmentSettings({
activitySummary,
environmentId, environmentId,
initialSegment, initialSegment,
setOpen, setOpen,
@@ -38,7 +41,7 @@ export function SegmentSettings({
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false); const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [segment, setSegment] = useState<TSegmentWithSurveyNames>(initialSegment); const [segment, setSegment] = useState<TSegmentWithSurveyRefs>(initialSegment);
const [isUpdatingSegment, setIsUpdatingSegment] = useState(false); const [isUpdatingSegment, setIsUpdatingSegment] = useState(false);
const [isDeletingSegment, setIsDeletingSegment] = useState(false); const [isDeletingSegment, setIsDeletingSegment] = useState(false);
@@ -257,9 +260,9 @@ export function SegmentSettings({
{isDeleteSegmentModalOpen ? ( {isDeleteSegmentModalOpen ? (
<ConfirmDeleteSegmentModal <ConfirmDeleteSegmentModal
activitySummary={activitySummary}
onDelete={handleDeleteSegment} onDelete={handleDeleteSegment}
open={isDeleteSegmentModalOpen} open={isDeleteSegmentModalOpen}
segment={initialSegment}
setOpen={setIsDeleteSegmentModalOpen} setOpen={setIsDeleteSegmentModalOpen}
/> />
) : null} ) : null}
@@ -4,10 +4,10 @@ import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import { UsersIcon } from "lucide-react"; import { UsersIcon } from "lucide-react";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment"; import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyNames>[] => { export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyRefs>[] => {
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = { const titleColumn: ColumnDef<TSegmentWithSurveyRefs> = {
id: "title", id: "title",
accessorKey: "title", accessorKey: "title",
header: t("common.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", id: "updatedAt",
accessorKey: "updatedAt", accessorKey: "updatedAt",
header: t("common.updated_at"), 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", id: "createdAt",
accessorKey: "createdAt", accessorKey: "createdAt",
header: t("common.created_at"), 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 { UsersIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; 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 { EditSegmentModal } from "./edit-segment-modal";
import { TSegmentActivitySummary } from "./segment-activity-utils";
type TSegmentTableDataRowProps = { type TSegmentTableDataRowProps = {
currentSegment: TSegmentWithSurveyNames; currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
segments: TSegment[]; segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[]; contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean; isContactsEnabled: boolean;
@@ -17,6 +19,7 @@ type TSegmentTableDataRowProps = {
export const SegmentTableDataRow = ({ export const SegmentTableDataRow = ({
currentSegment, currentSegment,
activitySummary,
contactAttributeKeys, contactAttributeKeys,
segments, segments,
isContactsEnabled, isContactsEnabled,
@@ -62,6 +65,7 @@ export const SegmentTableDataRow = ({
open={isEditSegmentModalOpen} open={isEditSegmentModalOpen}
setOpen={setIsEditSegmentModalOpen} setOpen={setIsEditSegmentModalOpen}
currentSegment={currentSegment} currentSegment={currentSegment}
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys} contactAttributeKeys={contactAttributeKeys}
segments={segments} segments={segments}
isContactsEnabled={isContactsEnabled} isContactsEnabled={isContactsEnabled}
@@ -4,13 +4,15 @@ import { Header, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { EditSegmentModal } from "./edit-segment-modal"; import { EditSegmentModal } from "./edit-segment-modal";
import { buildSegmentActivitySummaryFromSegments } from "./segment-activity-utils";
import { generateSegmentTableColumns } from "./segment-table-columns"; import { generateSegmentTableColumns } from "./segment-table-columns";
interface SegmentTableUpdatedProps { interface SegmentTableUpdatedProps {
segments: TSegmentWithSurveyNames[]; segments: TSegmentWithSurveyRefs[];
allSegments: TSegmentWithSurveyRefs[];
contactAttributeKeys: TContactAttributeKey[]; contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean; isContactsEnabled: boolean;
isReadOnly: boolean; isReadOnly: boolean;
@@ -18,16 +20,17 @@ interface SegmentTableUpdatedProps {
export function SegmentTable({ export function SegmentTable({
segments, segments,
allSegments,
contactAttributeKeys, contactAttributeKeys,
isContactsEnabled, isContactsEnabled,
isReadOnly, isReadOnly,
}: SegmentTableUpdatedProps) { }: Readonly<SegmentTableUpdatedProps>) {
const { t } = useTranslation(); const { t } = useTranslation();
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null); const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyRefs | null>(null);
const columns = useMemo(() => { const columns = useMemo(() => {
return generateSegmentTableColumns(t); return generateSegmentTableColumns(t);
}, []); }, [t]);
const table = useReactTable({ const table = useReactTable({
data: segments, data: segments,
@@ -35,7 +38,7 @@ export function SegmentTable({
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const getHeader = (header: Header<TSegmentWithSurveyNames, unknown>) => { const getHeader = (header: Header<TSegmentWithSurveyRefs, unknown>) => {
if (header.isPlaceholder) { if (header.isPlaceholder) {
return null; return null;
} }
@@ -136,6 +139,7 @@ export function SegmentTable({
open={!!editingSegment} open={!!editingSegment}
setOpen={(open) => !open && setEditingSegment(null)} setOpen={(open) => !open && setEditingSegment(null)}
currentSegment={editingSegment} currentSegment={editingSegment}
activitySummary={buildSegmentActivitySummaryFromSegments(editingSegment, allSegments)}
contactAttributeKeys={contactAttributeKeys} contactAttributeKeys={contactAttributeKeys}
segments={segments} segments={segments}
isContactsEnabled={isContactsEnabled} isContactsEnabled={isContactsEnabled}
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; 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 { getSegment } from "../segments";
import { segmentFilterToPrismaQuery } from "./prisma-query"; import { segmentFilterToPrismaQuery } from "./prisma-query";
@@ -270,7 +270,7 @@ describe("segmentFilterToPrismaQuery", () => {
]; ];
// Mock the getSegment function to return a segment with filters // Mock the getSegment function to return a segment with filters
const mockSegment: TSegmentWithSurveyNames = { const mockSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId, id: nestedSegmentId,
filters: nestedFilters, filters: nestedFilters,
environmentId: mockEnvironmentId, environmentId: mockEnvironmentId,
@@ -336,7 +336,7 @@ describe("segmentFilterToPrismaQuery", () => {
// Mock getSegment to return null for the non-existent segment // Mock getSegment to return null for the non-existent segment
vi.mocked(getSegment).mockResolvedValueOnce(mockSegment); 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); const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
@@ -426,7 +426,7 @@ describe("segmentFilterToPrismaQuery", () => {
]; ];
// Mock the getSegment function to return a segment with filters // Mock the getSegment function to return a segment with filters
const mockSegment: TSegmentWithSurveyNames = { const mockSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId, id: nestedSegmentId,
filters: nestedFilters, filters: nestedFilters,
environmentId: mockEnvironmentId, environmentId: mockEnvironmentId,
@@ -490,7 +490,7 @@ describe("segmentFilterToPrismaQuery", () => {
test("handle circular references in segment filters", async () => { test("handle circular references in segment filters", async () => {
// Mock getSegment to simulate a circular reference // Mock getSegment to simulate a circular reference
const circularSegment: TSegmentWithSurveyNames = { const circularSegment: TSegmentWithSurveyRefs = {
id: mockSegmentId, // Same ID creates the circular reference id: mockSegmentId, // Same ID creates the circular reference
filters: [ filters: [
{ {
@@ -550,7 +550,7 @@ describe("segmentFilterToPrismaQuery", () => {
test("handle missing segments in segment filters", async () => { test("handle missing segments in segment filters", async () => {
const nestedSegmentId = "segment-missing-123"; 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 = [ const filters: TBaseFilters = [
{ {
@@ -599,7 +599,7 @@ describe("segmentFilterToPrismaQuery", () => {
]; ];
// Mock the nested segment // Mock the nested segment
const mockNestedSegment: TSegmentWithSurveyNames = { const mockNestedSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId, id: nestedSegmentId,
filters: nestedFilters, filters: nestedFilters,
environmentId: mockEnvironmentId, environmentId: mockEnvironmentId,
@@ -890,7 +890,7 @@ describe("segmentFilterToPrismaQuery", () => {
]; ];
// Set up the mocks // Set up the mocks
const mockCircularSegment: TSegmentWithSurveyNames = { const mockCircularSegment: TSegmentWithSurveyRefs = {
id: circularSegmentId, id: circularSegmentId,
filters: circularFilters, filters: circularFilters,
environmentId: mockEnvironmentId, environmentId: mockEnvironmentId,
@@ -904,7 +904,7 @@ describe("segmentFilterToPrismaQuery", () => {
inactiveSurveys: [], inactiveSurveys: [],
}; };
const mockSecondSegment: TSegmentWithSurveyNames = { const mockSecondSegment: TSegmentWithSurveyRefs = {
id: secondSegmentId, id: secondSegmentId,
filters: secondFilters, filters: secondFilters,
environmentId: mockEnvironmentId, environmentId: mockEnvironmentId,
@@ -922,7 +922,7 @@ describe("segmentFilterToPrismaQuery", () => {
vi.mocked(getSegment) vi.mocked(getSegment)
.mockResolvedValueOnce(mockCircularSegment) // First call for circularSegmentId .mockResolvedValueOnce(mockCircularSegment) // First call for circularSegmentId
.mockResolvedValueOnce(mockSecondSegment) // Third call for secondSegmentId .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 // Complex filters with mixed error conditions
const filters: TBaseFilters = [ const filters: TBaseFilters = [
@@ -361,7 +361,7 @@ const buildSegmentFilterWhereClause = async (
environmentId: string, environmentId: string,
deviceType?: "phone" | "desktop" deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => { ): Promise<Prisma.ContactWhereInput> => {
const { root } = filter; const { root, qualifier } = filter;
const { segmentId } = root; const { segmentId } = root;
if (segmentPath.has(segmentId)) { if (segmentPath.has(segmentId)) {
@@ -382,7 +382,22 @@ const buildSegmentFilterWhereClause = async (
const newPath = new Set(segmentPath); const newPath = new Set(segmentPath);
newPath.add(segmentId); 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 { beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors"; 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 { checkForRecursiveSegmentFilter } from "@/modules/ee/contacts/segments/lib/helper";
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments"; 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 // Act & Assert
// The function should complete without throwing an error // The function should complete without throwing an error
@@ -8,7 +8,7 @@ import {
TEvaluateSegmentUserData, TEvaluateSegmentUserData,
TSegmentCreateInput, TSegmentCreateInput,
TSegmentUpdateInput, TSegmentUpdateInput,
TSegmentWithSurveyNames, TSegmentWithSurveyRefs,
} from "@formbricks/types/segment"; } from "@formbricks/types/segment";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
@@ -79,10 +79,10 @@ const mockSegmentPrisma = {
surveys: [{ id: surveyId, name: "Test Survey", status: "inProgress" }], surveys: [{ id: surveyId, name: "Test Survey", status: "inProgress" }],
}; };
const mockSegment: TSegmentWithSurveyNames = { const mockSegment: TSegmentWithSurveyRefs = {
...mockSegmentPrisma, ...mockSegmentPrisma,
surveys: [surveyId], surveys: [surveyId],
activeSurveys: ["Test Survey"], activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [], inactiveSurveys: [],
}; };
@@ -287,7 +287,7 @@ describe("Segment Service Tests", () => {
...mockSegment, ...mockSegment,
id: clonedSegmentId, id: clonedSegmentId,
title: "Copy of Test Segment (1)", title: "Copy of Test Segment (1)",
activeSurveys: ["Test Survey"], activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [], inactiveSurveys: [],
}; };
@@ -327,7 +327,7 @@ describe("Segment Service Tests", () => {
const clonedSegment2 = { const clonedSegment2 = {
...clonedSegment, ...clonedSegment,
title: "Copy of Test Segment (2)", title: "Copy of Test Segment (2)",
activeSurveys: ["Test Survey"], activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [], inactiveSurveys: [],
}; };
@@ -415,7 +415,7 @@ describe("Segment Service Tests", () => {
title: surveyId, title: surveyId,
isPrivate: true, isPrivate: true,
filters: [], filters: [],
activeSurveys: ["Test Survey"], activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [], inactiveSurveys: [],
}; };
@@ -487,7 +487,7 @@ describe("Segment Service Tests", () => {
const updatedSegment = { const updatedSegment = {
...mockSegment, ...mockSegment,
title: "Updated Segment", title: "Updated Segment",
activeSurveys: ["Test Survey"], activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [], inactiveSurveys: [],
}; };
const updateData: TSegmentUpdateInput = { title: "Updated Segment" }; const updateData: TSegmentUpdateInput = { title: "Updated Segment" };
@@ -531,7 +531,7 @@ describe("Segment Service Tests", () => {
...updatedSegment, ...updatedSegment,
surveys: [newSurveyId], surveys: [newSurveyId],
activeSurveys: [], activeSurveys: [],
inactiveSurveys: ["New Survey"], inactiveSurveys: [{ id: newSurveyId, name: "New Survey" }],
}; };
vi.mocked(prisma.segment.update).mockResolvedValue(updatedSegmentPrismaWithSurvey); vi.mocked(prisma.segment.update).mockResolvedValue(updatedSegmentPrismaWithSurvey);
@@ -25,7 +25,7 @@ import {
TSegmentPersonFilter, TSegmentPersonFilter,
TSegmentSegmentFilter, TSegmentSegmentFilter,
TSegmentUpdateInput, TSegmentUpdateInput,
TSegmentWithSurveyNames, TSegmentWithSurveyRefs,
ZRelativeDateValue, ZRelativeDateValue,
ZSegmentCreateInput, ZSegmentCreateInput,
ZSegmentFilters, ZSegmentFilters,
@@ -66,14 +66,14 @@ export const selectSegment = {
}, },
} satisfies Prisma.SegmentSelect; } satisfies Prisma.SegmentSelect;
export const transformPrismaSegment = (segment: PrismaSegment): TSegmentWithSurveyNames => { export const transformPrismaSegment = (segment: PrismaSegment): TSegmentWithSurveyRefs => {
const activeSurveys = segment.surveys const activeSurveys = segment.surveys
.filter((survey) => survey.status === "inProgress") .filter((survey) => survey.status === "inProgress")
.map((survey) => survey.name); .map((survey) => ({ id: survey.id, name: survey.name }));
const inactiveSurveys = segment.surveys const inactiveSurveys = segment.surveys
.filter((survey) => survey.status !== "inProgress") .filter((survey) => survey.status !== "inProgress")
.map((survey) => survey.name); .map((survey) => ({ id: survey.id, name: survey.name }));
return { return {
...segment, ...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]); validateInputs([segmentId, ZId]);
try { try {
const segment = await prisma.segment.findUnique({ 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]); validateInputs([environmentId, ZId]);
try { try {
const segments = await prisma.segment.findMany({ const segments = await prisma.segment.findMany({
@@ -47,6 +47,7 @@ export const SegmentsPage = async ({
upgradePromptTitle={t("environments.segments.unlock_segments_title")} upgradePromptTitle={t("environments.segments.unlock_segments_title")}
upgradePromptDescription={t("environments.segments.unlock_segments_description")}> upgradePromptDescription={t("environments.segments.unlock_segments_description")}>
<SegmentTable <SegmentTable
allSegments={segments}
segments={filteredSegments} segments={filteredSegments}
contactAttributeKeys={contactAttributeKeys} contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled} isContactsEnabled={isContactsEnabled}
@@ -11,7 +11,7 @@ export const TagsLoading = () => {
return ( return (
<PageContentWrapper> <PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}> <PageHeader pageTitle={t("common.workspace_configuration")}>
<ProjectConfigNavigation activeId="tags" /> <ProjectConfigNavigation activeId="tags" loading />
</PageHeader> </PageHeader>
<SettingsCard <SettingsCard
title={t("environments.workspace.tags.manage_tags")} title={t("environments.workspace.tags.manage_tags")}
@@ -129,8 +129,10 @@ export const EditWelcomeCard = ({
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]} allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId} environmentId={environmentId}
onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => { onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => {
if (url?.[0]) { if (url?.length) {
updateSurvey({ fileUrl: url[0] }); updateSurvey({ fileUrl: url[0] });
} else {
updateSurvey({ fileUrl: undefined });
} }
}} }}
fileUrl={localSurvey?.welcomeCard?.fileUrl} fileUrl={localSurvey?.welcomeCard?.fileUrl}
@@ -346,8 +346,8 @@ export const MultipleChoiceElementForm = ({
</div> </div>
<div className="mt-2"> <div className="mt-2">
<div className="mt-2 flex items-center justify-between space-x-2"> <div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
{specialChoices.map((specialChoice) => { {specialChoices.map((specialChoice) => {
if (element.choices.some((c) => c.id === specialChoice.id)) return null; if (element.choices.some((c) => c.id === specialChoice.id)) return null;
return ( return (
@@ -72,6 +72,7 @@ export const SurveyMenuBar = ({
const [lastAutoSaved, setLastAutoSaved] = useState<Date | null>(null); const [lastAutoSaved, setLastAutoSaved] = useState<Date | null>(null);
const isSuccessfullySavedRef = useRef(false); const isSuccessfullySavedRef = useRef(false);
const isAutoSavingRef = useRef(false); const isAutoSavingRef = useRef(false);
const isSurveyPublishingRef = useRef(false);
// Refs for interval-based auto-save (to access current values without re-creating interval) // Refs for interval-based auto-save (to access current values without re-creating interval)
const localSurveyRef = useRef(localSurvey); 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) // Skip if tab is not visible (no computation, no API calls for background tabs)
if (document.hidden) return; if (document.hidden) return;
// Skip if already saving (manual or auto) // Skip if already saving, publishing, or auto-saving
if (isAutoSavingRef.current || isSurveySavingRef.current) return; if (isAutoSavingRef.current || isSurveySavingRef.current || isSurveyPublishingRef.current) return;
// Check for changes using refs (avoids re-creating interval on every change) // Check for changes using refs (avoids re-creating interval on every change)
const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current; const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current;
@@ -289,10 +290,19 @@ export const SurveyMenuBar = ({
} as unknown as TSurveyDraft); } as unknown as TSurveyDraft);
if (updatedSurveyResponse?.data) { 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. // 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. // This keeps the UI stable while still tracking that changes have been saved.
// The comparison uses refs, so this prevents unnecessary re-saves. // The comparison uses refs, so this prevents unnecessary re-saves.
surveyRef.current = { ...updatedSurveyResponse.data }; surveyRef.current = { ...savedData };
isSuccessfullySavedRef.current = true; isSuccessfullySavedRef.current = true;
setLastAutoSaved(new Date()); setLastAutoSaved(new Date());
} }
@@ -417,11 +427,13 @@ export const SurveyMenuBar = ({
}; };
const handleSurveyPublish = async () => { const handleSurveyPublish = async () => {
isSurveyPublishingRef.current = true;
setIsSurveyPublishing(true); setIsSurveyPublishing(true);
const isSurveyValidatedWithZod = validateSurveyWithZod(); const isSurveyValidatedWithZod = validateSurveyWithZod();
if (!isSurveyValidatedWithZod) { if (!isSurveyValidatedWithZod) {
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false); setIsSurveyPublishing(false);
return; return;
} }
@@ -429,6 +441,7 @@ export const SurveyMenuBar = ({
try { try {
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount); const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount);
if (!isSurveyValidResult) { if (!isSurveyValidResult) {
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false); setIsSurveyPublishing(false);
return; return;
} }
@@ -445,10 +458,12 @@ export const SurveyMenuBar = ({
if (!publishResult?.data) { if (!publishResult?.data) {
const errorMessage = getFormattedErrorMessage(publishResult); const errorMessage = getFormattedErrorMessage(publishResult);
toast.error(errorMessage); toast.error(errorMessage);
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false); setIsSurveyPublishing(false);
return; return;
} }
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false); setIsSurveyPublishing(false);
// Set flag to prevent beforeunload warning during navigation // Set flag to prevent beforeunload warning during navigation
isSuccessfullySavedRef.current = true; isSuccessfullySavedRef.current = true;
@@ -456,6 +471,7 @@ export const SurveyMenuBar = ({
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error(t("environments.surveys.edit.error_publishing_survey")); toast.error(t("environments.surveys.edit.error_publishing_survey"));
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false); setIsSurveyPublishing(false);
} }
}; };
@@ -202,7 +202,7 @@ function getLanguageCode(langParam: string | undefined, survey: TSurvey): string
const selectedLanguage = survey.languages.find((surveyLanguage) => { const selectedLanguage = survey.languages.find((surveyLanguage) => {
return ( return (
surveyLanguage.language.code === langParam.toLowerCase() || surveyLanguage.language.code.toLowerCase() === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase() surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
); );
}); });
@@ -48,7 +48,7 @@ export function LanguageIndicator({
<button <button
aria-expanded={showLanguageDropdown} aria-expanded={showLanguageDropdown}
aria-haspopup="true" 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} onClick={toggleDropdown}
tabIndex={-1} tabIndex={-1}
type="button"> type="button">
@@ -2,7 +2,7 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { useTranslation } from "react-i18next"; 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 { Button } from "@/modules/ui/components/button";
import { import {
Dialog, Dialog,
@@ -15,16 +15,16 @@ import {
} from "@/modules/ui/components/dialog"; } from "@/modules/ui/components/dialog";
interface ConfirmDeleteSegmentModalProps { interface ConfirmDeleteSegmentModalProps {
activitySummary: TSegmentActivitySummary;
open: boolean; open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
segment: TSegmentWithSurveyNames;
onDelete: () => Promise<void>; onDelete: () => Promise<void>;
} }
export const ConfirmDeleteSegmentModal = ({ export const ConfirmDeleteSegmentModal = ({
activitySummary,
onDelete, onDelete,
open, open,
segment,
setOpen, setOpen,
}: ConfirmDeleteSegmentModalProps) => { }: ConfirmDeleteSegmentModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,9 +32,9 @@ export const ConfirmDeleteSegmentModal = ({
await onDelete(); await onDelete();
}; };
const segmentHasSurveys = useMemo(() => { const allSurveys = useMemo(() => {
return segment.activeSurveys.length > 0 || segment.inactiveSurveys.length > 0; return [...activitySummary.activeSurveys, ...activitySummary.inactiveSurveys];
}, [segment.activeSurveys.length, segment.inactiveSurveys.length]); }, [activitySummary.activeSurveys, activitySummary.inactiveSurveys]);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -46,16 +46,13 @@ export const ConfirmDeleteSegmentModal = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{segmentHasSurveys && ( {allSurveys.length > 0 && (
<DialogBody> <DialogBody>
<div className="space-y-2"> <div className="space-y-2">
<p>{t("environments.segments.cannot_delete_segment_used_in_surveys")}</p> <p>{t("environments.segments.cannot_delete_segment_used_in_surveys")}</p>
<ol className="my-2 ml-4 list-decimal"> <ol className="my-2 ml-4 list-decimal">
{segment.activeSurveys.map((survey) => ( {allSurveys.map((surveyName) => (
<li key={survey}>{survey}</li> <li key={surveyName}>{surveyName}</li>
))}
{segment.inactiveSurveys.map((survey) => (
<li key={survey}>{survey}</li>
))} ))}
</ol> </ol>
</div> </div>
@@ -69,7 +66,7 @@ export const ConfirmDeleteSegmentModal = ({
<Button variant="secondary" onClick={() => setOpen(false)}> <Button variant="secondary" onClick={() => setOpen(false)}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button variant="destructive" onClick={handleDelete} disabled={segmentHasSurveys}> <Button variant="destructive" onClick={handleDelete} disabled={allSurveys.length > 0}>
{t("common.delete")} {t("common.delete")}
</Button> </Button>
</DialogFooter> </DialogFooter>
+4 -1
View File
@@ -6,7 +6,7 @@ import { Logger } from "@/lib/common/logger";
import { getIsSetup, setIsSetup } from "@/lib/common/status"; import { getIsSetup, setIsSetup } from "@/lib/common/status";
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils"; import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state"; 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 { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
import { sendUpdatesToBackend } from "@/lib/user/update"; import { sendUpdatesToBackend } from "@/lib/user/update";
import { import {
@@ -316,6 +316,9 @@ export const setup = async (
addEventListeners(); addEventListeners();
addCleanupEventListeners(); addCleanupEventListeners();
// Preload surveys script so it's ready when a survey triggers
preloadSurveysScript(configInput.appUrl);
setIsSetup(true); setIsSetup(true);
logger.debug("Set up complete"); logger.debug("Set up complete");
+103 -19
View File
@@ -11,6 +11,7 @@ import {
handleHiddenFields, handleHiddenFields,
shouldDisplayBasedOnPercentage, shouldDisplayBasedOnPercentage,
} from "@/lib/common/utils"; } from "@/lib/common/utils";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config"; import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
import { type TTrackProperties } from "@/types/survey"; import { type TTrackProperties } from "@/types/survey";
@@ -60,6 +61,24 @@ export const renderWidget = async (
setIsSurveyRunning(true); setIsSurveyRunning(true);
// Wait for pending user identification to complete before rendering
const updateQueue = UpdateQueue.getInstance();
if (updateQueue.hasPendingWork()) {
logger.debug("Waiting for pending user identification before rendering survey");
const identificationSucceeded = await updateQueue.waitForPendingWork();
if (!identificationSucceeded) {
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
if (hasSegmentFilters) {
logger.debug("User identification failed. Skipping survey with segment filters.");
setIsSurveyRunning(false);
return;
}
logger.debug("User identification failed but survey has no segment filters. Proceeding.");
}
}
if (survey.delay) { if (survey.delay) {
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`); logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
} }
@@ -87,7 +106,15 @@ export const renderWidget = async (
const overlay = projectOverwrites.overlay ?? project.overlay; const overlay = projectOverwrites.overlay ?? project.overlay;
const placement = projectOverwrites.placement ?? project.placement; const placement = projectOverwrites.placement ?? project.placement;
const isBrandingEnabled = project.inAppSurveyBranding; 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 recaptchaSiteKey = config.get().environment.data.recaptchaSiteKey;
const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled); const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled);
@@ -200,30 +227,87 @@ export const removeWidgetContainer = (): void => {
document.getElementById(CONTAINER_ID)?.remove(); document.getElementById(CONTAINER_ID)?.remove();
}; };
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => { const SURVEYS_LOAD_TIMEOUT_MS = 10000;
const config = Config.getInstance(); 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) => { return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists const startTime = Date.now();
if (globalThis.window.formbricksSurveys) {
resolve(globalThis.window.formbricksSurveys); const check = (): void => {
} else { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
const script = document.createElement("script"); if (globalThis.window.formbricksSurveys) {
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => {
// Apply stored nonce if it was set before surveys package loaded
const storedNonce = globalThis.window.__formbricksNonce; const storedNonce = globalThis.window.__formbricksNonce;
if (storedNonce) { if (storedNonce) {
globalThis.window.formbricksSurveys.setNonce(storedNonce); globalThis.window.formbricksSurveys.setNonce(storedNonce);
} }
resolve(globalThis.window.formbricksSurveys); resolve(globalThis.window.formbricksSurveys);
}; return;
script.onerror = (error) => { }
console.error("Failed to load Formbricks Surveys library:", error);
reject(new Error(`Failed to load Formbricks Surveys library: ${error as string}`)); if (Date.now() - startTime >= SURVEYS_LOAD_TIMEOUT_MS) {
}; reject(new Error("Formbricks Surveys library did not become available within timeout"));
document.head.appendChild(script); 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);
};
+36 -1
View File
@@ -8,7 +8,9 @@ export class UpdateQueue {
private static instance: UpdateQueue | null = null; private static instance: UpdateQueue | null = null;
private updates: TUpdates | null = null; private updates: TUpdates | null = null;
private debounceTimeout: NodeJS.Timeout | null = null; private debounceTimeout: NodeJS.Timeout | null = null;
private pendingFlush: Promise<void> | null = null;
private readonly DEBOUNCE_DELAY = 500; private readonly DEBOUNCE_DELAY = 500;
private readonly PENDING_WORK_TIMEOUT = 5000;
private constructor() {} private constructor() {}
@@ -63,17 +65,45 @@ export class UpdateQueue {
return !this.updates; return !this.updates;
} }
public hasPendingWork(): boolean {
return this.updates !== null || this.pendingFlush !== null;
}
public async waitForPendingWork(): Promise<boolean> {
if (!this.hasPendingWork()) return true;
const flush = this.pendingFlush ?? this.processUpdates();
try {
const succeeded = await Promise.race([
flush.then(() => true as const),
new Promise<false>((resolve) => {
setTimeout(() => {
resolve(false);
}, this.PENDING_WORK_TIMEOUT);
}),
]);
return succeeded;
} catch {
return false;
}
}
public async processUpdates(): Promise<void> { public async processUpdates(): Promise<void> {
const logger = Logger.getInstance(); const logger = Logger.getInstance();
if (!this.updates) { if (!this.updates) {
return; return;
} }
// If a flush is already in flight, reuse it instead of creating a new promise
if (this.pendingFlush) {
return this.pendingFlush;
}
if (this.debounceTimeout) { if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout); clearTimeout(this.debounceTimeout);
} }
return new Promise((resolve, reject) => { const flushPromise = new Promise<void>((resolve, reject) => {
const handler = async (): Promise<void> => { const handler = async (): Promise<void> => {
try { try {
let currentUpdates = { ...this.updates }; let currentUpdates = { ...this.updates };
@@ -147,8 +177,10 @@ export class UpdateQueue {
} }
this.clearUpdates(); this.clearUpdates();
this.pendingFlush = null;
resolve(); resolve();
} catch (error: unknown) { } catch (error: unknown) {
this.pendingFlush = null;
logger.error( logger.error(
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}` `Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
); );
@@ -158,5 +190,8 @@ export class UpdateQueue {
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY); this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
}); });
this.pendingFlush = flushPromise;
return flushPromise;
} }
} }
@@ -1,4 +1,4 @@
import DOMPurify from "isomorphic-dompurify"; import { sanitize } from "isomorphic-dompurify";
import * as React from "react"; import * as React from "react";
import { cn, stripInlineStyles } from "@/lib/utils"; import { cn, stripInlineStyles } from "@/lib/utils";
@@ -39,7 +39,7 @@ function Label({
const isHtml = childrenString ? isValidHTML(strippedContent) : false; const isHtml = childrenString ? isValidHTML(strippedContent) : false;
const safeHtml = const safeHtml =
isHtml && strippedContent isHtml && strippedContent
? DOMPurify.sanitize(strippedContent, { ? sanitize(strippedContent, {
ADD_ATTR: ["target"], ADD_ATTR: ["target"],
FORBID_ATTR: ["style"], FORBID_ATTR: ["style"],
}) })
+9 -7
View File
@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import DOMPurify from "isomorphic-dompurify"; import { sanitize } from "isomorphic-dompurify";
import { extendTailwindMerge } from "tailwind-merge"; import { extendTailwindMerge } from "tailwind-merge";
const twMerge = extendTailwindMerge({ const twMerge = extendTailwindMerge({
@@ -27,14 +27,16 @@ export function cn(...inputs: ClassValue[]): string {
export const stripInlineStyles = (html: string): string => { export const stripInlineStyles = (html: string): string => {
if (!html) return html; if (!html) return html;
// Use DOMPurify to safely remove style attributes // Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// This is more secure than regex-based approaches and handles edge cases properly // DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
return DOMPurify.sanitize(html, { // `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"], 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"], ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true, KEEP_CONTENT: true,
}); });
}; };
+1
View File
@@ -4,6 +4,7 @@
"baseUrl": ".", "baseUrl": ".",
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2020", "ES2021.String"],
"noEmit": true, "noEmit": true,
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
@@ -59,7 +59,7 @@ export function LanguageSwitch({
handleI18nLanguage(calculatedLanguageCode); handleI18nLanguage(calculatedLanguageCode);
if (setDir) { if (setDir) {
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "auto"; const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "ltr";
setDir?.(calculateDir); setDir?.(calculateDir);
} }
@@ -9,12 +9,13 @@ export function RenderSurvey(props: SurveyContainerProps) {
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null); const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null); const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isRTL = isRTLLanguage(props.survey, props.languageCode); 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(() => { useEffect(() => {
const isRTL = isRTLLanguage(props.survey, props.languageCode); const isRTL = isRTLLanguage(props.survey, props.languageCode);
setDir(isRTL ? "rtl" : "auto"); setDir(isRTL ? "rtl" : "ltr");
}, [props.languageCode, props.survey]); // eslint-disable-next-line react-hooks/exhaustive-deps -- Only recalculate direction when languageCode changes, not on survey auto-save
}, [props.languageCode]);
const close = () => { const close = () => {
if (onFinishedTimeoutRef.current) { if (onFinishedTimeoutRef.current) {
@@ -1,20 +1,28 @@
import { ComponentChildren } from "preact"; import { ComponentChildren } from "preact";
import { useEffect } from "preact/hooks"; import { useEffect, useRef } from "preact/hooks";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import i18n from "../../lib/i18n.config"; import i18n from "../../lib/i18n.config";
export const I18nProvider = ({ language, children }: { language: string; children?: ComponentChildren }) => { 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. // 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. // This is safe because all translations are pre-loaded (bundled) in i18n.config.ts.
if (i18n.language !== language) { // On subsequent renders, skip this to avoid overriding language changes made by the user via LanguageSwitch.
i18n.changeLanguage(language); if (isFirstRender.current) {
}
// Handle language prop changes after initial render
useEffect(() => {
if (i18n.language !== language) { if (i18n.language !== language) {
i18n.changeLanguage(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]); }, [language]);
// work around for react-i18next not supporting preact // 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 => { export const stripInlineStyles = (html: string): string => {
if (!html) return html; if (!html) return html;
// Use DOMPurify to safely remove style attributes // Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// This is more secure than regex-based approaches and handles edge cases properly // DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
return DOMPurify.sanitize(html, { // `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"], 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"], ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true, 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 * This ensures translations are always available, even when called from API routes
*/ */
export const getTranslations = (languageCode: string): TFunction => { 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) // Ensure the language is set (i18n.changeLanguage is synchronous when resources are already loaded)
if (i18n.language !== languageCode) { if (i18n.language !== resolvedCode) {
i18n.changeLanguage(languageCode); 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 TSegmentCreateInput = z.infer<typeof ZSegmentCreateInput>;
export type TSegment = z.infer<typeof ZSegment>; export type TSegment = z.infer<typeof ZSegment>;
export type TSegmentWithSurveyNames = TSegment & { export interface TSegmentSurveyReference {
activeSurveys: string[]; id: string;
inactiveSurveys: string[]; name: string;
}
export type TSegmentWithSurveyRefs = TSegment & {
activeSurveys: TSegmentSurveyReference[];
inactiveSurveys: TSegmentSurveyReference[];
}; };
export const ZSegmentUpdateInput = z export const ZSegmentUpdateInput = z