mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 11:29:22 -05:00
Compare commits
17 Commits
4.9.0
...
4.8.7-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 74b679403d | |||
| 4a5404557b | |||
| fbb529d066 | |||
| e07b6fb3d1 | |||
| 3996f89f75 | |||
| 86f852ee4b | |||
| 5bf884d529 | |||
| 351cd75e57 | |||
| 2e36f0c590 | |||
| ce576b33ac | |||
| 25fd161578 | |||
| fac7369b3c | |||
| 913ab98d62 | |||
| 717a172ce0 | |||
| 8c935f20c2 | |||
| a10404ba1d | |||
| 39788ce0e1 |
@@ -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") {
|
||||||
|
|||||||
@@ -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";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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"],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user