Compare commits

..

11 Commits

Author SHA1 Message Date
Harsh Bhat b614668052 chore: remove captures, identification issues 2026-05-07 18:15:54 +05:30
Harsh Bhat 7642054967 chore: remove captures, identification issues 2026-05-07 18:14:56 +05:30
Dhruwang Jariwala 7e2c439325 feat: add PostHog group analytics and feature events (#7914)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
2026-05-07 07:49:27 +00:00
Javi Aguilar a2177eec96 fix: survey modal accessibility issues by using a focus trap (#7939) 2026-05-07 06:14:06 +00:00
Javi Aguilar 255c97854f fix: survey runtime accessibility for keyboard controls (#7927) 2026-05-06 10:21:42 +00:00
Matti Nannt d103499496 fix(security): strip sensitive survey and segment metadata from public client API (#7931)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-06 09:54:15 +00:00
Matti Nannt b863238f15 refactor: rename gethasNoOrganizations to getHasNoOrganizations (#7940)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 05:03:02 +00:00
Johannes 28280899ea fix: recover incomplete initial setup (#7912) 2026-05-05 14:28:23 +00:00
Matti Nannt bc63870289 feat: add Linear Releases integration to CI pipeline (#7921)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:17:48 +00:00
Javi Aguilar 9a04e95d15 fix: cal and open text fields a11y semantic improvements (#7936) 2026-05-05 12:31:09 +00:00
Bhagya Amarasinghe 9d9f38515d fix: omit replicas when HPA is enabled (#7934) 2026-05-05 10:32:16 +00:00
98 changed files with 1888 additions and 305 deletions
+28
View File
@@ -155,3 +155,31 @@ jobs:
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- docker-build-cloud
- helm-chart-release
- move-stable-tag
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Complete Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ github.event.release.tag_name }}
+30
View File
@@ -0,0 +1,30 @@
name: Linear Release Sync
on:
push:
branches:
- main
permissions:
contents: read
jobs:
linear-release:
name: Sync release to Linear
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Sync Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
@@ -2,6 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -41,6 +42,16 @@ const Page = async (props: ChannelPageProps) => {
const projects = await getUserProjects(session.user.id, params.organizationId);
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "surveys",
},
{ organizationId: params.organizationId }
);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -2,6 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -23,6 +24,13 @@ const Page = async (props: ModePageProps) => {
return redirect(`/auth/login`);
}
const experimentVariant =
(await getPostHogFeatureFlag(session.user.id, "onboarding-mode-experiment")) || "control";
if (experimentVariant === "remove-cx-and-surveys-mode") {
return redirect(`/organizations/${params.organizationId}/workspaces/new/channel`);
}
const t = await getTranslate();
const channelOptions = [
{
@@ -18,6 +18,7 @@ import { createProjectAction } from "@/app/(app)/environments/[environmentId]/ac
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
@@ -242,7 +243,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
survey={toJsEnvironmentStateSurvey(previewSurvey(projectName || t("common.my_product"), t))}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
@@ -7,6 +7,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -51,6 +52,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
const publicDomain = getPublicDomain();
if (searchParams.mode === "cx") {
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "cx",
},
{ organizationId: params.organizationId }
);
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -10,6 +10,7 @@ import {
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -80,6 +81,19 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
notificationSettings: updatedNotificationSettings,
});
groupIdentifyPostHog("workspace", project.id, { name: project.name });
capturePostHogEvent(
user.id,
"workspace_created",
{
organization_id: organizationId,
workspace_id: project.id,
name: project.name,
},
{ organizationId, workspaceId: project.id }
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
@@ -79,7 +79,9 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
organizationProjectsLimit={organizationProjectsLimit}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
responseCount={responseCount}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
environments={environments}
@@ -36,9 +36,11 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getAccessFlags } from "@/lib/membership/utils";
import { getPostHogClientFeatureFlag } from "@/lib/posthog/client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { TRIAL_BASE_RESPONSE_LIMIT, TrialBannerNew } from "@/modules/ee/billing/components/trial-banner-new";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
@@ -70,6 +72,7 @@ interface NavigationProps {
organizationProjectsLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
responseCount: number;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
@@ -104,6 +107,7 @@ export const MainNavigation = ({
organizationProjectsLimit,
isLicenseActive,
isAccessControlAllowed,
responseCount,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -557,11 +561,24 @@ export const MainNavigation = ({
)}
{/* Trial Days Remaining */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
{!isCollapsed &&
isFormbricksCloud &&
trialDaysRemaining !== null &&
(getPostHogClientFeatureFlag("new-trial-banner") === "test" ? (
<TrialBannerNew
trialDaysRemaining={trialDaysRemaining}
planName={organization.billing.stripe?.plan ?? "pro"}
responseCount={responseCount}
responseLimit={organization.billing.limits.monthly.responses}
baseResponseLimit={TRIAL_BASE_RESPONSE_LIMIT}
billingHref={`/environments/${environment.id}/settings/billing`}
hasPaymentMethod={organization.billing.stripe?.hasPaymentMethod}
/>
) : (
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
))}
<div className="flex flex-col">
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
@@ -2,6 +2,8 @@ import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
import { POSTHOG_KEY } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
@@ -25,6 +27,14 @@ const EnvLayout = async (props: {
return (
<>
<EnvironmentStorageHandler environmentId={params.environmentId} />
{POSTHOG_KEY && (
<PostHogGroupIdentify
organizationId={layoutData.organization.id}
organizationName={layoutData.organization.name}
workspaceId={layoutData.project.id}
workspaceName={layoutData.project.name}
/>
)}
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
@@ -4,6 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -146,6 +147,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
@@ -153,7 +155,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
organizationId,
access: [
{
type: "organization",
@@ -161,7 +163,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
projectId,
minPermission: "readWrite",
},
],
@@ -178,6 +180,18 @@ export const generatePersonalLinksAction = authenticatedActionClient
throw new UnknownError("No contacts found for the selected segment");
}
capturePostHogEvent(
ctx.user.id,
"personal_link_created",
{
organization_id: organizationId,
workspace_id: projectId,
survey_id: parsedInput.surveyId,
link_count: contactsResult.length,
},
{ organizationId, workspaceId: projectId }
);
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
@@ -1,6 +1,7 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getTranslate } from "@/lingodotdev/server";
@@ -18,7 +19,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const styling = getStyling(project, survey);
const styling = getStyling(project, toJsEnvironmentStateSurvey(survey));
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
@@ -42,18 +42,25 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
],
});
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const result = await getResponseDownloadFile(
parsedInput.surveyId,
parsedInput.format,
parsedInput.filterCriteria
);
capturePostHogEvent(ctx.user.id, "responses_exported", {
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
});
capturePostHogEvent(
ctx.user.id,
"responses_exported",
{
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
workspace_id: projectId,
},
{ organizationId, workspaceId: projectId }
);
return result;
});
@@ -43,14 +43,22 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
});
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(ctx.user.id, "integration_connected", {
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
});
capturePostHogEvent(
ctx.user.id,
"integration_connected",
{
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
},
{ organizationId, workspaceId: projectId }
);
return result;
})
@@ -12,6 +12,7 @@ describe("captureSurveyResponsePostHogEvent", () => {
const makeParams = (responseCount: number) => ({
organizationId: "org-1",
workspaceId: "ws-1",
surveyId: "survey-1",
surveyType: "link",
environmentId: "env-1",
@@ -23,15 +24,21 @@ describe("captureSurveyResponsePostHogEvent", () => {
captureSurveyResponsePostHogEvent(makeParams(1));
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
});
expect(capturePostHogEvent).toHaveBeenCalledWith(
"org-1",
"survey_response_received",
{
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
workspace_id: "ws-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
},
{ organizationId: "org-1", workspaceId: "ws-1" }
);
});
test("fires on every 100th response", async () => {
@@ -44,10 +51,20 @@ describe("captureSurveyResponsePostHogEvent", () => {
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
});
test("does NOT fire for 2nd through 99th responses", async () => {
test("fires on every 10th response up to 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 10, 50, 99]) {
for (const count of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).toHaveBeenCalledTimes(10);
});
test("does NOT fire for non-milestone responses under 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 11, 25, 49, 51, 99]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
@@ -75,7 +92,8 @@ describe("captureSurveyResponsePostHogEvent", () => {
expect.objectContaining({
is_first_response: false,
milestone: "200",
})
}),
{ organizationId: "org-1", workspaceId: "ws-1" }
);
});
});
@@ -2,6 +2,7 @@ import { capturePostHogEvent } from "@/lib/posthog";
interface SurveyResponsePostHogEventParams {
organizationId: string;
workspaceId: string;
surveyId: string;
surveyType: string;
environmentId: string;
@@ -10,24 +11,36 @@ interface SurveyResponsePostHogEventParams {
/**
* Captures a PostHog event for survey responses at milestones:
* 1st response, then every 100th (100, 200, 300, ...).
* 1st response, every 10th for the first 100 (10, 20, ..., 100),
* then every 100th (200, 300, 400, ...).
*/
export const captureSurveyResponsePostHogEvent = ({
organizationId,
workspaceId,
surveyId,
surveyType,
environmentId,
responseCount,
}: SurveyResponsePostHogEventParams): void => {
if (responseCount !== 1 && responseCount % 100 !== 0) return;
const isFirst = responseCount === 1;
const isEvery10thUnder100 = responseCount <= 100 && responseCount % 10 === 0;
const isEvery100thAbove100 = responseCount > 100 && responseCount % 100 === 0;
capturePostHogEvent(organizationId, "survey_response_received", {
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
if (!isFirst && !isEvery10thUnder100 && !isEvery100thAbove100) return;
capturePostHogEvent(
organizationId,
"survey_response_received",
{
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
workspace_id: workspaceId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
},
{ organizationId, workspaceId }
);
};
@@ -15,6 +15,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
@@ -307,9 +308,11 @@ export const POST = async (request: Request) => {
if (POSTHOG_KEY) {
const responseCount = await getResponseCountBySurveyId(surveyId);
const workspaceId = await getProjectIdFromEnvironmentId(environmentId);
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
workspaceId,
surveyId,
surveyType: survey.type,
environmentId,
@@ -12,7 +12,7 @@ import {
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -87,10 +87,18 @@ export const GET = async (req: Request) => {
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
session.user.id,
"integration_connected",
{
integration_type: "googleSheets",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
@@ -80,7 +80,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
select: {
id: true,
welcomeCard: true,
name: true,
// name intentionally omitted — internal label not needed by the SDK
questions: true,
blocks: true,
variables: true,
@@ -107,13 +107,13 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
styling: true,
status: true,
recaptcha: true,
// Fetch only what's needed to compute the minimal segment shape.
// Titles, descriptions, and filter conditions are evaluated server-side
// and must not be sent to the browser.
segment: {
include: {
surveys: {
select: {
id: true,
},
},
select: {
id: true,
filters: true,
},
},
recontactDays: true,
@@ -147,10 +147,28 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
throw new ResourceNotFoundError("project", null);
}
// Transform surveys using existing utility
const transformedSurveys = environmentData.surveys.map((survey) =>
transformPrismaSurvey<TJsEnvironmentStateSurvey>(survey)
);
// Transform surveys using the shared utility, then replace the segment with
// the minimal public shape (id + hasFilters). We null out segment before
// calling transformPrismaSurvey because that function expects a surveys[]
// relation on the segment object (used by the management API), which we
// intentionally don't fetch here.
const transformedSurveys = environmentData.surveys.map((survey) => {
const minimalSegment = survey.segment
? {
id: survey.segment.id,
hasFilters:
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
}
: null;
const { segment: _segment, ...surveyWithoutSegment } = survey;
const transformed = transformPrismaSurvey<TJsEnvironmentStateSurvey>({
...surveyWithoutSegment,
segment: null,
});
return { ...transformed, segment: minimalSegment };
});
return {
environment: {
@@ -10,6 +10,11 @@ import { capturePostHogEvent } from "@/lib/posthog";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
vi.mock("@/modules/storage/utils", () => ({ resolveStorageUrlsInObject: vi.fn((obj: unknown) => obj) }));
vi.mock("@/modules/survey/lib/utils", () => ({ transformPrismaSurvey: vi.fn() }));
// Mock dependencies
vi.mock("@/lib/cache", () => ({
cache: {
@@ -22,6 +27,9 @@ vi.mock("@formbricks/database", () => ({
environment: {
update: vi.fn(),
},
project: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
@@ -163,6 +171,7 @@ describe("getEnvironmentState", () => {
// Default mocks for successful retrieval
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId: "test-org-id" });
});
afterEach(() => {
@@ -329,11 +338,18 @@ describe("getEnvironmentState", () => {
await getEnvironmentState(environmentId);
expect(capturePostHogEvent).toHaveBeenCalledWith(environmentId, "app_connected", {
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
});
expect(capturePostHogEvent).toHaveBeenCalledWith(
environmentId,
"app_connected",
{
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
organization_id: "test-org-id",
workspace_id: "test-project-id",
},
{ organizationId: "test-org-id", workspaceId: "test-project-id" }
);
});
test("should not capture app_connected event when app setup already completed", async () => {
@@ -33,11 +33,25 @@ export const getEnvironmentState = async (
});
if (POSTHOG_KEY) {
capturePostHogEvent(environmentId, "app_connected", {
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
const workspaceId = environment.project.id;
const project = await prisma.project.findUnique({
where: { id: workspaceId },
select: { organizationId: true },
});
const organizationId = project?.organizationId;
capturePostHogEvent(
environmentId,
"app_connected",
{
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
organization_id: organizationId ?? "",
workspace_id: workspaceId,
},
organizationId ? { organizationId, workspaceId } : undefined
);
}
}
@@ -7,7 +7,7 @@ import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const getEmail = async (token: string) => {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -95,10 +95,18 @@ export const GET = withV1ApiWrapper({
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "airtable",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
@@ -13,7 +13,7 @@ import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -101,10 +101,18 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "notion",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
@@ -10,7 +10,7 @@ import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL }
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -109,10 +109,18 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "slack",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
@@ -0,0 +1,57 @@
"use client";
import posthog from "posthog-js";
import { useEffect, useRef } from "react";
interface PostHogGroupIdentifyProps {
organizationId: string;
organizationName: string;
workspaceId: string;
workspaceName: string;
}
export const PostHogGroupIdentify = ({
organizationId,
organizationName,
workspaceId,
workspaceName,
}: PostHogGroupIdentifyProps) => {
const cancelledRef = useRef(false);
useEffect(() => {
cancelledRef.current = false;
const applyGroups = () => {
posthog.group("organization", organizationId, { name: organizationName });
posthog.group("workspace", workspaceId, { name: workspaceName });
};
if (posthog.__loaded) {
applyGroups();
return;
}
// PostHogIdentify (in app layout) initialises posthog from a sibling
// useEffect; effect order isn't guaranteed, so poll briefly until loaded.
const intervalId = setInterval(() => {
if (cancelledRef.current) return;
if (posthog.__loaded) {
applyGroups();
clearInterval(intervalId);
}
}, 50);
const timeoutId = setTimeout(() => {
cancelledRef.current = true;
clearInterval(intervalId);
}, 5000);
return () => {
cancelledRef.current = true;
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [organizationId, organizationName, workspaceId, workspaceName]);
return null;
};
@@ -4,10 +4,10 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { gethasNoOrganizations } from "@/lib/instance/service";
import { getHasNoOrganizations } from "@/lib/instance/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
@@ -21,7 +21,7 @@ export const createOrganizationAction = authenticatedActionClient
.inputSchema(ZCreateOrganizationAction)
.action(
withAuditLogging("created", "organization", async ({ ctx, parsedInput }) => {
const hasNoOrganizations = await gethasNoOrganizations();
const hasNoOrganizations = await getHasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!hasNoOrganizations && !isMultiOrgEnabled) {
@@ -50,10 +50,17 @@ export const createOrganizationAction = authenticatedActionClient
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
capturePostHogEvent(ctx.user.id, "organization_created", {
organization_id: newOrganization.id,
is_first_org: hasNoOrganizations,
});
groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name });
capturePostHogEvent(
ctx.user.id,
"organization_created",
{
organization_id: newOrganization.id,
is_first_org: hasNoOrganizations,
},
{ organizationId: newOrganization.id }
);
return newOrganization;
})
+28
View File
@@ -0,0 +1,28 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { getHasNoOrganizations, getIsFreshInstance } from "@/lib/instance/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const Page = async () => {
const [session, isFreshInstance, hasNoOrganizations] = await Promise.all([
getServerSession(authOptions),
getIsFreshInstance(),
getHasNoOrganizations(),
]);
if (isFreshInstance) {
return redirect("/setup/intro");
}
if (hasNoOrganizations) {
if (session) {
return redirect("/setup/organization/create");
}
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
}
return redirect("/");
};
export default Page;
+1 -1
View File
@@ -19,7 +19,7 @@ export const getIsFreshInstance = reactCache(async (): Promise<boolean> => {
});
// Function to check if there are any organizations in the database
export const gethasNoOrganizations = reactCache(async (): Promise<boolean> => {
export const getHasNoOrganizations = reactCache(async (): Promise<boolean> => {
try {
const organizationCount = await prisma.organization.count();
return organizationCount === 0;
+81 -3
View File
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { capturePostHogEvent } from "./capture";
import { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
const mocks = vi.hoisted(() => ({
capture: vi.fn(),
groupIdentify: vi.fn(),
loggerWarn: vi.fn(),
}));
@@ -13,7 +14,7 @@ vi.mock("@formbricks/logger", () => ({
}));
vi.mock("./server", () => ({
posthogServerClient: { capture: mocks.capture },
posthogServerClient: { capture: mocks.capture, groupIdentify: mocks.groupIdentify },
}));
describe("capturePostHogEvent", () => {
@@ -32,6 +33,7 @@ describe("capturePostHogEvent", () => {
$lib: "posthog-node",
source: "server",
},
groups: undefined,
});
});
@@ -45,6 +47,41 @@ describe("capturePostHogEvent", () => {
$lib: "posthog-node",
source: "server",
},
groups: undefined,
});
});
test("includes organization and workspace groups when provided", () => {
capturePostHogEvent(
"user123",
"test_event",
{ key: "value" },
{ organizationId: "org_1", workspaceId: "ws_1" }
);
expect(mocks.capture).toHaveBeenCalledWith({
distinctId: "user123",
event: "test_event",
properties: {
key: "value",
$lib: "posthog-node",
source: "server",
},
groups: { organization: "org_1", workspace: "ws_1" },
});
});
test("includes only organization group when workspaceId missing", () => {
capturePostHogEvent("user123", "test_event", undefined, { organizationId: "org_1" });
expect(mocks.capture).toHaveBeenCalledWith({
distinctId: "user123",
event: "test_event",
properties: {
$lib: "posthog-node",
source: "server",
},
groups: { organization: "org_1" },
});
});
@@ -61,6 +98,44 @@ describe("capturePostHogEvent", () => {
});
});
describe("groupIdentifyPostHog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("calls posthog groupIdentify with correct params", () => {
groupIdentifyPostHog("organization", "org_1", { name: "Acme" });
expect(mocks.groupIdentify).toHaveBeenCalledWith({
groupType: "organization",
groupKey: "org_1",
properties: { name: "Acme" },
});
});
test("identifies workspace group", () => {
groupIdentifyPostHog("workspace", "ws_1", { name: "Marketing" });
expect(mocks.groupIdentify).toHaveBeenCalledWith({
groupType: "workspace",
groupKey: "ws_1",
properties: { name: "Marketing" },
});
});
test("does not throw when groupIdentify throws", () => {
mocks.groupIdentify.mockImplementation(() => {
throw new Error("Network error");
});
expect(() => groupIdentifyPostHog("organization", "org_1")).not.toThrow();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ error: expect.any(Error), groupType: "organization", groupKey: "org_1" },
"Failed to identify PostHog group"
);
});
});
describe("capturePostHogEvent with null client", () => {
test("no-ops when posthogServerClient is null", async () => {
vi.clearAllMocks();
@@ -74,11 +149,14 @@ describe("capturePostHogEvent with null client", () => {
posthogServerClient: null,
}));
const { capturePostHogEvent: captureWithNullClient } = await import("./capture");
const { capturePostHogEvent: captureWithNullClient, groupIdentifyPostHog: identifyWithNullClient } =
await import("./capture");
captureWithNullClient("user123", "test_event", { key: "value" });
identifyWithNullClient("organization", "org_1");
expect(mocks.capture).not.toHaveBeenCalled();
expect(mocks.groupIdentify).not.toHaveBeenCalled();
expect(mocks.loggerWarn).not.toHaveBeenCalled();
});
});
+36 -1
View File
@@ -4,10 +4,24 @@ import { posthogServerClient } from "./server";
type PostHogEventProperties = Record<string, string | number | boolean | null | undefined>;
export type PostHogGroupContext = {
organizationId?: string;
workspaceId?: string;
};
const buildGroups = (context?: PostHogGroupContext): Record<string, string> | undefined => {
if (!context) return undefined;
const groups: Record<string, string> = {};
if (context.organizationId) groups.organization = context.organizationId;
if (context.workspaceId) groups.workspace = context.workspaceId;
return Object.keys(groups).length > 0 ? groups : undefined;
};
export function capturePostHogEvent(
distinctId: string,
eventName: string,
properties?: PostHogEventProperties
properties?: PostHogEventProperties,
groupContext?: PostHogGroupContext
): void {
if (!posthogServerClient) return;
@@ -20,8 +34,29 @@ export function capturePostHogEvent(
$lib: "posthog-node",
source: "server",
},
groups: buildGroups(groupContext),
});
} catch (error) {
logger.warn({ error, eventName }, "Failed to capture PostHog event");
}
}
type PostHogGroupType = "organization" | "workspace";
export function groupIdentifyPostHog(
groupType: PostHogGroupType,
groupKey: string,
properties?: Record<string, string | number | boolean | null | undefined>
): void {
if (!posthogServerClient) return;
try {
posthogServerClient.groupIdentify({
groupType,
groupKey,
properties,
});
} catch (error) {
logger.warn({ error, groupType, groupKey }, "Failed to identify PostHog group");
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import "server-only";
export { capturePostHogEvent } from "./capture";
export { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
export type { PostHogGroupContext } from "./capture";
export { getPostHogFeatureFlag } from "./get-feature-flag";
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
+15
View File
@@ -0,0 +1,15 @@
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
/**
* Adapts a full management-side `TSurvey` into the minimal
* `TJsEnvironmentStateSurvey` shape that the SDK widget / shared SDK utilities
* expect. Only the segment shape needs reshaping — the rest of `TSurvey` is a
* structural superset of the SDK survey type.
*/
export const toJsEnvironmentStateSurvey = (survey: TSurvey): TJsEnvironmentStateSurvey => {
return {
...survey,
segment: survey.segment ? { id: survey.segment.id, hasFilters: survey.segment.filters.length > 0 } : null,
} as unknown as TJsEnvironmentStateSurvey;
};
+6 -3
View File
@@ -456,6 +456,9 @@
"title": "Title",
"top_left": "Top Left",
"top_right": "Top Right",
"trial_banner_days_remaining": "{count} days left",
"trial_banner_expired": "Trial expired",
"trial_banner_one_day_remaining": "1 day left",
"trial_days_remaining": "{count} days left in your trial",
"trial_expired": "Your trial has expired",
"trial_one_day_remaining": "1 day left in your trial",
@@ -2429,11 +2432,13 @@
"s": {
"check_inbox_or_spam": "Please also check your spam folder if you do not see the email in your inbox.",
"completed": "This survey is closed.",
"completed_heading": "Completed",
"create_your_own": "Create your own open-source survey",
"enter_pin": "This survey is protected. Enter the PIN below.",
"just_curious": "Just curious?",
"link_invalid": "This survey can only be taken by invitation.",
"paused": "This survey is temporarily paused.",
"paused_heading": "Paused",
"please_try_again_with_the_original_link": "Please try again with the original link",
"preview_survey_questions": "Preview survey questions.",
"question_preview": "Question Preview",
@@ -2447,9 +2452,7 @@
"verify_email_before_submission": "Verify your email to respond",
"verify_email_before_submission_button": "Verify",
"verify_email_before_submission_description": "To respond to this survey, please verify your email",
"want_to_respond": "Want to respond?",
"paused_heading": "Paused",
"completed_heading": "Completed"
"want_to_respond": "Want to respond?"
},
"setup": {
"intro": {
@@ -4,6 +4,7 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { verifyUserPassword } from "@/lib/user/password";
import { deleteUser, getUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -80,6 +81,8 @@ export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown(
await deleteUser(ctx.user.id);
capturePostHogEvent(ctx.user.id, "delete_account");
return { success: true };
} catch (error) {
logAccountDeletionError(ctx.user.id, error);
+35 -12
View File
@@ -13,8 +13,8 @@ import {
} from "@/lib/constants";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { actionClient } from "@/lib/utils/action-client";
import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
import { createUser, updateUser } from "@/modules/auth/lib/user";
@@ -116,6 +116,15 @@ async function handleInviteAcceptance(
role: invite.role,
});
try {
const invitedOrganization = await getOrganization(invite.organizationId);
if (invitedOrganization) {
groupIdentifyPostHog("organization", invitedOrganization.id, { name: invitedOrganization.name });
}
} catch (error) {
logger.warn({ error, organizationId: invite.organizationId }, "Failed to identify org group in PostHog");
}
if (invite.teamIds) {
await createTeamMembership(
{
@@ -166,10 +175,17 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
});
}
capturePostHogEvent(user.id, "organization_created", {
organization_id: organization.id,
is_first_org: true,
});
groupIdentifyPostHog("organization", organization.id, { name: organization.name });
capturePostHogEvent(
user.id,
"organization_created",
{
organization_id: organization.id,
is_first_org: true,
},
{ organizationId: organization.id }
);
await updateUser(user.id, {
notificationSettings: {
@@ -236,12 +252,19 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
});
capturePostHogEvent(user.id, "user_signed_up", {
auth_provider: "credentials",
email_domain: user.email.split("@")[1],
signup_source: parsedInput.inviteToken ? "invite" : "direct",
invite_organization_id: ctx.auditLoggingCtx.organizationId ?? null,
});
capturePostHogEvent(
user.id,
"user_signed_up",
{
auth_provider: "credentials",
email_domain: user.email.split("@")[1],
signup_source: parsedInput.inviteToken ? "invite" : "direct",
invite_organization_id: ctx.auditLoggingCtx.organizationId ?? null,
},
ctx.auditLoggingCtx.organizationId
? { organizationId: ctx.auditLoggingCtx.organizationId }
: undefined
);
}
if (user) {
+26 -11
View File
@@ -220,9 +220,14 @@ export const startHobbyAction = authenticatedActionClient
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
capturePostHogEvent(ctx.user.id, "stayed_on_hobby_plan", {
organization_id: parsedInput.organizationId,
});
capturePostHogEvent(
ctx.user.id,
"stayed_on_hobby_plan",
{
organization_id: parsedInput.organizationId,
},
{ organizationId: parsedInput.organizationId }
);
return { success: true };
});
@@ -257,15 +262,25 @@ export const startProTrialAction = authenticatedActionClient
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
capturePostHogEvent(ctx.user.id, "free_trial_started", {
plan: "pro",
organization_id: parsedInput.organizationId,
trial_duration_days: 14,
});
capturePostHogEvent(
ctx.user.id,
"free_trial_started",
{
plan: "pro",
organization_id: parsedInput.organizationId,
trial_duration_days: 14,
},
{ organizationId: parsedInput.organizationId }
);
capturePostHogEvent(ctx.user.id, "reverse_trial_started", {
organization_id: parsedInput.organizationId,
});
capturePostHogEvent(
ctx.user.id,
"reverse_trial_started",
{
organization_id: parsedInput.organizationId,
},
{ organizationId: parsedInput.organizationId }
);
return { success: true };
});
@@ -422,11 +422,6 @@ export const PricingTable = ({
<AlertDescription>
{t("environments.settings.billing.trial_alert_description")}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void openTrialPaymentCheckout()}>
{t("environments.settings.billing.add_payment_method")}
</AlertButton>
)}
</TrialAlert>
))}
@@ -0,0 +1,84 @@
"use client";
import Link from "next/link";
import posthog from "posthog-js";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
export const TRIAL_BASE_RESPONSE_LIMIT = 250;
interface TrialBannerNewProps {
trialDaysRemaining: number;
planName: string;
responseCount: number;
responseLimit: number | null;
baseResponseLimit?: number | null;
billingHref: string;
hasPaymentMethod?: boolean;
}
export const TrialBannerNew = ({
trialDaysRemaining,
planName,
responseCount,
responseLimit,
baseResponseLimit,
billingHref,
hasPaymentMethod = false,
}: TrialBannerNewProps) => {
const { t } = useTranslation();
const daysLabel = useMemo(() => {
if (trialDaysRemaining <= 0) return t("common.trial_banner_expired");
if (trialDaysRemaining === 1) return t("common.trial_banner_one_day_remaining");
return t("common.trial_banner_days_remaining", { count: trialDaysRemaining });
}, [trialDaysRemaining, t]);
const formattedPlanName = planName.charAt(0).toUpperCase() + planName.slice(1);
const usagePercent = responseLimit ? Math.min((responseCount / responseLimit) * 100, 100) : 0;
const markerPercent =
responseLimit && baseResponseLimit ? Math.min((baseResponseLimit / responseLimit) * 100, 100) : null;
return (
<div className="m-2">
<div className="rounded-lg border border-slate-200 bg-white p-3">
<div className="mb-2.5 flex items-center gap-2">
<span className="text-sm font-semibold text-slate-800">{daysLabel}</span>
<Badge text={`${formattedPlanName} Trial`} type="gray" size="tiny" />
</div>
{responseLimit !== null && (
<>
<p className="mb-1.5 text-xs text-slate-500">
{responseCount.toLocaleString()} /{" "}
{baseResponseLimit && <s className="mr-1">{baseResponseLimit.toLocaleString()}</s>}
{responseLimit.toLocaleString()} {t("common.responses").toLowerCase()}
</p>
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-blue-500 transition-all"
style={{ width: `${usagePercent}%` }}
/>
{markerPercent !== null && (
<div className="absolute inset-y-0 w-px bg-slate-400" style={{ left: `${markerPercent}%` }} />
)}
</div>
</>
)}
{!hasPaymentMethod && (
<Button
asChild
variant="outline"
size="sm"
className="mt-3 w-full"
onClick={() => posthog.capture("main_nav_add_payment_clicked")}>
<Link href={billingHref}>{t("environments.settings.billing.add_payment_method")}</Link>
</Button>
)}
</div>
</div>
);
};
@@ -3,6 +3,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
@@ -54,6 +55,17 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient
throw new InvalidInputError(errorMessage);
}
capturePostHogEvent(
ctx.user.id,
"personal_link_created",
{
organization_id: organizationId,
workspace_id: projectId,
survey_id: parsedInput.surveyId,
},
{ organizationId, workspaceId: projectId }
);
return {
surveyUrl: result.data,
};
+20
View File
@@ -1,9 +1,11 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
@@ -113,6 +115,10 @@ export const createContactsFromCSVAction = authenticatedActionClient
});
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const existingContactCount = await prisma.contact.count({
where: { environmentId: parsedInput.environmentId },
});
const result = await createContactsFromCSV(
parsedInput.csvData,
parsedInput.environmentId,
@@ -124,6 +130,20 @@ export const createContactsFromCSVAction = authenticatedActionClient
ctx.auditLoggingCtx.newObject = {
contacts: result.contacts,
};
capturePostHogEvent(
ctx.user.id,
"contact_created",
{
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
existing_contact_count: existingContactCount,
creation_method: "import",
import_count: result.contacts.length,
},
{ organizationId, workspaceId: projectId }
);
}
return result;
@@ -4,6 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -62,6 +63,18 @@ export const createContactAttributeKeyAction = authenticatedActionClient
ctx.auditLoggingCtx.newObject = contactAttributeKey;
capturePostHogEvent(
ctx.user.id,
"contact_attribute_key_created",
{
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
key: parsedInput.key,
},
{ organizationId, workspaceId: projectId }
);
return contactAttributeKey;
})
);
@@ -5,6 +5,7 @@ import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { loadNewSegmentInSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -59,6 +60,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
// Set the organizationId in the context to be used in the audit log
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
@@ -71,7 +73,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
projectId,
},
],
});
@@ -92,6 +94,18 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
ctx.auditLoggingCtx.segmentId = segment.id;
ctx.auditLoggingCtx.newObject = segment;
capturePostHogEvent(
ctx.user?.id ?? "",
"segment_created",
{
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
is_private: parsedInput.isPrivate ?? false,
},
{ organizationId, workspaceId: projectId }
);
return segment;
})
);
@@ -3,6 +3,7 @@ import { Prisma, Response } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TSurveyQuota } from "@formbricks/types/quota";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { getSurvey } from "@/lib/survey/service";
import { getQuotas } from "./quotas";
import { evaluateQuotas, handleQuotas } from "./utils";
@@ -52,7 +53,13 @@ export const evaluateResponseQuotas = async (input: QuotaEvaluationInput): Promi
return { shouldEndSurvey: false };
}
const isDefaultLanguage = survey.languages.find((lang) => lang.default)?.language.code === language;
const result = evaluateQuotas(survey, data, variables, quotas, isDefaultLanguage ? "default" : language);
const result = evaluateQuotas(
toJsEnvironmentStateSurvey(survey),
data,
variables,
quotas,
isDefaultLanguage ? "default" : language
);
const quotaFull = await handleQuotas(surveyId, responseId, result, responseFinished, prismaClient);
@@ -4,6 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
@@ -64,9 +65,39 @@ export const updateProjectBrandingAction = authenticatedActionClient.inputSchema
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = parsedInput.projectId;
ctx.auditLoggingCtx.oldObject = await getProject(parsedInput.projectId);
const oldProject = await getProject(parsedInput.projectId);
ctx.auditLoggingCtx.oldObject = oldProject;
const result = await updateProjectBranding(parsedInput.projectId, parsedInput.data);
ctx.auditLoggingCtx.newObject = await getProject(parsedInput.projectId);
const groupContext = { organizationId, workspaceId: parsedInput.projectId };
if (oldProject?.linkSurveyBranding === true && parsedInput.data.linkSurveyBranding === false) {
capturePostHogEvent(
ctx.user.id,
"remove_branding_enabled",
{
organization_id: organizationId,
workspace_id: parsedInput.projectId,
branding_type: "link",
},
groupContext
);
}
if (oldProject?.inAppSurveyBranding === true && parsedInput.data.inAppSurveyBranding === false) {
capturePostHogEvent(
ctx.user.id,
"remove_branding_enabled",
{
organization_id: organizationId,
workspace_id: parsedInput.projectId,
branding_type: "in_app",
},
groupContext
);
}
return result;
})
);
@@ -4,6 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { generateWebhookSecret } from "@/lib/crypto";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
@@ -31,6 +32,7 @@ const ZCreateWebhookAction = z.object({
export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreateWebhookAction).action(
withAuditLogging("created", "webhook", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
@@ -42,7 +44,7 @@ export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreate
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
projectId,
},
],
});
@@ -53,6 +55,19 @@ export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreate
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.newObject = parsedInput.webhookInput;
capturePostHogEvent(
ctx.user.id,
"integration_connected",
{
integration_type: "webhook",
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
},
{ organizationId, workspaceId: projectId }
);
return webhook;
})
);
+26 -1
View File
@@ -7,6 +7,7 @@ import { TUserNotificationSettings } from "@formbricks/types/user";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -47,10 +48,34 @@ export const createOrganizationAction = authenticatedActionClient
});
}
await createProject(newOrganization.id, {
const newProject = await createProject(newOrganization.id, {
name: "My Project",
});
groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name });
groupIdentifyPostHog("workspace", newProject.id, { name: newProject.name });
capturePostHogEvent(
ctx.user.id,
"organization_created",
{
organization_id: newOrganization.id,
is_first_org: false,
},
{ organizationId: newOrganization.id, workspaceId: newProject.id }
);
capturePostHogEvent(
ctx.user.id,
"workspace_created",
{
organization_id: newOrganization.id,
workspace_id: newProject.id,
name: newProject.name,
},
{ organizationId: newOrganization.id, workspaceId: newProject.id }
);
const updatedNotificationSettings: TUserNotificationSettings = {
...ctx.user.notificationSettings,
alert: {
@@ -328,10 +328,15 @@ export const inviteUserAction = authenticatedActionClient.inputSchema(ZInviteUse
await sendInviteMemberEmail(inviteId, parsedInput.email, ctx.user.name ?? "", parsedInput.name ?? "");
}
capturePostHogEvent(ctx.user.id, "team_member_invited", {
organization_id: parsedInput.organizationId,
invitee_role: parsedInput.role,
});
capturePostHogEvent(
ctx.user.id,
"team_member_invited",
{
organization_id: parsedInput.organizationId,
invitee_role: parsedInput.role,
},
{ organizationId: parsedInput.organizationId }
);
return inviteId;
})
@@ -6,6 +6,7 @@ import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/typ
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getProject } from "@/lib/project/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -72,6 +73,35 @@ export const updateProjectAction = authenticatedActionClient.inputSchema(ZUpdate
const result = await updateProject(parsedInput.projectId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
const groupContext = { organizationId, workspaceId: parsedInput.projectId };
if (oldObject?.linkSurveyBranding === true && parsedInput.data.linkSurveyBranding === false) {
capturePostHogEvent(
ctx.user.id,
"remove_branding_enabled",
{
organization_id: organizationId,
workspace_id: parsedInput.projectId,
branding_type: "link",
},
groupContext
);
}
if (oldObject?.inAppSurveyBranding === true && parsedInput.data.inAppSurveyBranding === false) {
capturePostHogEvent(
ctx.user.id,
"remove_branding_enabled",
{
organization_id: organizationId,
workspace_id: parsedInput.projectId,
branding_type: "in_app",
},
groupContext
);
}
return result;
})
);
@@ -1,14 +1,29 @@
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { getIsFreshInstance } from "@/lib/instance/service";
import { notFound, redirect } from "next/navigation";
import { getHasNoOrganizations, getIsFreshInstance } from "@/lib/instance/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const FreshInstanceLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession(authOptions);
const isFreshInstance = await getIsFreshInstance();
if (session || !isFreshInstance) {
if (!isFreshInstance) {
const hasNoOrganizations = await getHasNoOrganizations();
if (hasNoOrganizations) {
if (session) {
return redirect("/setup/organization/create");
}
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
}
return notFound();
}
if (session) {
return notFound();
}
return <>{children}</>;
};
@@ -3,7 +3,7 @@ import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { gethasNoOrganizations } from "@/lib/instance/service";
import { getHasNoOrganizations } from "@/lib/instance/service";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -29,7 +29,7 @@ export const CreateOrganizationPage = async () => {
return <ClientLogout />;
}
const hasNoOrganizations = await gethasNoOrganizations();
const hasNoOrganizations = await getHasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const userOrganizations = await getOrganizationsByUserId(session.user.id);
@@ -66,18 +66,26 @@ export const createSurveyAction = authenticatedActionClient.inputSchema(ZCreateS
await checkSurveyFollowUpsPermission(organizationId);
}
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const result = await createSurvey(parsedInput.environmentId, parsedInput.surveyBody);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = result.id;
ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(ctx.user.id, "survey_created", {
survey_id: result.id,
survey_type: result.type,
organization_id: organizationId,
question_count: result.questions?.length ?? 0,
created_from: parsedInput.createdFrom ?? "template",
});
capturePostHogEvent(
ctx.user.id,
"survey_created",
{
survey_id: result.id,
survey_type: result.type,
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
question_count: result.questions?.length ?? 0,
created_from: parsedInput.createdFrom ?? "template",
},
{ organizationId, workspaceId: projectId }
);
return result;
})
+192 -17
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyVariable, ZSurvey } from "@formbricks/types/surveys/types";
import { POSTHOG_KEY, UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
@@ -28,6 +28,138 @@ import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
import { getProject, getProjectLanguages } from "./lib/project";
type SurveyEditDiffContext = {
userId: string;
surveyId: string;
organizationId: string;
workspaceId: string;
environmentId: string;
};
const captureSurveyEditDiffEvents = (
oldSurvey: TSurvey | null,
newSurvey: TSurvey,
context: SurveyEditDiffContext
): void => {
if (!oldSurvey) return;
const groupContext = { organizationId: context.organizationId, workspaceId: context.workspaceId };
const baseProps = {
organization_id: context.organizationId,
workspace_id: context.workspaceId,
environment_id: context.environmentId,
survey_id: context.surveyId,
};
// hidden_field_added
const oldFieldIds = new Set(oldSurvey.hiddenFields?.fieldIds ?? []);
const newFieldIds = newSurvey.hiddenFields?.fieldIds ?? [];
const addedFieldIds = newFieldIds.filter((id) => !oldFieldIds.has(id));
if (addedFieldIds.length > 0) {
capturePostHogEvent(
context.userId,
"hidden_field_added",
{ ...baseProps, field_count: newFieldIds.length },
groupContext
);
}
// conditional_logic_added (per block)
const oldBlocks = oldSurvey.blocks ?? [];
const newBlocks = newSurvey.blocks ?? [];
const oldBlockLogic = new Map<string, number>(
oldBlocks.map((b) => [b.id, (b.logic?.length ?? 0) + (b.logicFallback ? 1 : 0)])
);
for (const block of newBlocks) {
const newLogicCount = (block.logic?.length ?? 0) + (block.logicFallback ? 1 : 0);
const oldLogicCount = oldBlockLogic.get(block.id) ?? 0;
if (newLogicCount > oldLogicCount) {
capturePostHogEvent(
context.userId,
"conditional_logic_added",
{ ...baseProps, question_id: block.id },
groupContext
);
}
}
// variable_created
const oldVariableIds = new Set((oldSurvey.variables ?? []).map((v: TSurveyVariable) => v.id));
for (const variable of newSurvey.variables ?? []) {
if (!oldVariableIds.has(variable.id)) {
capturePostHogEvent(
context.userId,
"variable_created",
{ ...baseProps, variable_type: variable.type },
groupContext
);
}
}
// survey_language_enabled / survey_language_added
const oldLanguages = oldSurvey.languages ?? [];
const newLanguages = newSurvey.languages ?? [];
const oldLanguageCodes = new Set(oldLanguages.map((l) => l.language.code));
const addedLanguages = newLanguages.filter((l) => !oldLanguageCodes.has(l.language.code));
if (addedLanguages.length > 0) {
const wasMultiLangBefore = oldLanguages.length > 1;
let currentCount = oldLanguages.length;
if (!wasMultiLangBefore) {
const [first, ...rest] = addedLanguages;
capturePostHogEvent(
context.userId,
"survey_language_enabled",
{ ...baseProps, language_code: first.language.code, existing_language_count: currentCount },
groupContext
);
currentCount++;
for (const lang of rest) {
capturePostHogEvent(
context.userId,
"survey_language_added",
{
...baseProps,
language_code: lang.language.code,
existing_language_count: currentCount,
},
groupContext
);
currentCount++;
}
} else {
for (const lang of addedLanguages) {
capturePostHogEvent(
context.userId,
"survey_language_added",
{
...baseProps,
language_code: lang.language.code,
existing_language_count: currentCount,
},
groupContext
);
currentCount++;
}
}
}
// follow_up_added
const oldFollowUpIds = new Set((oldSurvey.followUps ?? []).map((f) => f.id));
const newFollowUps = (newSurvey.followUps ?? []).filter((f) => !f.deleted);
for (const followUp of newFollowUps) {
if (!oldFollowUpIds.has(followUp.id)) {
capturePostHogEvent(
context.userId,
"follow_up_added",
{ ...baseProps, follow_up_id: followUp.id },
groupContext
);
}
}
};
/**
* Checks if survey follow-ups can be added for the given organization.
* Grandfathers existing follow-ups (allows keeping them even if the org lost access).
@@ -59,6 +191,7 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
const survey = parsedInput as TSurvey;
const organizationId = await getOrganizationIdFromSurveyId(survey.id);
const projectId = await getProjectIdFromSurveyId(survey.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
@@ -69,7 +202,7 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(survey.id),
projectId,
minPermission: "readWrite",
},
],
@@ -100,6 +233,14 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
captureSurveyEditDiffEvents(oldObject, result, {
userId: ctx.user.id,
surveyId: result.id,
organizationId,
workspaceId: projectId,
environmentId: result.environmentId,
});
revalidatePath(`/environments/${result.environmentId}/surveys/${result.id}`);
return result;
@@ -148,23 +289,39 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
if (POSTHOG_KEY && result.status !== "draft") {
const isPublish = oldObject?.status === "draft" && result.status === "inProgress";
const projectId = await getProjectIdFromSurveyId(parsedInput.id);
const posthogEventMetadata = {
survey_id: result.id,
survey_type: result.type,
question_count: getElementsFromBlocks(result.blocks).length,
organization_id: organizationId,
has_targeting: result.segment ? !result.segment.isPrivate : false,
language_count: result.languages?.length ?? 0,
};
captureSurveyEditDiffEvents(oldObject, result, {
userId: ctx.user.id,
surveyId: result.id,
organizationId,
workspaceId: projectId,
environmentId: result.environmentId,
});
if (isPublish) {
capturePostHogEvent(ctx.user.id, "survey_published", posthogEventMetadata);
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata);
} else {
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata);
if (POSTHOG_KEY) {
if (result.status !== "draft") {
const isPublish = oldObject?.status === "draft" && result.status === "inProgress";
const posthogEventMetadata = {
survey_id: result.id,
survey_type: result.type,
question_count: getElementsFromBlocks(result.blocks).length,
organization_id: organizationId,
workspace_id: projectId,
environment_id: result.environmentId,
has_targeting: result.segment ? !result.segment.isPrivate : false,
language_count: result.languages?.length ?? 0,
};
const groupContext = { organizationId, workspaceId: projectId };
if (isPublish) {
capturePostHogEvent(ctx.user.id, "survey_published", posthogEventMetadata, groupContext);
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata, groupContext);
} else {
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata, groupContext);
}
}
}
@@ -335,9 +492,27 @@ export const createActionClassAction = authenticatedActionClient.inputSchema(ZCr
});
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.action.environmentId);
const result = await createActionClass(parsedInput.action.environmentId, parsedInput.action);
ctx.auditLoggingCtx.actionClassId = result.id;
ctx.auditLoggingCtx.newObject = result;
const triggerType =
parsedInput.action.type === "code" ? "codeAction" : (parsedInput.action.noCodeConfig?.type ?? "noCode");
capturePostHogEvent(
ctx.user.id,
"action_class_created",
{
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.action.environmentId,
type: parsedInput.action.type,
trigger_type: triggerType,
},
{ organizationId, workspaceId: projectId }
);
return result;
})
);
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { TProjectStyling } from "@formbricks/types/project";
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { CustomScriptsInjector } from "@/modules/survey/link/components/custom-scripts-injector";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
@@ -133,11 +134,13 @@ export const SurveyClientWrapper = ({
}
setResponseData({});
};
const jsSurvey = useMemo(() => toJsEnvironmentStateSurvey(survey), [survey]);
// Determine text direction based on language code for logo positioning only
// which checks both language code and survey content. This is only for logo UI positioning.
const logoDir = useMemo(() => {
return isRTLLanguage(survey, languageCode) ? "rtl" : "auto";
}, [languageCode, survey]);
return isRTLLanguage(jsSurvey, languageCode) ? "rtl" : "auto";
}, [languageCode, jsSurvey]);
return (
<>
@@ -169,7 +172,7 @@ export const SurveyClientWrapper = ({
appUrl={publicDomain}
environmentId={survey.environmentId}
isPreviewMode={isPreview}
survey={survey}
survey={jsSurvey}
styling={styling}
languageCode={languageCode}
isBrandingEnabled={project.linkSurveyBranding}
@@ -10,6 +10,7 @@ import {
getSurveysUsingGivenLanguage,
updateLanguage,
} from "@/lib/language/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
@@ -50,6 +51,18 @@ export const createLanguageAction = authenticatedActionClient.inputSchema(ZCreat
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.languageId = result.id;
ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(
ctx.user.id,
"workspace_language_created",
{
organization_id: organizationId,
workspace_id: parsedInput.projectId,
language_code: result.code,
},
{ organizationId, workspaceId: parsedInput.projectId }
);
return result;
})
);
@@ -10,6 +10,7 @@ import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyLanguage, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { ClientLogo } from "@/modules/ui/components/client-logo";
import {
DropdownMenu,
@@ -272,7 +273,7 @@ export const PreviewSurvey = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={survey}
survey={toJsEnvironmentStateSurvey(survey)}
isBrandingEnabled={project.inAppSurveyBranding}
isRedirectDisabled={true}
languageCode={languageCode}
@@ -303,7 +304,7 @@ export const PreviewSurvey = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
isBrandingEnabled={project.linkSurveyBranding}
languageCode={languageCode}
responseCount={42}
@@ -388,7 +389,7 @@ export const PreviewSurvey = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={survey}
survey={toJsEnvironmentStateSurvey(survey)}
isBrandingEnabled={project.inAppSurveyBranding}
isRedirectDisabled={true}
languageCode={languageCode}
@@ -423,7 +424,7 @@ export const PreviewSurvey = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
isBrandingEnabled={project.linkSurveyBranding}
isRedirectDisabled={true}
languageCode={languageCode}
@@ -6,6 +6,7 @@ import { Fragment, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { ClientLogo } from "@/modules/ui/components/client-logo";
import { MediaBackground } from "@/modules/ui/components/media-background";
import { Modal } from "@/modules/ui/components/preview-survey/components/modal";
@@ -178,7 +179,7 @@ export const ThemeStylingPreviewSurvey = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "app" }}
survey={toJsEnvironmentStateSurvey({ ...survey, type: "app" })}
isBrandingEnabled={project.inAppSurveyBranding}
isRedirectDisabled={true}
onFileUpload={async (file) => file.name}
@@ -205,7 +206,7 @@ export const ThemeStylingPreviewSurvey = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
isBrandingEnabled={project.linkSurveyBranding}
isRedirectDisabled={true}
onFileUpload={async (file) => file.name}
+1 -1
View File
@@ -18,7 +18,7 @@ metadata:
{{- end }}
{{- end }}
spec:
{{- if .Values.deployment.replicas }}
{{- if and (not .Values.autoscaling.enabled) (not (kindIs "invalid" .Values.deployment.replicas)) }}
replicas: {{ .Values.deployment.replicas }}
{{- end }}
selector:
+1 -1
View File
@@ -83,7 +83,7 @@ deployment:
# Additional pod annotations
additionalPodAnnotations: {}
# Number of replicas
# Number of replicas when autoscaling is disabled
replicas: 1
# Image pull secrets for private container registries
-1
View File
@@ -96,7 +96,6 @@
"xm-and-surveys/surveys/link-surveys/data-prefilling",
"xm-and-surveys/surveys/link-surveys/embed-surveys",
"xm-and-surveys/surveys/link-surveys/link-settings",
"xm-and-surveys/surveys/link-surveys/pretty-url",
"xm-and-surveys/surveys/link-surveys/personal-links",
"xm-and-surveys/surveys/link-surveys/single-use-links",
"xm-and-surveys/surveys/link-surveys/source-tracking",
@@ -1,81 +0,0 @@
---
title: "Pretty URL"
description: "Create a custom, memorable URL for your survey instead of sharing a long auto-generated link."
icon: "link"
---
<Note>
**Self-Hosted Only**: Pretty URLs are available exclusively on self-hosted Formbricks instances. This feature is not available on Formbricks Cloud.
</Note>
## What is a Pretty URL?
By default, every survey is accessible at a URL containing its auto-generated ID, e.g. `yourdomain.com/s/cm1abc123xyz`. A Pretty URL lets you replace that with a short, human-readable slug of your choice:
```
yourdomain.com/p/customer-feedback
```
When someone visits the pretty URL, they are automatically redirected to the actual survey. Query parameters such as `suId` and `lang` are forwarded as well.
## Setting Up a Pretty URL
<Steps>
<Step title="Open the Share Modal">
Navigate to your survey's **Summary** page and click the **Share survey** button in the top toolbar.
</Step>
<Step title="Go to the Pretty URL tab">
In the Share Modal, select the **Pretty URL** tab.
</Step>
<Step title="Enter a slug">
Type your desired slug in the input field. Slugs may only contain **lowercase letters, numbers, and hyphens** (e.g. `customer-feedback`, `q4-nps-2024`).
The full URL is shown in real time below the input so you can confirm how it will look.
</Step>
<Step title="Save">
Click **Save**. The slug is now live. Anyone visiting the pretty URL is immediately redirected to your survey.
</Step>
</Steps>
## Managing Pretty URLs
Once a slug is saved, the Pretty URL tab shows the active link with two actions:
- **Copy**: copies the full pretty URL to your clipboard.
- **Remove**: deletes the slug (after a confirmation prompt). The survey remains accessible via its original `/s/[surveyId]` URL.
## Viewing All Pretty URLs in Your Organization
All surveys that have a pretty URL assigned are listed in one place:
1. Go to **Organization Settings → Domain**.
2. Open the **Pretty URLs** section.
The table shows each survey's name, workspace, slug, and environment type (production / development).
## Slug Rules
| Rule | Detail |
|------|--------|
| Characters | Lowercase letters (a-z), digits (0-9), and hyphens (-) |
| Uniqueness | Must be unique across your entire Formbricks instance |
| Format example | `customer-feedback`, `onboarding-survey`, `q4-nps` |
## Query Parameter Forwarding
Pretty URLs forward all query parameters to the destination survey URL. For example:
```
/p/customer-feedback?suId=contact123&lang=de
```
redirects to:
```
/s/[surveyId]?suId=contact123&lang=de
```
This means features like [single-use links](/xm-and-surveys/surveys/link-surveys/single-use-links), [data prefilling](/xm-and-surveys/surveys/link-surveys/data-prefilling), and [multi-language surveys](/xm-and-surveys/surveys/general-features/multi-language-surveys) all work with pretty URLs.
+4 -4
View File
@@ -243,9 +243,9 @@ export const setup = async (
filteredSurveys,
});
const surveyNames = filteredSurveys.map((s) => s.name);
const surveyIds = filteredSurveys.map((s) => s.id);
logger.debug(
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
`${surveyIds.length.toString()} surveys could be shown to current user on trigger: ${surveyIds.join(", ")}`
);
} catch {
logger.debug("Error during sync. Please try again.");
@@ -303,9 +303,9 @@ export const setup = async (
filteredSurveys,
});
const surveyNames = filteredSurveys.map((s) => s.name);
const surveyIds = filteredSurveys.map((s) => s.id);
logger.debug(
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
`${surveyIds.length.toString()} surveys could be shown to current user on trigger: ${surveyIds.join(", ")}`
);
} catch (e) {
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
@@ -279,6 +279,52 @@ describe("utils.ts", () => {
expect(result).toHaveLength(1);
});
test("anonymous user: excludes segment-targeted surveys (new shape: hasFilters=true)", () => {
environment.data.surveys = [
{
...baseSurvey,
id: mockSurveyId1,
segment: { id: mockSegmentId1, hasFilters: true },
displayOption: "respondMultiple",
} as TEnvironmentStateSurvey,
{
...baseSurvey,
id: mockSurveyId2,
segment: { id: mockSegmentId2, hasFilters: false },
displayOption: "respondMultiple",
} as TEnvironmentStateSurvey,
];
const result = filterSurveys(environment, user);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(mockSurveyId2);
});
test("anonymous user: excludes segment-targeted surveys when cached payload uses legacy shape (filters array)", () => {
// Simulates a localStorage payload written by an older SDK version that
// still has `segment.filters` and no `hasFilters`. The defensive check
// must fall back to the legacy shape so anonymous users don't receive
// segment-targeted surveys.
environment.data.surveys = [
{
...baseSurvey,
id: mockSurveyId1,
segment: { id: mockSegmentId1, filters: [{ type: "attribute", value: "plan" }] },
displayOption: "respondMultiple",
} as unknown as TEnvironmentStateSurvey,
{
...baseSurvey,
id: mockSurveyId2,
segment: { id: mockSegmentId2, filters: [] },
displayOption: "respondMultiple",
} as unknown as TEnvironmentStateSurvey,
];
const result = filterSurveys(environment, user);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(mockSurveyId2);
});
test("filters by segment if userId is set and user has segments", () => {
user.data.userId = "user_abc";
user.data.segments = [mockSegmentId1];
+14 -2
View File
@@ -53,6 +53,19 @@ export const wrapThrowsAsync =
}
};
/**
* Detect whether a survey's segment has filters. Handles both the current
* minimal shape (`{ id, hasFilters }`) and the legacy shape with a `filters`
* array older SDKs cached the full segment in localStorage and may still be
* read back here within the cache window after an SDK upgrade.
*/
export const surveyHasSegmentFilters = (survey: TEnvironmentStateSurvey): boolean => {
const segment = survey.segment as { hasFilters?: boolean; filters?: unknown[] } | null | undefined;
if (!segment) return false;
if (typeof segment.hasFilters === "boolean") return segment.hasFilters;
return Array.isArray(segment.filters) && segment.filters.length > 0;
};
/**
* Filters surveys based on the displayOption, recontactDays, and segments
* @param environmentSate - The environment state
@@ -123,8 +136,7 @@ export const filterSurveys = (
if (!userId) {
// exclude surveys that have a segment with filters
return filteredSurveys.filter((survey) => {
const segmentFiltersLength = survey.segment?.filters.length ?? 0;
return segmentFiltersLength === 0;
return !surveyHasSegmentFilters(survey);
});
}
@@ -8,7 +8,6 @@ export const mockEnvironmentId = "n48a66c01dz05k1297vq06pu";
export const mockSurvey: TEnvironmentStateSurvey = {
id: mockSurveyId,
name: "Test Survey",
welcomeCard: {
enabled: false,
timeToFinish: false,
@@ -1,6 +1,7 @@
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import type * as CommonUtils from "@/lib/common/utils";
import { filterSurveys, getLanguageCode, shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
import { mockSurvey } from "@/lib/survey/tests/__mocks__/widget.mock";
import * as widget from "@/lib/survey/widget";
@@ -34,14 +35,18 @@ vi.mock("@/lib/common/timeout-stack", () => ({
},
}));
vi.mock("@/lib/common/utils", () => ({
filterSurveys: vi.fn(),
getLanguageCode: vi.fn(),
getStyling: vi.fn(),
shouldDisplayBasedOnPercentage: vi.fn(),
wrapThrowsAsync: vi.fn(),
handleHiddenFields: vi.fn(),
}));
vi.mock("@/lib/common/utils", async (importOriginal) => {
const actual = await importOriginal<typeof CommonUtils>();
return {
...actual,
filterSurveys: vi.fn(),
getLanguageCode: vi.fn(),
getStyling: vi.fn(),
shouldDisplayBasedOnPercentage: vi.fn(),
wrapThrowsAsync: vi.fn(),
handleHiddenFields: vi.fn(),
};
});
const mockUpdateQueue = {
hasPendingWork: vi.fn().mockReturnValue(false),
@@ -67,7 +72,6 @@ describe("widget-file", () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = "";
// @ts-expect-error -- cleaning up mock
delete window.formbricksSurveys;
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
@@ -89,7 +93,7 @@ describe("widget-file", () => {
await widget.triggerSurvey(mockSurvey);
expect(mockLogger.debug).toHaveBeenCalledWith(
`Survey display of "${mockSurvey.name}" skipped based on displayPercentage.`
`Survey display of "${mockSurvey.id}" skipped based on displayPercentage.`
);
});
@@ -145,7 +149,7 @@ describe("widget-file", () => {
await widget.renderWidget(mockSurvey);
expect(mockLogger.debug).toHaveBeenCalledWith(
`Delaying survey "${mockSurvey.name}" by ${mockSurvey.delay.toString()} seconds.`
`Delaying survey "${mockSurvey.id}" by ${mockSurvey.delay.toString()} seconds.`
);
vi.advanceTimersByTime(mockSurvey.delay * 1000);
@@ -209,7 +213,7 @@ describe("widget-file", () => {
await widget.renderWidget(mockSurveyNoDelay as unknown as TEnvironmentStateSurvey);
expect(mockLogger.debug).toHaveBeenCalledWith(
`Survey "${mockSurvey.name}" is not available in specified language.`
`Survey "${mockSurvey.id}" is not available in specified language.`
);
});
@@ -456,7 +460,7 @@ describe("widget-file", () => {
await widget.renderWidget({
...mockSurvey,
delay: 0,
segment: { id: "seg_1", filters: [{ type: "attribute", value: "plan" }] },
segment: { id: "seg_1", hasFilters: true },
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
+5 -4
View File
@@ -10,6 +10,7 @@ import {
getStyling,
handleHiddenFields,
shouldDisplayBasedOnPercentage,
surveyHasSegmentFilters,
} from "@/lib/common/utils";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
@@ -32,7 +33,7 @@ export const triggerSurvey = async (
if (survey.displayPercentage) {
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
if (!shouldDisplaySurvey) {
logger.debug(`Survey display of "${survey.name}" skipped based on displayPercentage.`);
logger.debug(`Survey display of "${survey.id}" skipped based on displayPercentage.`);
return; // skip displaying the survey
}
}
@@ -67,7 +68,7 @@ export const renderWidget = async (
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;
const hasSegmentFilters = surveyHasSegmentFilters(survey);
if (hasSegmentFilters) {
logger.debug("User identification failed. Skipping survey with segment filters.");
@@ -80,7 +81,7 @@ export const renderWidget = async (
}
if (survey.delay) {
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
logger.debug(`Delaying survey "${survey.id}" by ${survey.delay.toString()} seconds.`);
}
const { project } = config.get().environment.data;
@@ -93,7 +94,7 @@ export const renderWidget = async (
const displayLanguage = getLanguageCode(survey, language);
//if survey is not available in selected language, survey wont be shown
if (!displayLanguage) {
logger.debug(`Survey "${survey.name}" is not available in specified language.`);
logger.debug(`Survey "${survey.id}" is not available in specified language.`);
setIsSurveyRunning(false);
return;
}
+4 -3
View File
@@ -1,10 +1,10 @@
/* eslint-disable import/no-extraneous-dependencies -- required for Prisma types */
import type { ActionClass, Language, Project, Segment, Survey, SurveyLanguage } from "@prisma/client";
import type { ActionClass, Language, Project, Survey, SurveyLanguage } from "@prisma/client";
export type TEnvironmentStateSurvey = Pick<
Survey,
| "id"
| "name"
// name intentionally omitted — internal label, not needed by SDK
| "welcomeCard"
| "questions"
| "variables"
@@ -25,7 +25,8 @@ export type TEnvironmentStateSurvey = Pick<
> & {
languages: (SurveyLanguage & { language: Language })[];
triggers: { actionClass: ActionClass }[];
segment?: Segment;
// Minimal segment shape — full filter logic is evaluated server-side and must not reach the browser
segment?: { id: string; hasFilters: boolean };
displayPercentage: number;
type: "link" | "app";
styling?: TSurveyStyling;
@@ -67,12 +67,15 @@ function OpenText({
);
};
const descriptionId = description ? `${inputId}-description` : undefined;
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
descriptionId={descriptionId}
required={required}
requiredLabel={requiredLabel}
htmlFor={inputId}
@@ -90,6 +93,7 @@ function OpenText({
value={value}
onChange={handleChange}
aria-required={required}
aria-describedby={descriptionId}
dir={dir}
rows={rows}
disabled={disabled}
@@ -105,6 +109,7 @@ function OpenText({
value={value}
onChange={handleChange}
aria-required={required}
aria-describedby={descriptionId}
dir={dir}
disabled={disabled}
errorMessage={errorMessage}
@@ -7,6 +7,7 @@ import { cn, stripInlineStyles } from "@/lib/utils";
interface ElementHeaderProps extends React.ComponentProps<"div"> {
headline: string;
description?: string;
descriptionId?: string;
required?: boolean;
/** Custom label for the required indicator. Defaults to "Required" */
requiredLabel?: string;
@@ -44,6 +45,7 @@ const isValidHTML = (str: string): boolean => {
function ElementHeader({
headline,
description,
descriptionId,
required = false,
requiredLabel = "Required",
htmlFor,
@@ -91,7 +93,7 @@ function ElementHeader({
{/* Description/Subheader */}
{description ? (
<Label htmlFor={htmlFor} variant="description">
<Label id={descriptionId} variant="description">
{description}
</Label>
) : null}
+1
View File
@@ -27,6 +27,7 @@
"select_option": "اختر خيارًا",
"select_options": "اختر الخيارات",
"sending_responses": "جارٍ إرسال الردود...",
"survey_dialog": "مربع حوار الاستبيان",
"takes_less_than_x_minutes": "{count, plural, zero {يستغرق أقل من دقيقة} one {يستغرق أقل من دقيقة واحدة} two {يستغرق أقل من دقيقتين} few {يستغرق أقل من {count} دقائق} many {يستغرق أقل من {count} دقيقة} other {يستغرق أقل من {count} دقيقة}}",
"takes_x_minutes": "{count, plural, zero {يستغرق صفر دقائق} one {يستغرق دقيقة واحدة} two {يستغرق دقيقتين} few {يستغرق {count} دقائق} many {يستغرق {count} دقيقة} other {يستغرق {count} دقيقة}}",
"takes_x_plus_minutes": "يستغرق {count}+ دقيقة",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Vælg en mulighed",
"select_options": "Vælg muligheder",
"sending_responses": "Sender svar…",
"survey_dialog": "Undersøgelsesdialog",
"takes_less_than_x_minutes": "{count, plural, one {Tager mindre end 1 minut} other {Tager mindre end {count} minutter}}",
"takes_x_minutes": "{count, plural, one {Tager 1 minut} other {Tager {count} minutter}}",
"takes_x_plus_minutes": "Tager {count}+ minutter",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Wähle eine Option",
"select_options": "Wähle Optionen",
"sending_responses": "Antworten werden gesendet...",
"survey_dialog": "Umfragedialog",
"takes_less_than_x_minutes": "{count, plural, one {Dauert weniger als 1 Minute} other {Dauert weniger als {count} Minuten}}",
"takes_x_minutes": "{count, plural, one {Dauert 1 Minute} other {Dauert {count} Minuten}}",
"takes_x_plus_minutes": "Dauert {count}+ Minuten",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Select an option",
"select_options": "Select options",
"sending_responses": "Sending responses…",
"survey_dialog": "Survey Dialog",
"takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}",
"takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}",
"takes_x_plus_minutes": "Takes {count}+ minutes",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Selecciona una opción",
"select_options": "Selecciona opciones",
"sending_responses": "Enviando respuestas...",
"survey_dialog": "Diálogo de encuesta",
"takes_less_than_x_minutes": "{count, plural, one {Toma menos de 1 minuto} other {Toma menos de {count} minutos}}",
"takes_x_minutes": "{count, plural, one {Toma 1 minuto} other {Toma {count} minutos}}",
"takes_x_plus_minutes": "Toma {count}+ minutos",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Vali variant",
"select_options": "Vali variandid",
"sending_responses": "Vastuste saatmine…",
"survey_dialog": "Küsitluse dialoog",
"takes_less_than_x_minutes": "{count, plural, one {Võtab vähem kui 1 minuti} other {Võtab vähem kui {count} minutit}}",
"takes_x_minutes": "{count, plural, one {Võtab 1 minuti} other {Võtab {count} minutit}}",
"takes_x_plus_minutes": "Võtab {count}+ minutit",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Sélectionner une option",
"select_options": "Sélectionner des options",
"sending_responses": "Envoi des réponses...",
"survey_dialog": "Boîte de dialogue du sondage",
"takes_less_than_x_minutes": "{count, plural, one {Prend moins d'une minute} other {Prend moins de {count} minutes}}",
"takes_x_minutes": "{count, plural, one {Prend 1 minute} other {Prend {count} minutes}}",
"takes_x_plus_minutes": "Prend {count}+ minutes",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "एक विकल्प चुनें",
"select_options": "विकल्प चुनें",
"sending_responses": "प्रतिक्रियाएँ भेज रहे हैं...",
"survey_dialog": "सर्वेक्षण संवाद",
"takes_less_than_x_minutes": "{count, plural, one {1 मिनट से कम लगता है} other {{count} मिनट से कम लगता है}}",
"takes_x_minutes": "{count, plural, one {1 मिनट लगता है} other {{count} मिनट लगते हैं}}",
"takes_x_plus_minutes": "{count}+ मिनट लगते हैं",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Lehetőség kiválasztása",
"select_options": "Lehetőségek kiválasztása",
"sending_responses": "Válaszok küldése…",
"survey_dialog": "Kérdőív párbeszédpanel",
"takes_less_than_x_minutes": "{count, plural, one {Kevesebb mint 1 percet vesz igénybe} other {Kevesebb mint {count} percet vesz igénybe}}",
"takes_x_minutes": "{count, plural, one {1 percet vesz igénybe} other {{count} percet vesz igénybe}}",
"takes_x_plus_minutes": "{count}+ percet vesz igénybe",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Seleziona un'opzione",
"select_options": "Seleziona opzioni",
"sending_responses": "Invio risposte in corso...",
"survey_dialog": "Finestra di dialogo del sondaggio",
"takes_less_than_x_minutes": "{count, plural, one {Richiede meno di 1 minuto} other {Richiede meno di {count} minuti}}",
"takes_x_minutes": "{count, plural, one {Richiede 1 minuto} other {Richiede {count} minuti}}",
"takes_x_plus_minutes": "Richiede più di {count} minuti",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "オプションを選択",
"select_options": "オプションを選択",
"sending_responses": "回答を送信中...",
"survey_dialog": "アンケートダイアログ",
"takes_less_than_x_minutes": "{count, plural, one {1分未満} other {{count}分未満}}",
"takes_x_minutes": "{count, plural, one {1分} other {{count}分}}",
"takes_x_plus_minutes": "{count}分以上",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Selecteer een optie",
"select_options": "Selecteer opties",
"sending_responses": "Reacties verzenden...",
"survey_dialog": "Enquête-dialoogvenster",
"takes_less_than_x_minutes": "{count, plural, one {Duurt minder dan 1 minuut} other {Duurt minder dan {count} minuten}}",
"takes_x_minutes": "{count, plural, one {Duurt 1 minuut} other {Duurt {count} minuten}}",
"takes_x_plus_minutes": "Duurt {count}+ minuten",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Selecione uma opção",
"select_options": "Selecione opções",
"sending_responses": "Enviando respostas...",
"survey_dialog": "Caixa de diálogo da pesquisa",
"takes_less_than_x_minutes": "{count, plural, one {Leva menos de 1 minuto} other {Leva menos de {count} minutos}}",
"takes_x_minutes": "{count, plural, one {Leva 1 minuto} other {Leva {count} minutos}}",
"takes_x_plus_minutes": "Leva {count}+ minutos",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Selectează o opțiune",
"select_options": "Selectează opțiuni",
"sending_responses": "Trimiterea răspunsurilor...",
"survey_dialog": "Dialog sondaj",
"takes_less_than_x_minutes": "{count, plural, one {Durează mai puțin de 1 minut} few {Durează mai puțin de {count} minute} other {Durează mai puțin de {count} de minute}}",
"takes_x_minutes": "{count, plural, one {Durează 1 minut} few {Durează {count} minute} other {Durează {count} de minute}}",
"takes_x_plus_minutes": "Durează peste {count} minute",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Выбери вариант",
"select_options": "Выбери варианты",
"sending_responses": "Отправка ответов...",
"survey_dialog": "Диалог опроса",
"takes_less_than_x_minutes": "{count, plural, one {Займёт меньше 1 минуты} few {Займёт меньше {count} минут} many {Займёт меньше {count} минут} other {Займёт меньше {count} минуты}}",
"takes_x_minutes": "{count, plural, one {Займёт 1 минуту} few {Займёт {count} минуты} many {Займёт {count} минут} other {Займёт {count} минуты}}",
"takes_x_plus_minutes": "Займёт {count}+ минут",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Välj ett alternativ",
"select_options": "Välj alternativ",
"sending_responses": "Skickar svar...",
"survey_dialog": "Enkätdialog",
"takes_less_than_x_minutes": "{count, plural, one {Tar mindre än 1 minut} other {Tar mindre än {count} minuter}}",
"takes_x_minutes": "{count, plural, one {Tar 1 minut} other {Tar {count} minuter}}",
"takes_x_plus_minutes": "Tar {count}+ minuter",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Bir seçenek seçin",
"select_options": "Seçenekleri seçin",
"sending_responses": "Yanıtlar gönderiliyor…",
"survey_dialog": "Anket iletişim kutusu",
"takes_less_than_x_minutes": "{count, plural, one {1 dakikadan az sürer} other {{count} dakikadan az sürer}}",
"takes_x_minutes": "{count, plural, one {1 dakika sürer} other {{count} dakika sürer}}",
"takes_x_plus_minutes": "{count}+ dakika sürer",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Variantni tanla",
"select_options": "Variantlarni tanla",
"sending_responses": "Javoblar yuborilmoqda...",
"survey_dialog": "Sorovnoma dialog oynasi",
"takes_less_than_x_minutes": "{count, plural, one {1 daqiqadan kam vaqt oladi} other {{count} daqiqadan kam vaqt oladi}}",
"takes_x_minutes": "{count, plural, one {1 daqiqa vaqt oladi} other {{count} daqiqa vaqt oladi}}",
"takes_x_plus_minutes": "{count}+ daqiqa vaqt oladi",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "请选择一个选项",
"select_options": "请选择多个选项",
"sending_responses": "正在发送响应...",
"survey_dialog": "调查对话框",
"takes_less_than_x_minutes": "{count, plural, one {少于 1 分钟} other {少于 {count} 分钟}}",
"takes_x_minutes": "{count, plural, one {1 分钟} other {{count} 分钟}}",
"takes_x_plus_minutes": "{count}+ 分钟",
@@ -63,7 +63,11 @@ export function CalElement({
elementId={element.id}
/>
<CalEmbed key={element.id} element={element} onSuccessfulBooking={onSuccessfulBooking} />
{errorMessage ? <span className="text-red-500">{errorMessage}</span> : null}
{errorMessage ? (
<span className="text-red-500" role="alert" aria-live="assertive" aria-atomic="true">
{errorMessage}
</span>
) : null}
</div>
</form>
);
@@ -61,7 +61,7 @@ export function OpenTextElement({
<form key={element.id} onSubmit={handleOnSubmit} className="w-full">
<OpenText
elementId={element.id}
inputId={element.id}
inputId={`${element.id}-input`}
headline={getLocalizedValue(element.headline, languageCode)}
description={element.subheader ? getLocalizedValue(element.subheader, languageCode) : undefined}
placeholder={getLocalizedValue(element.placeholder, languageCode)}
@@ -75,7 +75,11 @@ export function ElementMedia({ imgUrl, videoUrl, altText = "Image", className }:
target="_blank"
rel="noreferrer"
aria-label={t("common.open_in_new_tab")}
className="absolute right-2 bottom-2 flex items-center gap-2 rounded-md bg-slate-800/40 p-1.5 text-white opacity-0 backdrop-blur-lg transition duration-300 ease-in-out group-hover/image:opacity-100 hover:bg-slate-800/65">
className={cn(
"absolute right-2 bottom-2 flex items-center gap-2 rounded-md bg-slate-800/40 p-1.5",
"text-white backdrop-blur-lg transition duration-300 ease-in-out",
"opacity-0 group-hover/image:opacity-100 hover:bg-slate-800/65 focus:opacity-100"
)}>
{imgUrl ? <ImageDownIcon size={20} /> : <ExpandIcon size={20} />}
</a>
</div>
@@ -88,7 +88,6 @@ export function LanguageSwitch({
borderRadius: typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
}}
onClick={toggleDropdown}
tabIndex={-1}
aria-haspopup="true"
aria-expanded={showLanguageDropdown}
aria-label={t("common.language_switch")}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { isRTLLanguage } from "@/lib/utils";
import { SurveyContainer } from "../wrappers/survey-container";
@@ -8,6 +8,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
const [isOpen, setIsOpen] = useState(true);
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { onClose } = props;
const isRTL = isRTLLanguage(props.survey, props.languageCode);
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "ltr");
@@ -17,7 +18,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
// 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 = useCallback(() => {
if (onFinishedTimeoutRef.current) {
clearTimeout(onFinishedTimeoutRef.current);
onFinishedTimeoutRef.current = null;
@@ -31,11 +32,9 @@ export function RenderSurvey(props: SurveyContainerProps) {
setIsOpen(false);
closeTimeoutRef.current = setTimeout(() => {
if (props.onClose) {
props.onClose();
}
onClose?.();
}, 1000);
};
}, [onClose]);
useEffect(() => {
return () => {
@@ -64,7 +63,6 @@ export function RenderSurvey(props: SurveyContainerProps) {
onClose={close}
isOpen={isOpen}
dir={dir}>
{/* @ts-expect-error -- TODO: fix this */}
<Survey
{...props}
clickOutside={hasOverlay ? props.clickOutside : true}
@@ -1,12 +1,15 @@
import { useEffect, useRef } from "preact/hooks";
import { type ComponentChildren } from "preact";
import { useEffect } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TOverlay, type TPlacement } from "@formbricks/types/common";
import { useFocusTrap } from "@/lib/use-focus-trap";
import { cn } from "@/lib/utils";
interface SurveyContainerProps {
mode: "modal" | "inline";
placement?: TPlacement;
overlay?: TOverlay;
children: React.ReactNode;
children: ComponentChildren;
onClose?: () => void;
clickOutside?: boolean;
isOpen?: boolean;
@@ -23,8 +26,9 @@ export function SurveyContainer({
isOpen = true,
dir = "auto",
}: Readonly<SurveyContainerProps>) {
const modalRef = useRef<HTMLDivElement>(null);
const isModal = mode === "modal";
const { t } = useTranslation();
const modalRef = useFocusTrap<HTMLDivElement>({ enabled: isModal && isOpen, onEscapeKeyDown: onClose });
const hasOverlay = overlay !== "none";
useEffect(() => {
@@ -47,7 +51,7 @@ export function SurveyContainer({
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [clickOutside, onClose, isModal, isOpen]);
}, [clickOutside, hasOverlay, modalRef, onClose, isModal, isOpen]);
const getPlacementStyle = (placement: TPlacement): string => {
switch (placement) {
@@ -92,6 +96,10 @@ export function SurveyContainer({
)}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("common.survey_dialog")}
tabIndex={-1}
className={cn(
getPlacementStyle(placement),
isOpen ? "opacity-100" : "opacity-0",
@@ -0,0 +1,266 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
import { type ComponentChildren } from "preact";
import { afterEach, describe, expect, test, vi } from "vitest";
import { useFocusTrap } from "./use-focus-trap";
const FocusTrapFixture = ({
children,
enabled = true,
onEscapeKeyDown,
withTabIndex = true,
}: {
children: ComponentChildren;
enabled?: boolean;
onEscapeKeyDown?: () => void;
withTabIndex?: boolean;
}) => {
const focusTrapRef = useFocusTrap<HTMLDivElement>({ enabled, onEscapeKeyDown });
return (
<>
<button>Host page button</button>
<div ref={focusTrapRef} tabIndex={withTabIndex ? -1 : undefined}>
{children}
</div>
</>
);
};
const FocusTrapUnmountFixture = ({
showTrap,
onEscapeKeyDown,
}: {
showTrap: boolean;
onEscapeKeyDown?: () => void;
}) => (
<>
<button>External host button</button>
{showTrap ? (
<FocusTrapFixture onEscapeKeyDown={onEscapeKeyDown}>
<button>Survey action</button>
</FocusTrapFixture>
) : null}
</>
);
describe("useFocusTrap", () => {
afterEach(() => {
cleanup();
});
test("focuses the first tabbable element when active", async () => {
render(
<FocusTrapFixture>
<button>First action</button>
<button>Last action</button>
</FocusTrapFixture>
);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "First action" }));
});
});
test("makes the trap root focusable when it has no tabIndex", async () => {
render(
<FocusTrapFixture withTabIndex={false}>
<span>Static content</span>
</FocusTrapFixture>
);
await waitFor(() => {
expect(document.activeElement?.getAttribute("tabindex")).toBe("-1");
});
});
test("allows links to receive initial focus", async () => {
render(
<FocusTrapFixture>
<a href="https://formbricks.com">Formbricks link</a>
<button>Survey action</button>
</FocusTrapFixture>
);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole("link", { name: "Formbricks link" }));
});
});
test("keeps tab focus inside the trap", async () => {
render(
<FocusTrapFixture>
<button>First action</button>
<button>Last action</button>
</FocusTrapFixture>
);
const firstButton = screen.getByRole("button", { name: "First action" });
const lastButton = screen.getByRole("button", { name: "Last action" });
await waitFor(() => {
expect(document.activeElement).toBe(firstButton);
});
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(lastButton);
fireEvent.keyDown(document, { key: "Tab" });
expect(document.activeElement).toBe(firstButton);
});
test("keeps focus from moving outside the trap", async () => {
render(
<FocusTrapFixture>
<button>Survey action</button>
</FocusTrapFixture>
);
const trappedButton = screen.getByRole("button", { name: "Survey action" });
const hostPageButton = screen.getByRole("button", { name: "Host page button" });
await waitFor(() => {
expect(document.activeElement).toBe(trappedButton);
});
hostPageButton.focus();
await waitFor(() => {
expect(document.activeElement).toBe(trappedButton);
});
});
test("calls the Escape handler when provided", async () => {
const handleEscapeKeyDown = vi.fn();
render(
<FocusTrapFixture onEscapeKeyDown={handleEscapeKeyDown}>
<button>Survey action</button>
</FocusTrapFixture>
);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Survey action" }));
});
fireEvent.keyDown(document, { key: "Escape" });
expect(handleEscapeKeyDown).toHaveBeenCalledTimes(1);
});
test("restores focus to the previously focused element on unmount", async () => {
const initialEscapeHandler = vi.fn();
const updatedEscapeHandler = vi.fn();
const { rerender } = render(
<FocusTrapUnmountFixture showTrap={false} onEscapeKeyDown={initialEscapeHandler} />
);
const hostButton = screen.getByRole("button", { name: "External host button" });
hostButton.focus();
rerender(<FocusTrapUnmountFixture showTrap={true} onEscapeKeyDown={initialEscapeHandler} />);
const trappedButton = screen.getByRole("button", { name: "Survey action" });
await waitFor(() => {
expect(document.activeElement).toBe(trappedButton);
});
rerender(<FocusTrapUnmountFixture showTrap={true} onEscapeKeyDown={updatedEscapeHandler} />);
rerender(<FocusTrapUnmountFixture showTrap={false} onEscapeKeyDown={updatedEscapeHandler} />);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "External host button" }));
});
});
test("re-traps focus when focusout has no related target", async () => {
render(
<FocusTrapFixture>
<button>Survey action</button>
</FocusTrapFixture>
);
const trappedButton = screen.getByRole("button", { name: "Survey action" });
const hostPageButton = screen.getByRole("button", { name: "Host page button" });
await waitFor(() => {
expect(document.activeElement).toBe(trappedButton);
});
fireEvent.focusOut(trappedButton, { relatedTarget: null });
hostPageButton.focus();
await waitFor(() => {
expect(document.activeElement).toBe(trappedButton);
});
});
test("falls back to a connected element when the last focused node was removed", async () => {
const { rerender } = render(
<FocusTrapFixture>
<button>First action</button>
<button>Last action</button>
</FocusTrapFixture>
);
const firstButton = screen.getByRole("button", { name: "First action" });
const lastButton = screen.getByRole("button", { name: "Last action" });
const hostPageButton = screen.getByRole("button", { name: "Host page button" });
await waitFor(() => {
expect(document.activeElement).toBe(firstButton);
});
lastButton.focus();
await waitFor(() => {
expect(document.activeElement).toBe(lastButton);
});
rerender(
<FocusTrapFixture>
<button>First action</button>
</FocusTrapFixture>
);
hostPageButton.focus();
await waitFor(() => {
expect(document.activeElement).toBe(firstButton);
});
});
test("skips disabled, hidden, and inert candidates", async () => {
render(
<FocusTrapFixture>
<button disabled>Disabled action</button>
<button hidden>Hidden action</button>
<div
ref={(element) => {
element?.setAttribute("inert", "");
}}>
<button>Inert action</button>
</div>
<button>Enabled action</button>
</FocusTrapFixture>
);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Enabled action" }));
});
});
test("does not move focus when inactive", async () => {
render(
<FocusTrapFixture enabled={false}>
<button>Survey action</button>
</FocusTrapFixture>
);
await Promise.resolve();
expect(document.activeElement).toBe(document.body);
});
});
+272
View File
@@ -0,0 +1,272 @@
import { type MutableRef, useEffect, useRef } from "preact/hooks";
type FocusScope = { paused: boolean; pause: () => void; resume: () => void };
type FocusableTarget = HTMLElement | { focus: (options?: FocusOptions) => void };
type UseFocusTrapOptions = {
enabled: boolean;
onEscapeKeyDown?: () => void;
};
// focus trap behavior adapted from Radix UI FocusScope (MIT) for this Preact runtime.
const focusScopesStack = (() => {
let stack: FocusScope[] = [];
const remove = (focusScope: FocusScope) => stack.filter((scope) => scope !== focusScope);
return {
add: (focusScope: FocusScope) => {
const activeFocusScope = stack[0];
if (focusScope !== activeFocusScope) {
activeFocusScope?.pause();
}
stack = remove(focusScope);
stack.unshift(focusScope);
},
remove: (focusScope: FocusScope) => {
stack = remove(focusScope);
stack[0]?.resume();
},
};
})();
const focus = (element?: FocusableTarget | null, { select = false } = {}) => {
if (!element?.focus) return;
const previouslyFocusedElement = document.activeElement;
element.focus({ preventScroll: true });
if (
element !== previouslyFocusedElement &&
element instanceof HTMLInputElement &&
"select" in element &&
select
) {
element.select();
}
};
const focusFirst = (candidates: HTMLElement[], { select = false } = {}) => {
const previouslyFocusedElement = document.activeElement;
for (const candidate of candidates) {
focus(candidate, { select });
if (document.activeElement !== previouslyFocusedElement) return;
}
};
const isHidden = (node: HTMLElement, upTo: HTMLElement) => {
if (getComputedStyle(node).visibility === "hidden") return true;
let currentNode: HTMLElement | null = node;
while (currentNode) {
if (currentNode === upTo) return false;
if (getComputedStyle(currentNode).display === "none") return true;
currentNode = currentNode.parentElement;
}
return false;
};
const isDisabledFormControl = (element: HTMLElement) =>
(element instanceof HTMLButtonElement ||
element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement ||
element instanceof HTMLOptGroupElement ||
element instanceof HTMLOptionElement ||
element instanceof HTMLFieldSetElement) &&
element.disabled;
const getTabbableCandidates = (container: HTMLElement) => {
const nodes: HTMLElement[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node) => {
const element = node as HTMLElement;
const isHiddenInput = element.tagName === "INPUT" && (element as HTMLInputElement).type === "hidden";
if (element.closest("[inert]")) return NodeFilter.FILTER_REJECT;
if (element.closest("fieldset[disabled]")) return NodeFilter.FILTER_REJECT;
if (element.hidden || isHidden(element, container)) return NodeFilter.FILTER_REJECT;
if (isDisabledFormControl(element) || isHiddenInput) return NodeFilter.FILTER_SKIP;
return element.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
},
});
while (walker.nextNode()) {
nodes.push(walker.currentNode as HTMLElement);
}
return nodes;
};
const getTabbableEdges = (container: HTMLElement) => {
const candidates = getTabbableCandidates(container);
const first = candidates[0];
const last = candidates.at(-1);
return [first, last] as const;
};
export const useFocusTrap = <TElement extends HTMLElement>({
enabled,
onEscapeKeyDown,
}: UseFocusTrapOptions): MutableRef<TElement | null> => {
const containerRef = useRef<TElement>(null);
const lastFocusedElementRef = useRef<HTMLElement | null>(null);
const onEscapeKeyDownRef = useRef(onEscapeKeyDown);
const focusScopeRef = useRef<FocusScope>({
paused: false,
pause() {
this.paused = true;
},
resume() {
this.paused = false;
},
});
useEffect(() => {
// Keep the latest escape handler without re-running the main trap effect.
onEscapeKeyDownRef.current = onEscapeKeyDown;
}, [onEscapeKeyDown]);
useEffect(() => {
if (!enabled) return;
const container = containerRef.current;
if (!container) return;
const focusScope = focusScopeRef.current;
const previouslyFocusedElement = document.activeElement as HTMLElement | null;
const previousTabIndex = container.getAttribute("tabindex");
let isUnmounting = false;
if (previousTabIndex === null) {
container.setAttribute("tabindex", "-1");
}
focusScopesStack.add(focusScope);
if (!container.contains(previouslyFocusedElement)) {
focusFirst(getTabbableCandidates(container), { select: true });
if (document.activeElement === previouslyFocusedElement) {
focus(container);
}
}
if (container.contains(document.activeElement)) {
lastFocusedElementRef.current = document.activeElement as HTMLElement;
}
const focusLastElementInsideContainer = () => {
const [firstFocusableElement] = getTabbableEdges(container);
const lastFocusedElement =
lastFocusedElementRef.current && container.contains(lastFocusedElementRef.current)
? lastFocusedElementRef.current
: null;
focus(lastFocusedElement ?? firstFocusableElement ?? container, { select: true });
};
const handleFocusIn = (event: FocusEvent) => {
if (focusScope.paused) return;
const target = event.target as HTMLElement | null;
if (target && container.contains(target)) {
lastFocusedElementRef.current = target;
return;
}
focusLastElementInsideContainer();
};
const handleFocusOut = (event: FocusEvent) => {
if (focusScope.paused) return;
const relatedTarget = event.relatedTarget as HTMLElement | null;
if (relatedTarget && !container.contains(relatedTarget)) {
focusLastElementInsideContainer();
return;
}
if (relatedTarget === null) {
setTimeout(() => {
if (!isUnmounting && !container.contains(document.activeElement)) {
focusLastElementInsideContainer();
}
}, 0);
}
};
const handleMutations = () => {
if (!container.contains(document.activeElement)) {
focusLastElementInsideContainer();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (focusScope.paused) return;
const hasModifierKey = event.altKey || event.ctrlKey || event.metaKey;
if (event.key === "Escape" && !hasModifierKey && onEscapeKeyDownRef.current) {
event.preventDefault();
onEscapeKeyDownRef.current();
return;
}
const isTabKey = event.key === "Tab" && !event.altKey && !event.ctrlKey && !event.metaKey;
if (!isTabKey) return;
const focusedElement = document.activeElement as HTMLElement | null;
const [firstFocusableElement, lastFocusableElement] = getTabbableEdges(container);
if (!firstFocusableElement || !lastFocusableElement) {
if (focusedElement === container) {
event.preventDefault();
}
return;
}
if (!event.shiftKey && focusedElement === lastFocusableElement) {
event.preventDefault();
focus(firstFocusableElement, { select: true });
return;
}
if (event.shiftKey && focusedElement === firstFocusableElement) {
event.preventDefault();
focus(lastFocusableElement, { select: true });
}
};
document.addEventListener("focusin", handleFocusIn);
document.addEventListener("focusout", handleFocusOut);
document.addEventListener("keydown", handleKeyDown);
const mutationObserver = new MutationObserver(handleMutations);
mutationObserver.observe(container, { childList: true, subtree: true });
return () => {
isUnmounting = true;
document.removeEventListener("focusin", handleFocusIn);
document.removeEventListener("focusout", handleFocusOut);
document.removeEventListener("keydown", handleKeyDown);
mutationObserver.disconnect();
focusScopesStack.remove(focusScope);
if (previousTabIndex === null) {
container.removeAttribute("tabindex");
}
setTimeout(() => {
if (previouslyFocusedElement?.isConnected) {
focus(previouslyFocusedElement, { select: true });
}
}, 0);
};
}, [enabled]);
return containerRef;
};
+13 -5
View File
@@ -2,12 +2,13 @@ import { z } from "zod";
import { ZActionClass } from "./action-classes";
import { ZId } from "./common";
import { ZProject } from "./project";
import { ZJsEnvironmentStateSegment } from "./segment";
import { ZUploadFileConfig } from "./storage";
import { ZSurveyBase, surveyRefinement } from "./surveys/types";
export const ZJsEnvironmentStateSurvey = ZSurveyBase.pick({
id: true,
name: true,
// name intentionally omitted — internal label, not needed by SDK
welcomeCard: true,
questions: true,
blocks: true,
@@ -19,7 +20,7 @@ export const ZJsEnvironmentStateSurvey = ZSurveyBase.pick({
autoClose: true,
styling: true,
status: true,
segment: true,
// segment intentionally omitted from pick — replaced with minimal shape below
recontactDays: true,
displayLimit: true,
displayOption: true,
@@ -31,9 +32,16 @@ export const ZJsEnvironmentStateSurvey = ZSurveyBase.pick({
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
recaptcha: true,
}).superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
});
})
.extend({
// Only expose what the SDK needs: segment ID for membership check + whether any filters exist.
// Full filter logic (titles, descriptions, conditions) is evaluated server-side and must not
// be sent to the browser to avoid leaking sensitive targeting data.
segment: ZJsEnvironmentStateSegment.nullable(),
})
.superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
});
export type TJsEnvironmentStateSurvey = z.infer<typeof ZJsEnvironmentStateSurvey>;
+7
View File
@@ -349,6 +349,13 @@ export const ZSegment = z.object({
surveys: z.array(z.string()),
});
// Minimal segment shape for the public client API — strips sensitive targeting logic
export const ZJsEnvironmentStateSegment = z.object({
id: z.string(),
hasFilters: z.boolean(),
});
export type TJsEnvironmentStateSegment = z.infer<typeof ZJsEnvironmentStateSegment>;
export const ZSegmentCreateInput = z.object({
environmentId: z.string(),
title: z.string(),