Compare commits

...

20 Commits

Author SHA1 Message Date
Javi Aguilar fd2b212786 remove tsx test 2026-05-06 15:42:23 +02:00
Javi Aguilar 9c80d88a09 fix code smell 2026-05-06 15:42:23 +02:00
Javi Aguilar 7f4c974fc1 minor optimizations 2026-05-06 15:42:23 +02:00
Javi Aguilar ba2b22a328 localize the survey dialog aria-label 2026-05-06 15:42:22 +02:00
Javi Aguilar 05d38275ea fix: better a11y for modal surveys using a focus trap 2026-05-06 15:42:22 +02:00
Javi Aguilar 0b3efd95b9 feat: implement useFocusTrap hook for the preact survey runtime 2026-05-06 15:42:22 +02: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
Tiago fae00f6a82 fix: outlook preview (#7803)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-05-04 15:10:14 +00:00
Anshuman Pandey a274c444ad fix: reject SSO auto-provisioning when AUTH_SSO_DEFAULT_TEAM_ID is missing (#7926) 2026-05-04 10:57:44 +00:00
Anshuman Pandey 5fae207cd7 fix: duplicate action class name error (#7919) 2026-04-30 09:17:09 +00:00
Johannes 654539d320 fix: removed dead menu item & theme import (#7909)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 08:09:40 +00:00
Johannes 44aac89d41 fix: return generic credentials error for SSO-only accounts (#7911)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-30 08:09:34 +00:00
Johannes e0250b2a58 fix: include partial response in trigger description (#7908)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 06:27:48 +00:00
Labeeb a8d6cd8a9f fix: 7817 use fully translated inactive survey headings (#7836) 2026-04-30 04:46:13 +00:00
101 changed files with 3930 additions and 457 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 }}
@@ -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"
@@ -2,7 +2,7 @@
import DOMPurify from "dompurify";
import { CopyIcon, SendIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { AuthenticationError } from "@formbricks/types/errors";
@@ -21,6 +21,7 @@ interface EmailTabProps {
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [activeTab, setActiveTab] = useState("preview");
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
const { t } = useTranslation();
const emailHtml = useMemo(() => {
@@ -31,6 +32,40 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
const sanitizedEmailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
}, [emailHtmlPreview]);
const emailPreviewDocument = useMemo(() => {
if (!sanitizedEmailHtml) return "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="only light" />
<meta name="supported-color-schemes" content="light" />
<base target="_blank" />
<style>
:root {
color-scheme: only light;
supported-color-schemes: light;
}
html, body {
margin: 0;
padding: 0;
background: #ffffff;
color-scheme: only light;
}
</style>
</head>
<body>${sanitizedEmailHtml}</body>
</html>`;
}, [sanitizedEmailHtml]);
const tabs = [
{
id: "preview",
@@ -51,6 +86,25 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
getData();
}, [surveyId]);
useEffect(() => {
setPreviewFrameHeight(560);
}, [emailPreviewDocument]);
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
const { contentDocument } = event.currentTarget;
if (!contentDocument) {
return;
}
const nextHeight = Math.max(
contentDocument.body.scrollHeight,
contentDocument.documentElement.scrollHeight,
560
);
setPreviewFrameHeight(nextHeight);
};
const sendPreviewEmail = async () => {
try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
@@ -73,7 +127,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
if (activeTab === "preview") {
return (
<div className="space-y-4 pb-4">
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
<div
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
data-testid="survey-email-preview-shell">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-amber-500" />
@@ -87,9 +143,17 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
</div>
<div className="p-2">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
<div data-testid="survey-email-preview-content">
{emailPreviewDocument ? (
<iframe
className="mt-2 w-full rounded-md border-0 bg-white"
data-testid="survey-email-preview-frame"
onLoad={handlePreviewFrameLoad}
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
srcDoc={emailPreviewDocument}
style={{ height: `${previewFrameHeight}px` }}
title={t("environments.surveys.share.send_email.email_preview_tab")}
/>
) : (
<LoadingSpinner />
)}
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
describe("extractEmailBodyFragment", () => {
test("returns the body contents for rendered email documents", () => {
const html = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body class="email-body">
<table>
<tr>
<td>Preview content</td>
</tr>
</table>
</body>
</html>
`;
expect(extractEmailBodyFragment(html)).toBe(
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
);
});
test("removes document-level tags from rendered survey email markup", () => {
const fragment = extractEmailBodyFragment(`
<!DOCTYPE html>
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body>
<table>
<tr>
<td>Which fruits do you like</td>
</tr>
</table>
</body>
</html>
`);
expect(fragment).toBe(
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
);
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
});
test("falls back to the original markup when no body tag exists", () => {
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
});
test("removes React server markers from rendered fragments", () => {
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
"<div>Preview content</div>"
);
});
});
@@ -1,10 +1,12 @@
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";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
const t = await getTranslate();
@@ -17,12 +19,9 @@ 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);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");
return htmlCleaned;
return extractEmailBodyFragment(html.toString());
};
@@ -0,0 +1,11 @@
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
export const extractEmailBodyFragment = (html: string): string => {
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
};
+57 -1
View File
@@ -1,9 +1,15 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { authenticateRequest } from "./auth";
import { authenticateRequest, handleErrorResponse } from "./auth";
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
getApiKeyWithPermissions: vi.fn(),
@@ -193,3 +199,53 @@ describe("authenticateRequest", () => {
expect(result).toBeNull();
});
});
describe("handleErrorResponse", () => {
test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => {
const response = handleErrorResponse(new Error("NotAuthenticated"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("not_authenticated");
});
test("returns 401 unauthorized for 'Unauthorized' message", async () => {
const response = handleErrorResponse(new Error("Unauthorized"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("unauthorized");
});
test("returns 409 conflict for UniqueConstraintError", async () => {
const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists"));
expect(response.status).toBe(409);
const body = await response.json();
expect(body.code).toBe("conflict");
expect(body.message).toBe("Action with name foo already exists");
});
test("returns 400 badRequest for DatabaseError", async () => {
const response = handleErrorResponse(new DatabaseError("db boom"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("db boom");
});
test("returns 400 badRequest for InvalidInputError", async () => {
const response = handleErrorResponse(new InvalidInputError("bad input"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
});
test("returns 500 internalServerError for unknown errors", async () => {
const response = handleErrorResponse(new Error("something else"));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.message).toBe("Some error occurred");
});
});
+9 -1
View File
@@ -1,6 +1,11 @@
import { NextRequest } from "next/server";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
@@ -40,6 +45,9 @@ export const handleErrorResponse = (error: any): Response => {
case "Unauthorized":
return responses.unauthorizedResponse();
default:
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
@@ -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: {
@@ -1,6 +1,6 @@
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -80,6 +80,11 @@ export const POST = withV1ApiWrapper({
response: responses.successResponse(actionClass),
};
} catch (error) {
if (error instanceof UniqueConstraintError) {
return {
response: responses.conflictResponse(error.message),
};
}
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
@@ -4,7 +4,7 @@ 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";
@@ -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) {
+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;
+2 -1
View File
@@ -1192,6 +1192,7 @@ checksums:
environments/settings/profile/update_personal_info: 5806f9bae0248a604cf85a2d8790a606
environments/settings/profile/warning_cannot_delete_account: 07c25c3829149cd7171e7ad88229deac
environments/settings/profile/warning_cannot_undo: dd1b2a59ff244b362d1d0d4eb1dbf7c6
environments/settings/profile/wrong_password: e3523f78b302d11b33af6cc40d8df9da
environments/settings/teams/add_members_description: 96e1e7125a0dfeaecc2c238eda3a216f
environments/settings/teams/add_workspaces_description: f0f0cdd4d1032fbcb83d34a780bdfa52
environments/settings/teams/all_members_added: 0541be1777b5c838f2e039035488506c
@@ -1481,7 +1482,7 @@ checksums:
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
+151 -2
View File
@@ -1,12 +1,16 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
createActionClass,
deleteActionClass,
getActionClass,
getActionClassByEnvironmentIdAndName,
getActionClasses,
updateActionClass,
} from "./service";
vi.mock("@formbricks/database", () => ({
@@ -16,6 +20,8 @@ vi.mock("@formbricks/database", () => ({
findFirst: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
@@ -178,4 +184,147 @@ describe("ActionClass Service", () => {
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
});
});
describe("createActionClass", () => {
const codeInput: TActionClassInput = {
name: "Code Action",
description: "desc",
type: "code",
key: "code-action-key",
environmentId: "env-create",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should create and return the action class", async () => {
const created: TActionClass = {
id: "id-create",
createdAt: new Date(),
updatedAt: new Date(),
name: codeInput.name,
description: codeInput.description ?? null,
type: "code",
key: codeInput.type === "code" ? codeInput.key : null,
noCodeConfig: null,
environmentId: codeInput.environmentId,
};
vi.mocked(prisma.actionClass.create).mockResolvedValue(created as never);
const result = await createActionClass(codeInput.environmentId, codeInput);
expect(result).toEqual(created);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Action with name ${codeInput.name} already exists`
);
});
test("should throw UniqueConstraintError on P2002 even when target is missing", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: undefined }
)
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
});
test("should throw DatabaseError for non-P2002 errors", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(new Error("boom"));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(DatabaseError);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Database error when creating an action for environment ${codeInput.environmentId}`
);
});
});
describe("updateActionClass", () => {
const updateInput: Partial<TActionClassInput> = {
name: "Renamed Action",
description: "updated desc",
type: "code",
key: "renamed-key",
environmentId: "env-update",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should update and return the action class", async () => {
const updated = {
id: "id-update",
createdAt: new Date(),
updatedAt: new Date(),
name: updateInput.name,
description: updateInput.description ?? null,
type: "code" as const,
key: "renamed-key",
noCodeConfig: null,
environmentId: updateInput.environmentId,
surveyTriggers: [],
};
vi.mocked(prisma.actionClass.update).mockResolvedValue(updated as never);
const result = await updateActionClass(updateInput.environmentId!, "id-update", updateInput);
expect(result).toEqual(updated);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
`Action with name ${updateInput.name} already exists`
);
});
test("should throw DatabaseError for other PrismaClientKnownRequestError codes", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "test",
})
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
DatabaseError
);
});
test("should rethrow unknown errors", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(new Error("boom"));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
"boom"
);
});
});
});
+3 -3
View File
@@ -7,7 +7,7 @@ import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -135,7 +135,7 @@ export const createActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError(
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
@@ -185,7 +185,7 @@ export const updateActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError(
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
+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;
+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;
};
@@ -12,6 +12,7 @@ import {
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
UniqueConstraintError,
UnknownError,
ValidationError,
isExpectedError,
@@ -74,6 +75,7 @@ describe("isExpectedError (shared helper)", () => {
"OperationNotAllowedError",
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
"UniqueConstraintError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
@@ -91,6 +93,7 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: ValidationError, args: ["Invalid data"] },
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true);
@@ -186,6 +189,14 @@ describe("actionClient handleServerError", () => {
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("UniqueConstraintError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(
new UniqueConstraintError("Action with name foo already exists")
);
expect(result?.serverError).toBe("Action with name foo already exists");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
describe("unexpected errors SHOULD be reported to Sentry", () => {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Dein Link ist abgelaufen.",
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig.",
"link_expired_heading": "Dein Link ist abgelaufen."
},
"common": {
"accepted": "Akzeptiert",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Frageeinstellungen ausblenden",
"hostname": "Hostname",
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Immer anzeigen, wenn ausgelöst, bis eine Antwort oder Teilantwort übermittelt wurde.",
"ignore_global_waiting_time": "Abkühlphase ignorieren",
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
"image": "Bild",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "Bestätige deine E-Mail, um zu antworten",
"verify_email_before_submission_button": "Überprüfen",
"verify_email_before_submission_description": "Um an dieser Umfrage teilzunehmen, bitte bestätige deine E-Mail",
"want_to_respond": "Möchtest Du antworten?"
"want_to_respond": "Möchtest Du antworten?",
"paused_heading": "Pausiert",
"completed_heading": "Abgeschlossen"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Your link is expired.",
"link_expired_description": "The link you used is no longer valid."
"link_expired_description": "The link you used is no longer valid.",
"link_expired_heading": "Your link is expired."
},
"common": {
"accepted": "Accepted",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Hide Question settings",
"hostname": "Hostname",
"if_you_need_more_please": "If you need more, please",
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response is submitted.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response or partial response is submitted.",
"ignore_global_waiting_time": "Ignore Cooldown Period",
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
"image": "Image",
@@ -2446,7 +2447,9 @@
"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?"
"want_to_respond": "Want to respond?",
"paused_heading": "Paused",
"completed_heading": "Completed"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Tu enlace ha caducado.",
"link_expired_description": "El enlace que has utilizado ya no es válido."
"link_expired_description": "El enlace que has utilizado ya no es válido.",
"link_expired_heading": "Tu enlace ha caducado."
},
"common": {
"accepted": "Aceptado",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Ocultar ajustes de la pregunta",
"hostname": "Nombre de host",
"if_you_need_more_please": "Si necesitas más, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cuando se active hasta que se envíe una respuesta.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cada vez que se active hasta que se envíe una respuesta o respuesta parcial.",
"ignore_global_waiting_time": "Ignorar periodo de espera",
"ignore_global_waiting_time_description": "Esta encuesta puede mostrarse siempre que se cumplan sus condiciones, incluso si otra encuesta se mostró recientemente.",
"image": "Imagen",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "Verifica tu correo electrónico para responder",
"verify_email_before_submission_button": "Verificar",
"verify_email_before_submission_description": "Para responder a esta encuesta, por favor verifica tu correo electrónico",
"want_to_respond": "¿Quieres responder?"
"want_to_respond": "¿Quieres responder?",
"paused_heading": "Pausado",
"completed_heading": "Completado"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Votre lien est expiré.",
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide.",
"link_expired_heading": "Votre lien est expiré."
},
"common": {
"accepted": "Accepté",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Masquer les paramètres de la question",
"hostname": "Nom d'hôte",
"if_you_need_more_please": "Si vous avez besoin de plus, veuillez",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse ou une réponse partielle soit soumise.",
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
"image": "Image",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "Vérifiez votre email pour répondre.",
"verify_email_before_submission_button": "Vérifier",
"verify_email_before_submission_description": "Pour répondre à cette enquête, veuillez vérifier votre e-mail.",
"want_to_respond": "Voulez-vous répondre ?"
"want_to_respond": "Voulez-vous répondre ?",
"paused_heading": "En pause",
"completed_heading": "Terminé"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "A hivatkozása lejárt.",
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes."
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes.",
"link_expired_heading": "A hivatkozása lejárt."
},
"common": {
"accepted": "Elfogadva",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Kérdésbeállítások elrejtése",
"hostname": "Gépnév",
"if_you_need_more_please": "Ha többre van szüksége, akkor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Maradjon megjelenítve bármikor is aktiválódott, amíg egy választ el nem küldenek.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Továbbra is megjelenítés minden egyes aktiváláskor, amíg választ vagy részleges választ nem küldenek be.",
"ignore_global_waiting_time": "Várakozási időszak figyelmen kívül hagyása",
"ignore_global_waiting_time_description": "Ez a kérdőív akkor jelenhet meg, ha a feltételei teljesülnek, még akkor is, ha egy másik kérdőív jelent meg nemrég.",
"image": "Kép",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "Ellenőrizze az e-mail-címét a válaszadáshoz",
"verify_email_before_submission_button": "Ellenőrzés",
"verify_email_before_submission_description": "A kérdőívre való válaszadáshoz ellenőrizze az e-mail-címét",
"want_to_respond": "Szeretne válaszolni?"
"want_to_respond": "Szeretne válaszolni?",
"paused_heading": "Szüneteltetve",
"completed_heading": "Befejezve"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "リンクの有効期限が切れています。",
"link_expired_description": "使用したリンクはすでに無効です。"
"link_expired_description": "使用したリンクはすでに無効です。",
"link_expired_heading": "リンクの有効期限が切れています。"
},
"common": {
"accepted": "承認済み",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "質問設定を非表示",
"hostname": "ホスト名",
"if_you_need_more_please": "さらに必要な場合は、",
"if_you_really_want_that_answer_ask_until_you_get_it": "回答が提出されるまで、トリガーされるたびに表示し続けます。",
"if_you_really_want_that_answer_ask_until_you_get_it": "回答または一部の回答が送信されるまで、トリガーされるたびに表示し続けます。",
"ignore_global_waiting_time": "クールダウン期間を無視",
"ignore_global_waiting_time_description": "このフォームは、最近別のフォームが表示されていても、条件が満たされればいつでも表示できます。",
"image": "画像",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "回答するにはメールアドレスを認証してください",
"verify_email_before_submission_button": "認証",
"verify_email_before_submission_description": "このフォームに回答するには、メールアドレスを認証してください",
"want_to_respond": "回答しますか?"
"want_to_respond": "回答しますか?",
"paused_heading": "一時停止",
"completed_heading": "完了"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Uw link is verlopen.",
"link_expired_description": "De link die u gebruikte is niet meer geldig."
"link_expired_description": "De link die u gebruikte is niet meer geldig.",
"link_expired_heading": "Uw link is verlopen."
},
"common": {
"accepted": "Geaccepteerd",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Vraaginstellingen verbergen",
"hostname": "Hostnaam",
"if_you_need_more_please": "Als je meer nodig hebt,",
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een reactie is ingediend.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een antwoord of gedeeltelijk antwoord is ingediend.",
"ignore_global_waiting_time": "Afkoelperiode negeren",
"ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.",
"image": "Afbeelding",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "Verifieer uw e-mailadres om te reageren",
"verify_email_before_submission_button": "Verifiëren",
"verify_email_before_submission_description": "Om op deze enquête te reageren, dient u uw e-mailadres te verifiëren",
"want_to_respond": "Wilt u reageren?"
"want_to_respond": "Wilt u reageren?",
"paused_heading": "Gepauzeerd",
"completed_heading": "Voltooid"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Seu link está expirado.",
"link_expired_description": "O link que você usou não é mais válido."
"link_expired_description": "O link que você usou não é mais válido.",
"link_expired_heading": "Seu link está expirado."
},
"common": {
"accepted": "Aceito",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Ocultar configurações da pergunta",
"hostname": "nome do host",
"if_you_need_more_please": "Se você precisar de mais, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar mostrando sempre que acionada até que uma resposta seja enviada.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continue mostrando sempre que acionado até que uma resposta ou resposta parcial seja enviada.",
"ignore_global_waiting_time": "Ignorar período de espera",
"ignore_global_waiting_time_description": "Esta pesquisa pode ser mostrada sempre que suas condições forem atendidas, mesmo que outra pesquisa tenha sido mostrada recentemente.",
"image": "imagem",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "Verifique seu e-mail para responder",
"verify_email_before_submission_button": "Verificar",
"verify_email_before_submission_description": "Para responder a esta pesquisa, confirme seu e-mail",
"want_to_respond": "Quer responder?"
"want_to_respond": "Quer responder?",
"paused_heading": "Pausado",
"completed_heading": "Concluído"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "O seu link expirou.",
"link_expired_description": "O link que utilizou já não é válido."
"link_expired_description": "O link que utilizou já não é válido.",
"link_expired_heading": "O seu link expirou."
},
"common": {
"accepted": "Aceite",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Ocultar definições da pergunta",
"hostname": "Nome do host",
"if_you_need_more_please": "Se precisar de mais, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta seja submetida.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta ou resposta parcial seja submetida.",
"ignore_global_waiting_time": "Ignorar período de espera",
"ignore_global_waiting_time_description": "Este inquérito pode ser mostrado sempre que as suas condições forem cumpridas, mesmo que outro inquérito tenha sido mostrado recentemente.",
"image": "Imagem",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "Verifique o seu email para responder",
"verify_email_before_submission_button": "Verificar",
"verify_email_before_submission_description": "Para responder a este questionário, por favor verifique o seu email",
"want_to_respond": "Quer responder?"
"want_to_respond": "Quer responder?",
"paused_heading": "Em pausa",
"completed_heading": "Concluído"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Link-ul dumneavoastră a expirat.",
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil."
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil.",
"link_expired_heading": "Link-ul dumneavoastră a expirat."
},
"common": {
"accepted": "Acceptat",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Ascunde setările întrebării",
"hostname": "Nume gazdă",
"if_you_need_more_please": "Dacă aveți nevoie de mai mult, vă rugăm",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișezi de fiecare dată când este declanșat, până când se trimite un răspuns sau un răspuns parțial.",
"ignore_global_waiting_time": "Ignoră perioada de răcire",
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
"image": "Imagine",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "Verificați-vă emailul pentru a răspunde",
"verify_email_before_submission_button": "Verifică",
"verify_email_before_submission_description": "Pentru a răspunde la acest sondaj, vă rugăm să vă verificați emailul",
"want_to_respond": "Dorești să răspunzi?"
"want_to_respond": "Dorești să răspunzi?",
"paused_heading": "Pauză",
"completed_heading": "Completat"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Ваша ссылка истекла.",
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна."
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна.",
"link_expired_heading": "Ваша ссылка истекла."
},
"common": {
"accepted": "Принято",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Скрыть настройки вопроса",
"hostname": "Имя хоста",
"if_you_need_more_please": "Если вам нужно больше, пожалуйста",
"if_you_really_want_that_answer_ask_until_you_get_it": оказывать каждый раз при срабатывании, пока не будет получен ответ.",
"if_you_really_want_that_answer_ask_until_you_get_it": родолжай показывать при каждом срабатывании триггера, пока не будет отправлен ответ или частичный ответ.",
"ignore_global_waiting_time": "Игнорировать период ожидания",
"ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
"image": "Изображение",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "Подтвердите свой email, чтобы ответить",
"verify_email_before_submission_button": "Подтвердить",
"verify_email_before_submission_description": "Чтобы ответить на этот опрос, пожалуйста, подтвердите свой email",
"want_to_respond": "Хотите ответить?"
"want_to_respond": "Хотите ответить?",
"paused_heading": "Приостановлено",
"completed_heading": "Завершено"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Din länk har gått ut.",
"link_expired_description": "Länken du använde är inte längre giltig."
"link_expired_description": "Länken du använde är inte längre giltig.",
"link_expired_heading": "Din länk har gått ut."
},
"common": {
"accepted": "Accepterad",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Dölj frågeinställningar",
"hostname": "Värdnamn",
"if_you_need_more_please": "Om du behöver mer, vänligen",
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa när villkoren är uppfyllda tills ett svar skickas in.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa varje gång det utlöses tills ett svar eller ett delsvar skickas in.",
"ignore_global_waiting_time": "Ignorera väntetid",
"ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.",
"image": "Bild",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "Verifiera din e-post för att svara",
"verify_email_before_submission_button": "Verifiera",
"verify_email_before_submission_description": "För att svara på denna enkät, vänligen verifiera din e-post",
"want_to_respond": "Vill du svara?"
"want_to_respond": "Vill du svara?",
"paused_heading": "Pausad",
"completed_heading": "Slutförd"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Bağlantınızın süresi doldu.",
"link_expired_description": "Kullandığınız bağlantı artık geçerli değil."
"link_expired_description": "Kullandığınız bağlantı artık geçerli değil.",
"link_expired_heading": "Bağlantınızın süresi doldu."
},
"common": {
"accepted": "Kabul Edildi",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "Soru ayarlarını gizle",
"hostname": "Ana bilgisayar adı",
"if_you_need_more_please": "Daha fazlasına ihtiyacınız varsa lütfen",
"if_you_really_want_that_answer_ask_until_you_get_it": "Yanıt gönderilene kadar tetiklendiğinde göstermeye devam et.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Bir yanıt veya kısmi yanıt gönderilene kadar her tetiklendiğinde göstermeye devam et.",
"ignore_global_waiting_time": "Bekleme Süresini Yoksay",
"ignore_global_waiting_time_description": "Bu survey, yakın zamanda başka bir survey gösterilmiş olsa bile koşulları sağlandığında gösterilebilir.",
"image": "Görsel",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "Yanıtlamak için email adresinizi doğrulayın",
"verify_email_before_submission_button": "Doğrula",
"verify_email_before_submission_description": "Bu survey'e yanıt vermek için lütfen email adresinizi doğrulayın",
"want_to_respond": "Yanıtlamak ister misiniz?"
"want_to_respond": "Yanıtlamak ister misiniz?",
"paused_heading": "Duraklatıldı",
"completed_heading": "Tamamlandı"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "您的 链接 已过期。",
"link_expired_description": "您 使用 的 链接 已失效。"
"link_expired_description": "您 使用 的 链接 已失效。",
"link_expired_heading": "您的 链接 已过期。"
},
"common": {
"accepted": "已接受",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "隐藏问题设置",
"hostname": "主 机 名",
"if_you_need_more_please": "如果您需要更多,请",
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
"if_you_really_want_that_answer_ask_until_you_get_it": "持续在触发时显示,直到提交响应或部分响应。",
"ignore_global_waiting_time": "忽略冷却期",
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
"image": "图片",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "验证 您的 邮件 以 响应",
"verify_email_before_submission_button": "验证",
"verify_email_before_submission_description": "要 响应 此 调查,请 验证 您的 邮件",
"want_to_respond": "想要 参与 吗?"
"want_to_respond": "想要 参与 吗?",
"paused_heading": "暂停",
"completed_heading": "完成"
},
"setup": {
"intro": {
+6 -3
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "您 的 連結 已過期。",
"link_expired_description": "您 使用 的 連結 已無效。"
"link_expired_description": "您 使用 的 連結 已無效。",
"link_expired_heading": "您 的 連結 已過期。"
},
"common": {
"accepted": "已接受",
@@ -1553,7 +1554,7 @@
"hide_question_settings": "隱藏問題設定",
"hostname": "主機名稱",
"if_you_need_more_please": "如果您需要更多,請",
"if_you_really_want_that_answer_ask_until_you_get_it": "每次觸發時都顯示,直到提交回應為止。",
"if_you_really_want_that_answer_ask_until_you_get_it": "持續顯示,直到使用者提交回應或部分回應為止。",
"ignore_global_waiting_time": "忽略冷卻期",
"ignore_global_waiting_time_description": "此問卷在符合條件時即可顯示,即使最近已顯示過其他問卷。",
"image": "圖片",
@@ -2446,7 +2447,9 @@
"verify_email_before_submission": "驗證您的電子郵件以回應",
"verify_email_before_submission_button": "驗證",
"verify_email_before_submission_description": "若要回應此問卷,請驗證您的電子郵件",
"want_to_respond": "想要回應嗎?"
"want_to_respond": "想要回應嗎?",
"paused_heading": "已暫停",
"completed_heading": "已完成"
},
"setup": {
"intro": {
@@ -175,7 +175,7 @@ describe("authOptions", () => {
);
}, 15000);
test("should throw error if user has no password stored", async () => {
test("should throw generic invalid credentials error if user has no password stored", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
@@ -186,7 +186,7 @@ describe("authOptions", () => {
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"User has no password stored"
"Invalid credentials"
);
}, 15000);
+1 -1
View File
@@ -117,7 +117,7 @@ export const authOptions: NextAuthOptions = {
if (!user.password) {
logAuthAttempt("no_password_set", "credentials", "password_validation", user.id, user.email);
throw new Error("User has no password stored");
throw new Error("Invalid credentials");
}
if (user.isActive === false) {
@@ -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);
@@ -264,6 +264,14 @@ const provisionNewSsoUser = async ({
"License and instance configuration checked"
);
if (!isFirstUser && !isMultiOrgEnabled && SKIP_INVITE_FOR_SSO && !DEFAULT_TEAM_ID) {
contextLogger.error(
{ reason: "missing_default_team_id" },
"SSO callback rejected: AUTH_SKIP_INVITE_FOR_SSO is enabled but AUTH_SSO_DEFAULT_TEAM_ID is not configured. Refusing to auto-provision new SSO user into an arbitrary organization."
);
return false;
}
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
if (!callbackUrl) {
contextLogger.debug(
@@ -120,12 +120,21 @@ vi.mock("@formbricks/logger", () => ({
},
}));
const constantsOverrides = vi.hoisted(() => ({
SKIP_INVITE_FOR_SSO: false as boolean,
DEFAULT_TEAM_ID: "team-123" as string | undefined,
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
SKIP_INVITE_FOR_SSO: 0,
DEFAULT_TEAM_ID: "team-123",
get SKIP_INVITE_FOR_SSO() {
return constantsOverrides.SKIP_INVITE_FOR_SSO;
},
get DEFAULT_TEAM_ID() {
return constantsOverrides.DEFAULT_TEAM_ID;
},
};
});
@@ -147,6 +156,8 @@ const transactionUser = {
describe("handleSsoCallback", () => {
beforeEach(() => {
vi.clearAllMocks();
constantsOverrides.SKIP_INVITE_FOR_SSO = false;
constantsOverrides.DEFAULT_TEAM_ID = "team-123";
vi.mocked(prisma.$transaction).mockImplementation(
async (callback: (tx: any) => unknown) =>
@@ -705,6 +716,80 @@ describe("handleSsoCallback", () => {
expect(createUser).not.toHaveBeenCalled();
});
test("rejects auto-provisioning when SKIP_INVITE_FOR_SSO is enabled but DEFAULT_TEAM_ID is missing", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
constantsOverrides.DEFAULT_TEAM_ID = undefined;
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(false);
expect(getFirstOrganization).not.toHaveBeenCalled();
expect(getOrganizationByTeamId).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
expect(createMembership).not.toHaveBeenCalled();
});
test("rejects auto-provisioning when DEFAULT_TEAM_ID is empty string", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
constantsOverrides.DEFAULT_TEAM_ID = "";
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(false);
expect(getFirstOrganization).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
});
test("allows auto-provisioning when SKIP_INVITE_FOR_SSO and DEFAULT_TEAM_ID are both set", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
constantsOverrides.DEFAULT_TEAM_ID = "team-123";
vi.mocked(createUser).mockResolvedValue(
mockCreatedUser("Auto Provisioned User") as typeof mockUser & {
notificationSettings: { alert: Record<string, never>; unsubscribedOrganizationIds: string[] };
}
);
transactionAccount.findUnique.mockResolvedValue(null);
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(getOrganizationByTeamId).toHaveBeenCalledWith("team-123");
expect(getFirstOrganization).not.toHaveBeenCalled();
expect(createMembership).toHaveBeenCalledWith(
mockOrganization.id,
mockUser.id,
{ role: "member", accepted: true },
expect.anything()
);
});
test("assigns invited SSO users into the resolved organization and syncs notification settings", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,445 @@
import type { TFunction } from "i18next";
import { describe, expect, test } from "vitest";
import { renderEmbedSurveyPreviewEmail } from "@formbricks/email";
import { exampleData } from "@formbricks/email/src/lib/example-data";
import { embedSurveyPreviewEmailHtml } from "@formbricks/email/src/lib/fixtures/embed-survey-preview-email-html";
import { t as mockT } from "@formbricks/email/src/lib/mock-translate";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { extractEmailBodyFragment } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplateFragment";
import { mixColor } from "@/lib/utils/colors";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import {
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS,
EMBED_SURVEY_PREVIEW_CHOICE_IDS,
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS,
EMBED_SURVEY_PREVIEW_CTA_URL,
EMBED_SURVEY_PREVIEW_HEADLINE,
EMBED_SURVEY_PREVIEW_LOCALE,
EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS,
EMBED_SURVEY_PREVIEW_MATRIX_ROWS,
EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER,
EMBED_SURVEY_PREVIEW_PICTURE_CHOICES,
EMBED_SURVEY_PREVIEW_QUESTION_ID,
EMBED_SURVEY_PREVIEW_STYLING,
EMBED_SURVEY_PREVIEW_SURVEY_URL,
createEmbedSurveyPreviewEmailSurvey,
} from "@/modules/email/fixtures/embed-survey-preview-email-fixture";
const mockPreviewT = mockT as unknown as TFunction;
const renderPreviewHtml = (type?: TSurveyElementTypeEnum) =>
getPreviewEmailTemplateHtml(
createEmbedSurveyPreviewEmailSurvey(type),
EMBED_SURVEY_PREVIEW_SURVEY_URL,
EMBED_SURVEY_PREVIEW_STYLING,
EMBED_SURVEY_PREVIEW_LOCALE,
mockPreviewT
);
const renderPreviewFragment = async (type?: TSurveyElementTypeEnum) =>
extractEmailBodyFragment(await renderPreviewHtml(type));
const normalizeStyleAttribute = (style: string) =>
style
.split(";")
.map((declaration) => declaration.trim().replace(/\s*:\s*/g, ":"))
.filter(Boolean)
.sort()
.join(";");
const normalizeHtml = (html: string) =>
html
.replace(
/style="([^"]*)"/g,
(_match: string, style: string) => `style="${normalizeStyleAttribute(style)}"`
)
.replace(/\s+/g, " ")
.replace(/\s*([:;])\s*/g, "$1")
.replace(/\s*=\s*/g, "=")
.replace(/="\s+/g, '="')
.replace(/>\s+/g, ">")
.replace(/\s+</g, "<")
.replace(/\s+>/g, ">")
.trim();
const decodeSerializedHrefEntities = (html: string) => html.replaceAll("&amp;", "&");
const expectSharedPreviewSignals = (html: string) => {
expect(html).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
expect(html).toContain(EMBED_SURVEY_PREVIEW_HEADLINE);
expect(html).toContain("skipPrefilled=true");
expect(html).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples)}`
);
expect(html).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas)}`
);
expect(html).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples)}`
);
expect(html).toContain("utm_source=email_branding");
};
const expectPreviewFragmentBaseSignals = (html: string) => {
expect(html).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
expect(html).toContain(EMBED_SURVEY_PREVIEW_HEADLINE);
};
const runtimeCoverageCases = [
{
name: "open text",
type: TSurveyElementTypeEnum.OpenText,
expectedTexts: [EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER],
expectedHrefFragments: ["preview=true"],
},
{
name: "consent",
type: TSurveyElementTypeEnum.Consent,
expectedTexts: ["I agree to be contacted", "Accept", "Reject"],
expectedHrefFragments: [
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=accepted&skipPrefilled=true`,
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=dismissed&skipPrefilled=true`,
],
},
{
name: "nps",
type: TSurveyElementTypeEnum.NPS,
expectedTexts: ["Not likely", "Very likely"],
expectedHrefFragments: [`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=0&skipPrefilled=true`],
},
{
name: "cta",
type: TSurveyElementTypeEnum.CTA,
expectedTexts: ["Open the docs"],
expectedHrefFragments: [EMBED_SURVEY_PREVIEW_CTA_URL],
},
{
name: "rating",
type: TSurveyElementTypeEnum.Rating,
expectedTexts: ["Poor", "Great"],
expectedHrefFragments: [`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=1&skipPrefilled=true`],
},
{
name: "picture selection",
type: TSurveyElementTypeEnum.PictureSelection,
expectedTexts: [],
expectedHrefFragments: [
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=embed-survey-preview-picture-1&skipPrefilled=true`,
EMBED_SURVEY_PREVIEW_PICTURE_CHOICES[0],
],
},
{
name: "cal",
type: TSurveyElementTypeEnum.Cal,
expectedTexts: ["Schedule your meeting"],
expectedHrefFragments: ["preview=true"],
},
{
name: "date",
type: TSurveyElementTypeEnum.Date,
expectedTexts: ["Select a date"],
expectedHrefFragments: ["preview=true"],
},
{
name: "matrix",
type: TSurveyElementTypeEnum.Matrix,
expectedTexts: [EMBED_SURVEY_PREVIEW_MATRIX_ROWS[0], EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS[0], "Continue"],
expectedHrefFragments: ["preview=true"],
},
{
name: "address",
type: TSurveyElementTypeEnum.Address,
expectedTexts: [
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1,
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.city,
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.country,
],
expectedHrefFragments: ["preview=true"],
},
{
name: "ranking",
type: TSurveyElementTypeEnum.Ranking,
expectedTexts: ["Apples", "Bananas", "Pineapples"],
expectedHrefFragments: ["preview=true"],
},
{
name: "contact info",
type: TSurveyElementTypeEnum.ContactInfo,
expectedTexts: [
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName,
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.email,
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.company,
],
expectedHrefFragments: ["preview=true"],
},
{
name: "file upload",
type: TSurveyElementTypeEnum.FileUpload,
expectedTexts: ["Click or drag to upload files."],
expectedHrefFragments: ["preview=true"],
},
] as const;
describe("renderEmbedSurveyPreviewEmail", () => {
test("keeps the checked-in preview fixture aligned with the runtime survey email behaviors", async () => {
const runtimeFragment = await renderPreviewFragment();
expectSharedPreviewSignals(runtimeFragment);
expectSharedPreviewSignals(embedSurveyPreviewEmailHtml);
expect(runtimeFragment).toContain("font-family:Inter, Helvetica, Arial, sans-serif");
expect(embedSurveyPreviewEmailHtml).toContain("font-family:Inter, Helvetica, Arial, sans-serif");
expect(runtimeFragment).toContain("color-scheme:only light");
expect(embedSurveyPreviewEmailHtml).toContain("color-scheme:only light");
expect(runtimeFragment).toContain('bgcolor="#4a865f"');
expect(embedSurveyPreviewEmailHtml).toContain('bgcolor="#4a865f"');
expect(runtimeFragment).not.toContain('bgcolor="#ffffff"');
expect(embedSurveyPreviewEmailHtml).not.toContain('bgcolor="#ffffff"');
expect(runtimeFragment).not.toContain("background-image");
expect(embedSurveyPreviewEmailHtml).not.toContain("background-image");
expect(runtimeFragment).toContain("background-color:#ffffff !important");
expect(embedSurveyPreviewEmailHtml).toContain("background-color:#ffffff !important");
expect(runtimeFragment).toContain("border-radius:0.25rem");
expect(embedSurveyPreviewEmailHtml).toContain("border-radius:0.25rem");
expect(runtimeFragment).not.toContain("☐");
expect(embedSurveyPreviewEmailHtml).not.toContain("☐");
expect(normalizeHtml(runtimeFragment)).toBe(normalizeHtml(embedSurveyPreviewEmailHtml));
});
test("renders the checked-in survey preview fixture inside the React Email wrapper", async () => {
const renderedHtml = await renderEmbedSurveyPreviewEmail({
...exampleData.embedSurveyPreviewEmail,
t: mockT,
});
expect(exampleData.embedSurveyPreviewEmail.html).toBe(embedSurveyPreviewEmailHtml);
expect(exampleData.embedSurveyPreviewEmail.html).toContain(EMBED_SURVEY_PREVIEW_HEADLINE);
expect(exampleData.embedSurveyPreviewEmail.html).toContain("border-radius:0.25rem");
expect(exampleData.embedSurveyPreviewEmail.html).toContain("Apples");
expect(exampleData.embedSurveyPreviewEmail.html).not.toContain("Example Survey Embed");
expect(exampleData.embedSurveyPreviewEmail.html).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
expect(renderedHtml).toContain('name="color-scheme"');
expect(renderedHtml).toContain('content="only light"');
expect(renderedHtml).toContain('name="supported-color-schemes"');
expect(renderedHtml).not.toContain("@media");
expect(renderedHtml).not.toContain("background-image");
expect(normalizeHtml(renderedHtml)).toContain(normalizeHtml(embedSurveyPreviewEmailHtml));
});
test.each(runtimeCoverageCases)(
"covers the $name preview branch without nested document markup",
async ({ type, expectedTexts, expectedHrefFragments }) => {
const fragment = await renderPreviewFragment(type);
const decodedHrefFragment = decodeSerializedHrefEntities(fragment);
expectPreviewFragmentBaseSignals(fragment);
for (const expectedText of expectedTexts) {
expect(fragment).toContain(expectedText);
}
for (const expectedHrefFragment of expectedHrefFragments) {
expect(decodedHrefFragment).toContain(expectedHrefFragment);
}
}
);
test("uses per-option prefill links for multiple-choice preview emails", async () => {
const multiChoiceFragment = await renderPreviewFragment(TSurveyElementTypeEnum.MultipleChoiceMulti);
expect(multiChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples)}`
);
expect(multiChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas)}`
);
expect(multiChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples)}`
);
expect(multiChoiceFragment).toContain("skipPrefilled=true");
expect(multiChoiceFragment).toContain("border-radius:0.25rem");
expect(multiChoiceFragment).toContain("height:1rem");
expect(multiChoiceFragment).not.toContain("☐");
expect(multiChoiceFragment).not.toMatch(/<a\b[^>]*style="[^"]*width:\s*100%/i);
});
test("uses stable encoded choice ids for single-choice prefilling links", async () => {
const singleChoiceFragment = await renderPreviewFragment(TSurveyElementTypeEnum.MultipleChoiceSingle);
expect(singleChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples)}`
);
expect(singleChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas)}`
);
expect(singleChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples)}`
);
expect(singleChoiceFragment).toContain("skipPrefilled=true");
expect(singleChoiceFragment).toContain("border-radius:9999px");
expect(singleChoiceFragment).not.toContain("◯");
});
test("keeps preview controls close to survey UI styling", async () => {
const [
rankingFragment,
npsFragment,
ratingFragment,
dateFragment,
ctaFragment,
fileUploadFragment,
addressFragment,
contactInfoFragment,
matrixFragment,
] = await Promise.all([
renderPreviewFragment(TSurveyElementTypeEnum.Ranking),
renderPreviewFragment(TSurveyElementTypeEnum.NPS),
renderPreviewFragment(TSurveyElementTypeEnum.Rating),
renderPreviewFragment(TSurveyElementTypeEnum.Date),
renderPreviewFragment(TSurveyElementTypeEnum.CTA),
renderPreviewFragment(TSurveyElementTypeEnum.FileUpload),
renderPreviewFragment(TSurveyElementTypeEnum.Address),
renderPreviewFragment(TSurveyElementTypeEnum.ContactInfo),
renderPreviewFragment(TSurveyElementTypeEnum.Matrix),
]);
expect(rankingFragment).toContain("1px dashed #22c55e");
expect(npsFragment).toContain("box-sizing:border-box");
expect(npsFragment).toContain("height:47px");
expect(npsFragment).toContain("line-height:41px");
expect(npsFragment).toContain("border-radius:8px 0 0 8px");
expect(npsFragment).toContain("border-radius:0 8px 8px 0");
expect(npsFragment).toContain("border-left:0 !important");
expect(npsFragment).not.toContain("line-height:120%");
expect(npsFragment).not.toContain("mso-text-raise");
expect(npsFragment).not.toContain("padding-left:4px");
expect(ratingFragment).toContain("height:47px");
expect(ratingFragment).toContain("line-height:41px");
expect(ratingFragment).toContain("border-radius:8px 0 0 8px");
expect(ratingFragment).toContain("border-radius:0 8px 8px 0");
expect(ratingFragment).toContain("border-left:0 !important");
expect(ratingFragment).toMatch(/target="_blank"\s*>1<\/a/);
expect(ratingFragment).not.toContain("height:46px");
expect(dateFragment).toContain("lucide-calendar-days");
expect(ctaFragment).toContain("text-align:left");
expect(ctaFragment).toContain("lucide-square-arrow-out-up-right");
expect(ctaFragment).not.toContain("↗");
expect(fileUploadFragment).toContain("lucide-upload");
expect(fileUploadFragment).toContain("2px dashed #d6e4dc");
expect(fileUploadFragment).toContain("height:96px");
expect(fileUploadFragment).toContain("line-height:96px");
expect(fileUploadFragment).not.toContain("#64748b");
expect(addressFragment).toContain("width:100%");
expect(addressFragment).toMatch(
new RegExp(`<p[^>]*>\\s*${EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1}\\s*</p>`)
);
expect(addressFragment).not.toContain(`>${EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1}</a>`);
expect(contactInfoFragment).toContain("box-sizing:border-box");
expect(contactInfoFragment).toMatch(
new RegExp(`<p[^>]*>\\s*${EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName}\\s*</p>`)
);
expect(contactInfoFragment).not.toContain(`>${EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName}</a>`);
expect(matrixFragment).toContain("text-align:center");
expect(matrixFragment).toContain("height:1rem;width:1rem");
expect(matrixFragment).toContain("border-radius:9999px");
expect(matrixFragment).not.toContain("margin-right:0.75rem");
});
test("matches survey OpenText input sizing and placeholder opacity", async () => {
const singleLineSurvey = createEmbedSurveyPreviewEmailSurvey(TSurveyElementTypeEnum.OpenText);
const openTextQuestion = singleLineSurvey.blocks[0].elements[0];
if (openTextQuestion.type !== TSurveyElementTypeEnum.OpenText) {
throw new Error("Expected open text question fixture");
}
openTextQuestion.headline = {
default:
'<p class="fb-editor-paragraph" dir="ltr"><span style="white-space: pre-wrap;">Open text block</span></p>',
};
openTextQuestion.longAnswer = false;
const openTextStyling = {
...EMBED_SURVEY_PREVIEW_STYLING,
inputColor: { light: "#fcedf0" },
inputTextColor: { light: "#901629" },
inputPlaceholderOpacity: 0.5,
inputHeight: 20,
};
const expectedPlaceholderColor = mixColor(mixColor("#901629", "#ffffff", 0.3), "#fcedf0", 0.5);
const singleLineHtml = await getPreviewEmailTemplateHtml(
singleLineSurvey,
EMBED_SURVEY_PREVIEW_SURVEY_URL,
openTextStyling,
EMBED_SURVEY_PREVIEW_LOCALE,
mockPreviewT
);
const singleLineFragment = extractEmailBodyFragment(singleLineHtml);
expect(singleLineFragment).toContain(EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER);
expect(singleLineFragment).toContain('class="fb-editor-paragraph" dir="ltr" style="margin:0"');
expect(singleLineFragment).toContain("min-height:20px");
expect(singleLineFragment).toContain(`color:${expectedPlaceholderColor} !important`);
expect(singleLineFragment).toContain("text-decoration:none !important");
expect(singleLineFragment).toContain("background-color:#fcedf0 !important");
expect(singleLineFragment).toMatch(
/<div\b[^>]*style="[^"]*background-color:#fcedf0 !important[^"]*border-radius:8px[^"]*overflow:hidden[^"]*padding:8px 8px/
);
expect(singleLineFragment).toContain("overflow:hidden");
expect(singleLineFragment).toContain("padding:8px 8px");
expect(singleLineFragment).not.toMatch(/<td\b[^>]*bgcolor="#fcedf0"/);
expect(singleLineFragment).toMatch(/<a\b[\s\S]*?>\s*<span\b/i);
expect(singleLineFragment).toContain(`>${EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER}</span`);
expect(singleLineFragment).not.toMatch(/<a\b[^>]*>\s*<p\b/i);
expect(singleLineFragment).not.toMatch(/<a\b[^>]*border:1px solid/i);
expect(singleLineFragment).not.toContain("min-height:64px");
openTextQuestion.longAnswer = true;
const longAnswerHtml = await getPreviewEmailTemplateHtml(
singleLineSurvey,
EMBED_SURVEY_PREVIEW_SURVEY_URL,
openTextStyling,
EMBED_SURVEY_PREVIEW_LOCALE,
mockPreviewT
);
const longAnswerFragment = extractEmailBodyFragment(longAnswerHtml);
expect(longAnswerFragment).toContain("min-height:64px");
expect(longAnswerFragment).toContain("background-color:#fcedf0 !important");
expect(longAnswerFragment).toMatch(
/<div\b[^>]*style="[^"]*background-color:#fcedf0 !important[^"]*border-radius:8px[^"]*overflow:hidden[^"]*padding:8px 8px/
);
expect(longAnswerFragment).toContain("overflow:hidden");
expect(longAnswerFragment).toContain("padding:8px 8px");
expect(longAnswerFragment).not.toMatch(/<td\b[^>]*bgcolor="#fcedf0"/);
expect(longAnswerFragment).toContain("text-decoration:none !important");
expect(longAnswerFragment).toMatch(/<a\b[\s\S]*?>\s*<span\b/i);
expect(longAnswerFragment).toContain(`>${EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER}</span`);
expect(longAnswerFragment).not.toMatch(/<a\b[^>]*>\s*<p\b/i);
expect(longAnswerFragment).not.toMatch(/<a\b[^>]*border:1px solid/i);
});
test("renders star ratings with SVG icons instead of emoji", async () => {
const starRatingSurvey = createEmbedSurveyPreviewEmailSurvey(TSurveyElementTypeEnum.Rating);
const ratingQuestion = starRatingSurvey.blocks[0].elements[0];
if (ratingQuestion.type !== TSurveyElementTypeEnum.Rating) {
throw new Error("Expected rating question fixture");
}
ratingQuestion.scale = "star";
ratingQuestion.isColorCodingEnabled = false;
const starRatingHtml = await getPreviewEmailTemplateHtml(
starRatingSurvey,
EMBED_SURVEY_PREVIEW_SURVEY_URL,
EMBED_SURVEY_PREVIEW_STYLING,
EMBED_SURVEY_PREVIEW_LOCALE,
mockPreviewT
);
const starRatingFragment = extractEmailBodyFragment(starRatingHtml);
expect(starRatingFragment).toContain("lucide-star");
expect(starRatingFragment).not.toContain("⭐");
});
});
@@ -0,0 +1,302 @@
import { TFunction } from "i18next";
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
import {
buildBlock,
buildCTAElement,
buildConsentElement,
buildMultipleChoiceElement,
buildNPSElement,
buildOpenTextElement,
buildRatingElement,
} from "@/app/lib/survey-block-builder";
import { createI18nString } from "@/lib/i18n/utils";
const fixtureT = ((key: string) => key) as TFunction;
export const EMBED_SURVEY_PREVIEW_SURVEY_ID = "embed-survey-preview-survey";
export const EMBED_SURVEY_PREVIEW_BLOCK_ID = "embed-survey-preview-block";
export const EMBED_SURVEY_PREVIEW_QUESTION_ID = "embed-survey-preview-question";
export const EMBED_SURVEY_PREVIEW_CHOICE_IDS = {
apples: "embed-survey-preview-choice-apples",
bananas: "embed-survey-preview-choice-bananas",
pineapples: "embed-survey-preview-choice-pineapples",
} as const;
export const EMBED_SURVEY_PREVIEW_HEADLINE = "Which fruits do you like";
export const EMBED_SURVEY_PREVIEW_CHOICES = ["Apples", "Bananas", "Pineapples"] as const;
export const EMBED_SURVEY_PREVIEW_SURVEY_URL = "https://app.formbricks.com/s/embed-survey-preview";
export const EMBED_SURVEY_PREVIEW_LOCALE = "en-US";
export const EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER = "Share your thoughts";
export const EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS = {
addressLine1: "Street address",
city: "City",
country: "Country",
} as const;
export const EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS = {
firstName: "First name",
email: "Work email",
company: "Company",
} as const;
export const EMBED_SURVEY_PREVIEW_MATRIX_ROWS = ["Product quality", "Ease of use"] as const;
export const EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS = ["Poor", "Great"] as const;
export const EMBED_SURVEY_PREVIEW_PICTURE_CHOICES = [
"https://app.formbricks.com/static/media/powered-by-formbricks.7aec4b1c.svg",
"https://app.formbricks.com/static/media/powered-by-formbricks.7aec4b1c.svg?variant=2",
] as const;
export const EMBED_SURVEY_PREVIEW_CTA_URL = "https://formbricks.com/docs";
export const EMBED_SURVEY_PREVIEW_STYLING: TSurveyStyling = {
brandColor: { light: "#22c55e" },
cardBackgroundColor: { light: "#4a865f" },
cardBorderColor: { light: "#4a865f" },
inputColor: { light: "#ffffff" },
inputBorderColor: { light: "#d6e4dc" },
questionColor: { light: "#1f2937" },
roundness: 8,
};
const createChoiceElement = (
type: TSurveyElementTypeEnum.MultipleChoiceMulti | TSurveyElementTypeEnum.MultipleChoiceSingle
) =>
buildMultipleChoiceElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
choices: [...EMBED_SURVEY_PREVIEW_CHOICES],
choiceIds: [
EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples,
EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas,
EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples,
],
required: true,
shuffleOption: "none",
});
const createPreviewSurvey = (element: TSurveyElement): TSurvey =>
({
id: EMBED_SURVEY_PREVIEW_SURVEY_ID,
name: "Embed Survey Preview",
type: "link",
status: "inProgress",
welcomeCard: {
enabled: false,
},
blocks: [
buildBlock({
id: EMBED_SURVEY_PREVIEW_BLOCK_ID,
name: "Block 1",
elements: [element],
buttonLabel: "Next",
backButtonLabel: "Back",
t: fixtureT,
}),
],
endings: [],
hiddenFields: {
enabled: false,
},
variables: [],
styling: EMBED_SURVEY_PREVIEW_STYLING,
surveyClosedMessage: {
enabled: false,
},
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
isCaptureIpEnabled: false,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
}) as unknown as TSurvey;
const createPreviewElementByType = (type: TSurveyElementTypeEnum): TSurveyElement => {
switch (type) {
case TSurveyElementTypeEnum.MultipleChoiceMulti:
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return createChoiceElement(type);
case TSurveyElementTypeEnum.OpenText:
return buildOpenTextElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
placeholder: EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER,
inputType: "text",
required: true,
longAnswer: true,
});
case TSurveyElementTypeEnum.Consent:
return buildConsentElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
subheader: "We need your permission",
label: "I agree to be contacted",
required: false,
});
case TSurveyElementTypeEnum.NPS:
return buildNPSElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
lowerLabel: "Not likely",
upperLabel: "Very likely",
required: true,
isColorCodingEnabled: true,
});
case TSurveyElementTypeEnum.CTA:
return buildCTAElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
subheader: "Read more about Formbricks",
buttonExternal: true,
ctaButtonLabel: "Open the docs",
buttonUrl: EMBED_SURVEY_PREVIEW_CTA_URL,
});
case TSurveyElementTypeEnum.Rating:
return buildRatingElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
scale: "number",
range: 5,
lowerLabel: "Poor",
upperLabel: "Great",
required: true,
isColorCodingEnabled: true,
});
case TSurveyElementTypeEnum.PictureSelection:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
allowMulti: false,
choices: EMBED_SURVEY_PREVIEW_PICTURE_CHOICES.map((imageUrl, index) => ({
id: `embed-survey-preview-picture-${index + 1}`,
imageUrl,
})),
} as TSurveyElement;
case TSurveyElementTypeEnum.Cal:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
subheader: createI18nString("Book a time with us", []),
required: false,
calUserName: "demo-user",
} as TSurveyElement;
case TSurveyElementTypeEnum.Date:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
format: "M-d-y",
} as TSurveyElement;
case TSurveyElementTypeEnum.Matrix:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
shuffleOption: "none",
rows: EMBED_SURVEY_PREVIEW_MATRIX_ROWS.map((label, index) => ({
id: `embed-survey-preview-row-${index + 1}`,
label: createI18nString(label, []),
})),
columns: EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS.map((label, index) => ({
id: `embed-survey-preview-column-${index + 1}`,
label: createI18nString(label, []),
})),
} as TSurveyElement;
case TSurveyElementTypeEnum.Address:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
addressLine1: {
show: true,
required: true,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1, []),
},
addressLine2: {
show: false,
required: false,
placeholder: createI18nString("Address line 2", []),
},
city: {
show: true,
required: true,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.city, []),
},
state: {
show: false,
required: false,
placeholder: createI18nString("State", []),
},
zip: {
show: false,
required: false,
placeholder: createI18nString("ZIP code", []),
},
country: {
show: true,
required: true,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.country, []),
},
} as TSurveyElement;
case TSurveyElementTypeEnum.Ranking:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
shuffleOption: "none",
choices: [...EMBED_SURVEY_PREVIEW_CHOICES].map((choice, index) => ({
id: `embed-survey-preview-ranking-${index + 1}`,
label: createI18nString(choice, []),
})),
} as TSurveyElement;
case TSurveyElementTypeEnum.ContactInfo:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
firstName: {
show: true,
required: true,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName, []),
},
lastName: {
show: false,
required: false,
placeholder: createI18nString("Last name", []),
},
email: {
show: true,
required: true,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.email, []),
},
phone: {
show: false,
required: false,
placeholder: createI18nString("Phone", []),
},
company: {
show: true,
required: false,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.company, []),
},
} as TSurveyElement;
case TSurveyElementTypeEnum.FileUpload:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
allowMultipleFiles: false,
} as TSurveyElement;
}
};
export const createEmbedSurveyPreviewEmailSurvey = (
type: TSurveyElementTypeEnum = TSurveyElementTypeEnum.MultipleChoiceMulti
): TSurvey => createPreviewSurvey(createPreviewElementByType(type));
@@ -0,0 +1,500 @@
import type { CSSProperties } from "react";
import type { TSurveyStyling } from "@formbricks/types/surveys/types";
import { COLOR_DEFAULTS, STYLE_DEFAULTS } from "@/lib/styling/constants";
import { isLight, mixColor } from "@/lib/utils/colors";
export interface PreviewEmailStyleTokens {
accentBackgroundColor: string;
brandColor: string;
buttonBackgroundColor: string;
buttonBorderRadius: string;
buttonFontSize: string;
buttonFontWeight: string;
buttonPaddingX: string;
buttonPaddingY: string;
buttonTextColor: string;
cardBackgroundColor: string;
cardBorderColor: string;
elementDescriptionColor: string;
elementDescriptionFontSize: string;
elementDescriptionFontWeight: string;
elementHeadlineColor: string;
elementHeadlineFontSize: string;
elementHeadlineFontWeight: string;
elementUpperLabelColor: string;
elementUpperLabelFontSize: string;
elementUpperLabelFontWeight: string;
fontFamily: string;
inputBackgroundColor: string;
inputBorderRadius: string;
inputFontSize: string;
inputHeight: string;
inputPaddingX: string;
inputPaddingY: string;
inputPlaceholderEmailColor: string;
inputShadow: string;
inputTextColor: string;
inputColor: string;
inputBorderColor: string;
optionBackgroundColor: string;
optionBorderColor: string;
optionBorderRadius: string;
optionFontSize: string;
optionLabelColor: string;
optionPaddingX: string;
optionPaddingY: string;
questionColor: string;
roundness: number;
signatureColor: string;
}
export type PreviewMarkerVariant = "checkbox" | "radio" | "ranking";
type PreviewStyleValue = string | number | null | undefined;
export const PREVIEW_LINK_TARGET = "_blank";
const SURVEY_EMAIL_FONT_FAMILY = "Inter, Helvetica, Arial, sans-serif";
const FORCE_LIGHT_COLOR_SCHEME = "only light";
export const CHOICE_LINK_CLASSNAME =
"bg-option-bg border-option-border text-option-label block rounded-option border border-solid px-option-x py-option-y font-survey text-option font-normal leading-5 no-underline";
export const SECONDARY_BUTTON_CLASSNAME =
"border-input-border inline-block rounded-button border border-solid px-button-x py-button-y font-survey text-button font-button leading-5 no-underline";
export const SCALE_BUTTON_CLASSNAME =
"border-input-border block w-full border border-solid p-0 text-center font-survey text-sm font-medium no-underline box-border";
const CHOICE_MARKER_CLASSNAME = "inline-block h-4 w-4 box-border align-middle leading-4";
const EMAIL_PREVIEW_ACCENT_COLORS = {
"bg-emerald-100": "#d1fae5",
"bg-orange-100": "#ffedd5",
"bg-rose-100": "#ffe4e6",
"emerald-100": "#d1fae5",
"orange-100": "#ffedd5",
"rose-100": "#ffe4e6",
} as const;
const RICH_TEXT_PARAGRAPH_TAG_REGEX = /<p\b([^>]*)>/gi;
const RICH_TEXT_STYLE_ATTRIBUTE_REGEX = /\sstyle=(["'])(.*?)\1/i;
const RICH_TEXT_STYLE_ATTRIBUTE_REPLACE_REGEX = /\sstyle=(["'])(.*?)\1/gi;
export const importantStyle = (value: string): string => `${value} !important`;
export const normalizeRichTextSpacing = (html: string): string =>
html.replaceAll(RICH_TEXT_PARAGRAPH_TAG_REGEX, (_tag, attributes: string = "") => {
if (RICH_TEXT_STYLE_ATTRIBUTE_REGEX.test(attributes)) {
return `<p${attributes.replaceAll(
RICH_TEXT_STYLE_ATTRIBUTE_REPLACE_REGEX,
(_styleAttribute, quote: string, styleValue: string) => {
const trimmedStyle = styleValue.trim();
const styleWithMargin = trimmedStyle ? `${trimmedStyle};margin:0` : "margin:0";
return ` style=${quote}${styleWithMargin}${quote}`;
}
)}>`;
}
return `<p${attributes} style="margin:0">`;
});
const getPreviewDimension = (value: PreviewStyleValue, fallback: PreviewStyleValue): string => {
const resolvedValue = value ?? fallback;
if (resolvedValue === null || resolvedValue === undefined || resolvedValue === "") {
return "";
}
if (typeof resolvedValue === "number") {
return `${resolvedValue}px`;
}
if (typeof resolvedValue === "string" && !Number.isNaN(Number(resolvedValue))) {
return `${resolvedValue}px`;
}
return typeof resolvedValue === "string" ? resolvedValue : "";
};
const getPreviewFontWeight = (value: PreviewStyleValue, fallback: PreviewStyleValue): string => {
const resolvedValue = value ?? fallback;
if (resolvedValue === null || resolvedValue === undefined || resolvedValue === "") {
return "";
}
return typeof resolvedValue === "string" || typeof resolvedValue === "number"
? resolvedValue.toString()
: "";
};
const getPreviewOpacity = (value: PreviewStyleValue, fallback: PreviewStyleValue): number => {
const resolvedValue = value ?? fallback;
const parsedValue = Number(resolvedValue);
if (!Number.isFinite(parsedValue)) return 1;
return Math.min(Math.max(parsedValue, 0), 1);
};
export const getForcedBackgroundStyle = (color: string): CSSProperties => ({
background: importantStyle(color),
backgroundColor: importantStyle(color),
colorScheme: FORCE_LIGHT_COLOR_SCHEME,
});
export const getForcedColorStyle = (color: string): CSSProperties => ({
color: importantStyle(color),
colorScheme: FORCE_LIGHT_COLOR_SCHEME,
});
const getPreviewRoundness = (roundness: TSurveyStyling["roundness"]): number => {
if (typeof roundness === "number") {
return Number.isFinite(roundness) ? roundness : 8;
}
if (typeof roundness === "string") {
const parsedRoundness = Number.parseFloat(roundness);
return Number.isFinite(parsedRoundness) ? parsedRoundness : 8;
}
return 8;
};
export const getPreviewEmailStyleTokens = (styling: TSurveyStyling): PreviewEmailStyleTokens => {
const questionColor =
styling.questionColor?.light ?? STYLE_DEFAULTS.questionColor?.light ?? COLOR_DEFAULTS.questionColor;
const brandColor =
styling.brandColor?.light ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
const inputTextColor = styling.inputTextColor?.light ?? questionColor;
const inputBackgroundColor =
styling.inputBgColor?.light ??
styling.inputColor?.light ??
STYLE_DEFAULTS.inputColor?.light ??
COLOR_DEFAULTS.inputColor;
const inputPlaceholderColor = mixColor(inputTextColor, "#ffffff", 0.3);
const inputPlaceholderOpacity = getPreviewOpacity(
styling.inputPlaceholderOpacity,
STYLE_DEFAULTS.inputPlaceholderOpacity ?? 0.5
);
const optionBackgroundColor =
styling.optionBgColor?.light ??
styling.inputColor?.light ??
STYLE_DEFAULTS.optionBgColor?.light ??
COLOR_DEFAULTS.inputColor;
const buttonBackgroundColor = styling.buttonBgColor?.light ?? brandColor;
const previewRoundness = getPreviewRoundness(styling.roundness);
const signatureColor = isLight(questionColor)
? mixColor(questionColor, "#000000", 0.2)
: mixColor(questionColor, "#ffffff", 0.2);
return {
accentBackgroundColor: styling.accentBgColor?.light ?? mixColor(brandColor, "#ffffff", 0.8),
brandColor,
buttonBackgroundColor,
buttonBorderRadius: getPreviewDimension(
styling.buttonBorderRadius,
STYLE_DEFAULTS.buttonBorderRadius ?? previewRoundness
),
buttonFontSize: getPreviewDimension(styling.buttonFontSize, STYLE_DEFAULTS.buttonFontSize),
buttonFontWeight: getPreviewFontWeight(styling.buttonFontWeight, STYLE_DEFAULTS.buttonFontWeight),
buttonPaddingX: getPreviewDimension(styling.buttonPaddingX, STYLE_DEFAULTS.buttonPaddingX),
buttonPaddingY: getPreviewDimension(styling.buttonPaddingY, STYLE_DEFAULTS.buttonPaddingY),
buttonTextColor:
styling.buttonTextColor?.light ?? (isLight(buttonBackgroundColor) ? "#0f172a" : "#ffffff"),
cardBackgroundColor:
styling.cardBackgroundColor?.light ??
STYLE_DEFAULTS.cardBackgroundColor?.light ??
COLOR_DEFAULTS.cardBackgroundColor,
cardBorderColor:
styling.cardBorderColor?.light ??
STYLE_DEFAULTS.cardBorderColor?.light ??
COLOR_DEFAULTS.cardBorderColor,
elementDescriptionColor: styling.elementDescriptionColor?.light ?? questionColor,
elementDescriptionFontSize: getPreviewDimension(
styling.elementDescriptionFontSize,
STYLE_DEFAULTS.elementDescriptionFontSize
),
elementDescriptionFontWeight: getPreviewFontWeight(
styling.elementDescriptionFontWeight,
STYLE_DEFAULTS.elementDescriptionFontWeight
),
elementHeadlineColor: styling.elementHeadlineColor?.light ?? questionColor,
elementHeadlineFontSize: getPreviewDimension(
styling.elementHeadlineFontSize,
STYLE_DEFAULTS.elementHeadlineFontSize
),
elementHeadlineFontWeight: getPreviewFontWeight(
styling.elementHeadlineFontWeight,
STYLE_DEFAULTS.elementHeadlineFontWeight
),
elementUpperLabelColor: styling.elementUpperLabelColor?.light ?? questionColor,
elementUpperLabelFontSize: getPreviewDimension(
styling.elementUpperLabelFontSize,
STYLE_DEFAULTS.elementUpperLabelFontSize
),
elementUpperLabelFontWeight: getPreviewFontWeight(
styling.elementUpperLabelFontWeight,
STYLE_DEFAULTS.elementUpperLabelFontWeight
),
fontFamily: styling.fontFamily ?? SURVEY_EMAIL_FONT_FAMILY,
inputBackgroundColor,
inputBorderColor:
styling.inputBorderColor?.light ??
STYLE_DEFAULTS.inputBorderColor?.light ??
COLOR_DEFAULTS.inputBorderColor,
inputBorderRadius: getPreviewDimension(
styling.inputBorderRadius,
STYLE_DEFAULTS.inputBorderRadius ?? previewRoundness
),
inputColor: styling.inputColor?.light ?? STYLE_DEFAULTS.inputColor?.light ?? COLOR_DEFAULTS.inputColor,
inputFontSize: getPreviewDimension(styling.inputFontSize, STYLE_DEFAULTS.inputFontSize),
inputHeight: getPreviewDimension(styling.inputHeight, STYLE_DEFAULTS.inputHeight),
inputPaddingX: getPreviewDimension(styling.inputPaddingX, STYLE_DEFAULTS.inputPaddingX),
inputPaddingY: getPreviewDimension(styling.inputPaddingY, STYLE_DEFAULTS.inputPaddingY),
inputPlaceholderEmailColor: mixColor(
inputPlaceholderColor,
inputBackgroundColor,
1 - inputPlaceholderOpacity
),
inputShadow: styling.inputShadow ?? STYLE_DEFAULTS.inputShadow ?? "",
inputTextColor,
optionBackgroundColor,
optionBorderColor:
styling.optionBorderColor?.light ??
styling.inputBorderColor?.light ??
STYLE_DEFAULTS.optionBorderColor?.light ??
COLOR_DEFAULTS.inputBorderColor,
optionBorderRadius: getPreviewDimension(
styling.optionBorderRadius,
STYLE_DEFAULTS.optionBorderRadius ?? previewRoundness
),
optionFontSize: getPreviewDimension(styling.optionFontSize, STYLE_DEFAULTS.optionFontSize),
optionLabelColor: styling.optionLabelColor?.light ?? questionColor,
optionPaddingX: getPreviewDimension(styling.optionPaddingX, STYLE_DEFAULTS.optionPaddingX),
optionPaddingY: getPreviewDimension(styling.optionPaddingY, STYLE_DEFAULTS.optionPaddingY),
questionColor,
roundness: previewRoundness,
signatureColor,
};
};
const buildSurveyEmailUrl = (surveyUrl: string, entries: Array<[string, string]>): string => {
const url = new URL(surveyUrl);
for (const [key, value] of entries) {
url.searchParams.set(key, value);
}
return url.toString();
};
export const getPreviewSurveyUrl = (surveyUrl: string): string =>
buildSurveyEmailUrl(surveyUrl, [["preview", "true"]]);
export const getPrefilledSurveyUrl = (surveyUrl: string, questionId: string, value: string): string =>
buildSurveyEmailUrl(surveyUrl, [
["preview", "true"],
[questionId, value],
["skipPrefilled", "true"],
]);
const getChoiceBlockStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedBackgroundStyle(styleTokens.optionBackgroundColor),
border: importantStyle(`1px solid ${styleTokens.optionBorderColor}`),
});
const getChoiceTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedColorStyle(styleTokens.optionLabelColor),
});
export const getChoiceCardStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getChoiceBlockStyle(styleTokens),
...getChoiceTextStyle(styleTokens),
textDecoration: "none",
});
const getFieldPlaceholderStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedBackgroundStyle(styleTokens.inputBackgroundColor),
...getForcedColorStyle(styleTokens.inputPlaceholderEmailColor),
border: importantStyle(`1px solid ${styleTokens.inputBorderColor}`),
});
export const getInputTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedColorStyle(styleTokens.inputTextColor),
});
export const getFieldLabelStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedColorStyle(styleTokens.questionColor),
fontFamily: styleTokens.fontFamily,
});
export const getSecondaryButtonStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
border: importantStyle(`1px solid ${styleTokens.inputBorderColor}`),
...getForcedColorStyle(styleTokens.questionColor),
});
export const getPrimaryButtonStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getSecondaryButtonStyle(styleTokens),
...getForcedBackgroundStyle(styleTokens.buttonBackgroundColor),
border: importantStyle(`1px solid ${styleTokens.buttonBackgroundColor}`),
...getForcedColorStyle(styleTokens.buttonTextColor),
});
export const getPreviewAccentColor = (token?: string): string | undefined =>
token ? EMAIL_PREVIEW_ACCENT_COLORS[token as keyof typeof EMAIL_PREVIEW_ACCENT_COLORS] : undefined;
const getConnectedScaleBorderRadius = (
styleTokens: PreviewEmailStyleTokens,
optionIndex: number,
optionCount: number
): string => {
if (optionCount <= 1) return styleTokens.inputBorderRadius;
if (optionIndex === 0) return `${styleTokens.inputBorderRadius} 0 0 ${styleTokens.inputBorderRadius}`;
if (optionIndex === optionCount - 1)
return `0 ${styleTokens.inputBorderRadius} ${styleTokens.inputBorderRadius} 0`;
return "0";
};
const getScaleLineHeight = (height: string, hasColorStrip: boolean): string => {
if (!hasColorStrip || !height.endsWith("px")) return height;
const numericHeight = Number.parseFloat(height);
if (!Number.isFinite(numericHeight)) return height;
return `${Math.max(numericHeight - 6, 0).toString()}px`;
};
export const getScaleOptionStyle = ({
styleTokens,
borderTopColor,
height,
isConnected = false,
isCompact = false,
isTransparent = false,
optionCount = 0,
optionIndex = 0,
}: {
styleTokens: PreviewEmailStyleTokens;
borderTopColor?: string;
height?: string;
isConnected?: boolean;
isCompact?: boolean;
isTransparent?: boolean;
optionCount?: number;
optionIndex?: number;
}): CSSProperties => {
const optionHeight = height ?? (isCompact ? "40px" : "46px");
const optionLineHeight = getScaleLineHeight(optionHeight, Boolean(borderTopColor));
const shouldConnect = isConnected && optionCount > 0;
return {
...getForcedBackgroundStyle(isTransparent ? "transparent" : styleTokens.inputBackgroundColor),
border: importantStyle(
isTransparent ? "1px solid transparent" : `1px solid ${styleTokens.inputBorderColor}`
),
borderRadius: shouldConnect
? getConnectedScaleBorderRadius(styleTokens, optionIndex, optionCount)
: styleTokens.inputBorderRadius,
...(shouldConnect && optionIndex > 0 ? { borderLeft: importantStyle("0") } : {}),
...getForcedColorStyle(styleTokens.inputTextColor),
height: optionHeight,
lineHeight: optionLineHeight,
...(borderTopColor ? { borderTop: importantStyle(`6px solid ${borderTopColor}`) } : {}),
};
};
export const getLightModeTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedColorStyle(styleTokens.elementHeadlineColor),
fontFamily: styleTokens.fontFamily,
fontSize: styleTokens.elementHeadlineFontSize,
fontWeight: styleTokens.elementHeadlineFontWeight,
});
export const getHelperLabelTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedColorStyle(styleTokens.elementUpperLabelColor),
fontFamily: styleTokens.fontFamily,
fontSize: styleTokens.elementUpperLabelFontSize,
fontWeight: styleTokens.elementUpperLabelFontWeight,
});
export const getCenteredPlaceholderStyle = (
styleTokens: PreviewEmailStyleTokens,
overrides?: CSSProperties
): CSSProperties => {
const baseStyle: CSSProperties = {
...getFieldPlaceholderStyle(styleTokens),
};
return overrides ? { ...baseStyle, ...overrides } : baseStyle;
};
export const getCenteredPlaceholderTextStyle = (
styleTokens: PreviewEmailStyleTokens,
overrides?: CSSProperties
): CSSProperties => {
const baseStyle: CSSProperties = {
...getForcedColorStyle(styleTokens.inputPlaceholderEmailColor),
};
return overrides ? { ...baseStyle, ...overrides } : baseStyle;
};
export const getInputShellStyle = (
styleTokens: PreviewEmailStyleTokens,
overrides?: CSSProperties
): CSSProperties => ({
...getCenteredPlaceholderStyle(styleTokens),
boxSizing: "border-box",
borderRadius: styleTokens.inputBorderRadius,
display: "block",
fontFamily: styleTokens.fontFamily,
fontSize: styleTokens.inputFontSize,
lineHeight: "20px",
overflow: "hidden",
padding: `${styleTokens.inputPaddingY} ${styleTokens.inputPaddingX}`,
width: "100%",
...overrides,
});
export const getInputShellLinkStyle = (
styleTokens: PreviewEmailStyleTokens,
overrides?: CSSProperties
): CSSProperties => ({
...getInputTextStyle(styleTokens),
display: "block",
fontFamily: styleTokens.fontFamily,
fontSize: styleTokens.inputFontSize,
fontWeight: 400,
lineHeight: "20px",
textDecoration: importantStyle("none"),
width: "100%",
...overrides,
});
export const getScaleColumnStyle = (optionCount: number): CSSProperties => ({
width: `${(100 / optionCount).toFixed(4)}%`,
});
export const getChoiceMarkerStyle = (
marker: PreviewMarkerVariant,
styleTokens: PreviewEmailStyleTokens
): CSSProperties => ({
...(marker === "ranking"
? {
background: importantStyle("transparent"),
backgroundColor: importantStyle("transparent"),
colorScheme: FORCE_LIGHT_COLOR_SCHEME,
}
: getForcedBackgroundStyle("#ffffff")),
border: importantStyle(
`${marker === "ranking" ? "1px dashed" : "1px solid"} ${
marker === "ranking" ? styleTokens.brandColor : styleTokens.inputBorderColor
}`
),
});
export const getChoiceMarkerClassName = (marker: PreviewMarkerVariant, withLabelGap = true): string =>
`${withLabelGap ? "mr-3 " : ""}${CHOICE_MARKER_CLASSNAME} ${
marker === "checkbox" ? "rounded" : "rounded-full"
}`;
@@ -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);
@@ -1,9 +1,9 @@
import { ActionClass } from "@prisma/client";
import { ActionClass, Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { createActionClass } from "./action-class";
vi.mock("@formbricks/database", () => ({
@@ -99,14 +99,19 @@ describe("createActionClass", () => {
expect(result).toEqual(createdAction);
});
test("should throw DatabaseError for unique constraint violation", async () => {
const prismaError = {
code: PrismaErrorType.UniqueConstraintViolation,
meta: { target: ["name"] },
};
test("should throw UniqueConstraintError for unique constraint violation", async () => {
const prismaError = Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target: ["name"] } }
);
vi.mocked(prisma.actionClass.create).mockRejectedValue(prismaError);
await expect(createActionClass(mockEnvironmentId, mockCodeActionInput)).rejects.toThrow(DatabaseError);
await expect(createActionClass(mockEnvironmentId, mockCodeActionInput)).rejects.toThrow(
UniqueConstraintError
);
});
test("should throw DatabaseError for other database errors", async () => {
@@ -2,7 +2,7 @@ import { ActionClass, Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
export const createActionClass = async (
environmentId: string,
@@ -32,7 +32,7 @@ export const createActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError(
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
@@ -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}
@@ -33,6 +33,14 @@ export const SurveyInactive = async ({
"link expired": t("c.link_expired_description"),
};
const headings = {
paused: t("s.paused_heading"),
completed: t("s.completed_heading"),
"link invalid": t("s.this_looks_fishy"),
"response submitted": t("s.survey_already_answered_heading"),
"link expired": t("c.link_expired_heading"),
};
const showCTA =
status !== "link invalid" &&
status !== "link expired" &&
@@ -47,7 +55,7 @@ export const SurveyInactive = async ({
<h1 className="text-4xl font-bold text-slate-800">
{(status === "completed" || status === "link expired") && surveyClosedMessage
? surveyClosedMessage.heading
: `${t("common.survey")} ${status}.`}
: headings[status]}
</h1>
<p className="text-lg leading-10 text-slate-500">
{status === "completed" && surveyClosedMessage
@@ -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}
@@ -0,0 +1,195 @@
import { expect } from "@playwright/test";
import { type Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
EMBED_SURVEY_PREVIEW_CHOICE_IDS,
EMBED_SURVEY_PREVIEW_HEADLINE,
EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER,
EMBED_SURVEY_PREVIEW_QUESTION_ID,
createEmbedSurveyPreviewEmailSurvey,
} from "@/modules/email/fixtures/embed-survey-preview-email-fixture";
import { test } from "./lib/fixtures";
/**
* Manual QA for changes touching the survey email preview:
* 1. Check the React Email preview at http://localhost:3456/preview/survey/embed-survey-preview-email.
* 2. Check the in-app summary email preview for the seeded kitchen sink survey.
* 3. Send one preview email and compare it in new Outlook against the in-app preview.
*/
const getUserContext = async (email: string) => {
const user = await prisma.user.findUnique({
where: {
email,
},
select: {
id: true,
memberships: {
take: 1,
select: {
organization: {
select: {
projects: {
take: 1,
select: {
environments: {
where: {
type: "development",
},
take: 1,
select: {
id: true,
},
},
},
},
},
},
},
},
},
});
const environmentId = user?.memberships[0]?.organization.projects[0]?.environments[0]?.id;
if (!user?.id || !environmentId) {
throw new Error(`Unable to resolve user context for ${email}`);
}
return {
environmentId,
userId: user.id,
};
};
const createSurveySeed = async (
environmentId: string,
userId: string,
name: string,
type: TSurveyElementTypeEnum = TSurveyElementTypeEnum.MultipleChoiceMulti
) => {
const surveyFixture = createEmbedSurveyPreviewEmailSurvey(type);
const blocks = surveyFixture.blocks as unknown as Prisma.InputJsonValue[];
const endings = surveyFixture.endings as unknown as Prisma.InputJsonValue[];
const variables = surveyFixture.variables as unknown as Prisma.InputJsonValue[];
return prisma.survey.create({
data: {
environmentId,
createdBy: userId,
name,
status: "inProgress",
type: "link",
welcomeCard: surveyFixture.welcomeCard,
blocks,
endings,
hiddenFields: surveyFixture.hiddenFields,
styling: surveyFixture.styling,
surveyClosedMessage: surveyFixture.surveyClosedMessage,
isBackButtonHidden: surveyFixture.isBackButtonHidden,
isAutoProgressingEnabled: surveyFixture.isAutoProgressingEnabled,
isCaptureIpEnabled: surveyFixture.isCaptureIpEnabled,
isVerifyEmailEnabled: surveyFixture.isVerifyEmailEnabled,
isSingleResponsePerEmailEnabled: surveyFixture.isSingleResponsePerEmailEnabled,
variables,
},
});
};
test.describe("Survey Email Preview", () => {
test("renders the real summary email preview with styled per-option links", async ({ page, users }) => {
const timestamp = Date.now();
const email = `survey-email-preview-${timestamp}@example.com`;
const user = await users.create({
email,
name: `survey-email-preview-${timestamp}`,
projectName: "Email Preview Workspace",
});
await user.login();
await expect(page).toHaveURL(/\/environments\/[^/]+/);
const { environmentId, userId } = await getUserContext(email);
const survey = await createSurveySeed(environmentId, userId, `Email Preview Survey ${timestamp}`);
await page.goto(`/environments/${environmentId}/surveys/${survey.id}/summary`);
await page.getByRole("button", { name: "Share survey" }).click();
await page.getByRole("button", { name: "Email embed" }).click();
const previewShell = page.getByTestId("survey-email-preview-shell");
const previewFrameElement = page.getByTestId("survey-email-preview-frame");
const previewFrame = page.frameLocator('[data-testid="survey-email-preview-frame"]');
await expect(previewShell).toBeVisible();
await expect(previewFrameElement).toBeVisible();
await expect(previewFrame.getByText(EMBED_SURVEY_PREVIEW_HEADLINE)).toBeVisible();
await expect(previewFrame.getByText("Apples", { exact: true })).toBeVisible();
await expect(previewFrame.getByText("Bananas", { exact: true })).toBeVisible();
await expect(previewFrame.getByText("Pineapples", { exact: true })).toBeVisible();
const firstChoiceLink = previewFrame.locator(
`a[href*="skipPrefilled=true"][href*="${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples)}"]`
);
const firstChoiceMarker = firstChoiceLink.locator('span[style*="border"]');
await expect(previewFrame.locator(`a[href*="${EMBED_SURVEY_PREVIEW_QUESTION_ID}="]`)).toHaveCount(3);
await expect(firstChoiceLink).toHaveCount(1);
await expect(firstChoiceMarker).toHaveCount(1);
await expect(firstChoiceMarker).toHaveCSS("height", "16px");
await expect(firstChoiceMarker).toHaveCSS("border-top-left-radius", "4px");
await expect(firstChoiceLink).toContainText("Apples");
await expect(firstChoiceLink).toHaveCSS("background-color", "rgb(243, 244, 246)");
await expect(firstChoiceLink).toHaveCSS("border-top-left-radius", "8px");
await expect(firstChoiceLink).toHaveCSS("font-family", /Inter/);
await expect(firstChoiceLink).toHaveCSS("padding-top", "16px");
await expect(firstChoiceLink).toHaveAttribute(
"href",
new RegExp(`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples}`)
);
await expect(firstChoiceLink).toHaveAttribute("href", /preview=true/);
await expect(firstChoiceLink).toHaveAttribute("href", /skipPrefilled=true/);
await expect(firstChoiceLink).toHaveAttribute("target", "_blank");
const poweredByLink = previewFrame.getByRole("link", { name: "Powered by Formbricks" });
await expect(poweredByLink).toHaveAttribute("href", "https://formbricks.com?utm_source=email_branding");
});
test("keeps non-option email previews clickable in the summary modal", async ({ page, users }) => {
const timestamp = Date.now();
const email = `survey-email-open-text-${timestamp}@example.com`;
const user = await users.create({
email,
name: `survey-email-open-text-${timestamp}`,
projectName: "Email Preview Workspace",
});
await user.login();
await expect(page).toHaveURL(/\/environments\/[^/]+/);
const { environmentId, userId } = await getUserContext(email);
const survey = await createSurveySeed(
environmentId,
userId,
`Email Preview Open Text ${timestamp}`,
TSurveyElementTypeEnum.OpenText
);
await page.goto(`/environments/${environmentId}/surveys/${survey.id}/summary`);
await page.getByRole("button", { name: "Share survey" }).click();
await page.getByRole("button", { name: "Email embed" }).click();
const previewFrame = page.frameLocator('[data-testid="survey-email-preview-frame"]');
const openTextLink = previewFrame.getByRole("link", { name: EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER });
const openTextInputShell = openTextLink.locator("xpath=..");
await expect(previewFrame.getByText(EMBED_SURVEY_PREVIEW_HEADLINE)).toBeVisible();
await expect(openTextLink).toBeVisible();
await expect(openTextInputShell).toHaveCSS("background-color", "rgb(243, 244, 246)");
await expect(openTextInputShell).toHaveCSS("border-top-left-radius", "8px");
await expect(openTextInputShell).toHaveCSS("padding-top", "8px");
await expect(openTextInputShell).toHaveCSS("padding-left", "8px");
await expect(openTextLink).toHaveAttribute("href", /preview=true/);
await expect(openTextLink).not.toHaveAttribute("href", /skipPrefilled=true/);
await expect(openTextLink).toHaveAttribute("target", "_blank");
});
});
+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
@@ -277,7 +277,6 @@
"self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
"self-hosting/advanced/enterprise-features/team-access",
"self-hosting/advanced/enterprise-features/contact-management-segments",
"self-hosting/advanced/enterprise-features/multi-language-surveys",
"self-hosting/advanced/enterprise-features/oidc-sso",
"self-hosting/advanced/enterprise-features/saml-sso",
"self-hosting/advanced/enterprise-features/audit-logging"
@@ -5,8 +5,6 @@ description: Enable comprehensive audit logs for your Formbricks instance.
icon: file-shield
---
import Hint from "@theme/Hint";
Audit logs record **who** did **what**, **when**, **from where**, and **with what outcome** across your Formbricks instance.
@@ -20,7 +20,7 @@ export function EmbedSurveyPreviewEmail({
...legalProps
}: EmbedSurveyPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t} {...legalProps}>
<EmailTemplate forceLightMode logoUrl={logoUrl} t={t} {...legalProps}>
<Container>
<Heading>{t("emails.embed_survey_preview_email_heading")}</Heading>
<Text className="text-sm">{t("emails.embed_survey_preview_email_text")}</Text>
@@ -1,20 +1,33 @@
import { Container } from "@react-email/components";
import type { CSSProperties } from "react";
import { cn } from "../../src/lib/cn";
interface ElementHeaderProps {
readonly headline: string;
readonly subheader?: string;
readonly className?: string;
readonly style?: CSSProperties;
readonly subheaderStyle?: CSSProperties;
}
export function ElementHeader({ headline, subheader, className }: ElementHeaderProps): React.JSX.Element {
export function ElementHeader({
headline,
subheader,
className,
style,
subheaderStyle,
}: ElementHeaderProps): React.JSX.Element {
return (
<>
<Container className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
<Container
className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}
style={style}>
<div dangerouslySetInnerHTML={{ __html: headline }} />
</Container>
{subheader && (
<Container className="text-question-color m-0 mt-2 block p-0 text-sm font-normal leading-6">
<Container
className="text-question-color m-0 mt-2 block p-0 text-sm font-normal leading-6"
style={{ ...style, ...subheaderStyle }}>
<div dangerouslySetInnerHTML={{ __html: subheader }} />
</Container>
)}
@@ -1,18 +1,21 @@
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
import { Body, Container, Head, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
import { TEmailTemplateLegalProps } from "../types/email";
import { TFunction } from "../types/translations";
const fbLogoUrl = "https://app.formbricks.com/logo-transparent.png";
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
const FORCE_LIGHT_COLOR_SCHEME = "only light";
interface EmailTemplateProps extends TEmailTemplateLegalProps {
readonly children: React.ReactNode;
readonly forceLightMode?: boolean;
readonly logoUrl?: string;
readonly t: TFunction;
}
export function EmailTemplate({
children,
forceLightMode = false,
logoUrl,
t,
privacyUrl,
@@ -23,10 +26,25 @@ export function EmailTemplate({
return (
<Html>
{forceLightMode && (
<Head>
<meta name="color-scheme" content="only light" />
<meta name="supported-color-schemes" content="light" />
<style>
{`:root {
color-scheme: only light;
supported-color-schemes: light;
}`}
</style>
</Head>
)}
<Tailwind>
<Body
className="m-0 h-full w-full justify-center bg-slate-50 p-6 text-center text-sm text-slate-800"
style={{
...(forceLightMode
? { backgroundColor: "#f8fafc", color: "#1e293b", colorScheme: FORCE_LIGHT_COLOR_SCHEME }
: {}),
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
}}>
<Section>
+2 -1
View File
@@ -3,6 +3,7 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { embedSurveyPreviewEmailHtml } from "./fixtures/embed-survey-preview-email-html";
export const exampleData = {
verificationEmail: {
@@ -41,7 +42,7 @@ export const exampleData = {
},
embedSurveyPreviewEmail: {
html: '<div style="padding: 20px; background-color: #f3f4f6; border-radius: 8px;"><h3 style="margin-top: 0;">Example Survey Embed</h3><p>This is a preview of how your survey will look when embedded in an email.</p></div>',
html: embedSurveyPreviewEmailHtml,
environmentId: "clxyz123456789",
},
@@ -0,0 +1,142 @@
export const embedSurveyPreviewEmailHtml = `
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
bgcolor="#4a865f"
style="background-color:#4a865f !important;border-color:rgb(74,134,95);border-radius:8px;margin-right:0rem;margin-left:0rem;margin-bottom:0.5rem;margin-top:0.5rem;border-style:solid;border-width:1px;padding:2rem;color:#1f2937 !important;background:#4a865f !important;color-scheme:only light;border:1px solid #4a865f !important;font-family:Inter, Helvetica, Arial, sans-serif">
<tbody>
<tr>
<td>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="max-width:37.5em;color:#1f2937 !important;margin:0rem;display:block;font-size:16px;line-height:1.5rem;font-weight:600;margin-right:2rem;color-scheme:only light;font-family:Inter, Helvetica, Arial, sans-serif">
<tbody>
<tr style="width:100%">
<td><div>Which fruits do you like</div></td>
</tr>
</tbody>
</table>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="max-width:none;margin-right:0rem;margin-left:0rem">
<tbody>
<tr style="width:100%">
<td>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="margin-top:0.5rem;width:100%">
<tbody>
<tr>
<td>
<a
href="https://app.formbricks.com/s/embed-survey-preview?preview=true&amp;embed-survey-preview-question=embed-survey-preview-choice-apples&amp;skipPrefilled=true"
style="color:#1f2937 !important;text-decoration-line:none;background-color:#ffffff !important;border-color:rgb(214,228,220);display:block;border-radius:8px;border-style:solid;border-width:1px;padding-right:16px;padding-left:16px;padding-bottom:16px;padding-top:16px;font-family:Inter,Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.25rem;background:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important;text-decoration:none"
target="_blank"
><span
style="margin-right:0.75rem;display:inline-block;height:1rem;width:1rem;box-sizing:border-box;vertical-align:middle;line-height:1rem;border-radius:0.25rem;background:#ffffff !important;background-color:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important"
>\u00A0</span
><span style="vertical-align:middle">Apples</span></a
>
</td>
</tr>
</tbody>
</table>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="margin-top:0.5rem;width:100%">
<tbody>
<tr>
<td>
<a
href="https://app.formbricks.com/s/embed-survey-preview?preview=true&amp;embed-survey-preview-question=embed-survey-preview-choice-bananas&amp;skipPrefilled=true"
style="color:#1f2937 !important;text-decoration-line:none;background-color:#ffffff !important;border-color:rgb(214,228,220);display:block;border-radius:8px;border-style:solid;border-width:1px;padding-right:16px;padding-left:16px;padding-bottom:16px;padding-top:16px;font-family:Inter,Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.25rem;background:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important;text-decoration:none"
target="_blank"
><span
style="margin-right:0.75rem;display:inline-block;height:1rem;width:1rem;box-sizing:border-box;vertical-align:middle;line-height:1rem;border-radius:0.25rem;background:#ffffff !important;background-color:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important"
>\u00A0</span
><span style="vertical-align:middle">Bananas</span></a
>
</td>
</tr>
</tbody>
</table>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="margin-top:0.5rem;width:100%">
<tbody>
<tr>
<td>
<a
href="https://app.formbricks.com/s/embed-survey-preview?preview=true&amp;embed-survey-preview-question=embed-survey-preview-choice-pineapples&amp;skipPrefilled=true"
style="color:#1f2937 !important;text-decoration-line:none;background-color:#ffffff !important;border-color:rgb(214,228,220);display:block;border-radius:8px;border-style:solid;border-width:1px;padding-right:16px;padding-left:16px;padding-bottom:16px;padding-top:16px;font-family:Inter,Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.25rem;background:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important;text-decoration:none"
target="_blank"
><span
style="margin-right:0.75rem;display:inline-block;height:1rem;width:1rem;box-sizing:border-box;vertical-align:middle;line-height:1rem;border-radius:0.25rem;background:#ffffff !important;background-color:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important"
>\u00A0</span
><span style="vertical-align:middle"
>Pineapples</span
></a
>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="max-width:37.5em;margin-right:auto;margin-left:auto;margin-top:2rem;text-align:center">
<tbody>
<tr style="width:100%">
<td>
<a
href="https://formbricks.com?utm_source=email_branding"
style="color:#4c545f !important;text-decoration-line:none;font-size:0.75rem;line-height:1.3333333333333333;color-scheme:only light;font-family:Inter, Helvetica, Arial, sans-serif"
target="_blank"
>Powered by Formbricks</a
>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
`.trim();
+2
View File
@@ -5,6 +5,8 @@ type TranslationKey = string;
type TranslationValue = string;
const translations: Record<TranslationKey, TranslationValue> = {
"common.continue": "Continue",
"common.powered_by_formbricks": "Powered by Formbricks",
"emails.accept": "Accept",
"emails.click_or_drag_to_upload_files": "Click or drag to upload files.",
"emails.email_customization_preview_email_heading": "Hey {userName}",
+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;
};
+1
View File
@@ -160,6 +160,7 @@ export const EXPECTED_ERROR_NAMES = new Set([
"OperationNotAllowedError",
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
"UniqueConstraintError",
]);
/**
+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>;

Some files were not shown because too many files have changed in this diff Show More