Compare commits

...

9 Commits

Author SHA1 Message Date
Johannes a37ad0cd66 adds setting toggle to not change default behaviour for all existing in-product surveys 2026-04-13 20:18:46 +02:00
Johannes 175775c96e Merge branch 'main' of https://github.com/formbricks/formbricks into fix/survey-font-family-inherit
Made-with: Cursor
2026-04-13 18:47:32 +02:00
Anshuman Pandey 2556f5e15d fix: add missing PostHog events (#7722)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:57:12 +00:00
Johannes cc0eec3bf0 feat: add auto-progress mode for rating and NPS surveys (#7709)
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-13 11:22:50 +00:00
Johannes 4b009a8eb4 revert: enhance welcome card to support video uploads (#7712)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-13 08:17:05 +00:00
Johannes 2aaddf7306 fix: prevent TTC overcount for multi-question blocks (#7713)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-13 07:56:40 +00:00
Dhruwang Jariwala fb5d6145d0 fix: only show beforeunload warning when offline support is active (#7715) 2026-04-13 07:19:57 +00:00
Dhruwang Jariwala 59310bac93 fix: validate "Other" option text on required questions and remove duplicate response entry (#7716) 2026-04-13 07:05:08 +00:00
Dhruwang 05be68b714 feat: inherit host page font-family in surveys package
Allow embedded surveys to automatically use the host page's font instead
of hardcoding Inter. This enables custom font support without any
additional configuration.
2026-03-25 11:15:02 +05:30
108 changed files with 1275 additions and 189 deletions
@@ -75,7 +75,10 @@ export const ProjectSettings = ({
organizationId,
data: {
...data,
styling: fullStyling,
styling: {
...fullStyling,
isPageFontInheritedByDefault: true,
},
config: { channel, industry },
teamIds: data.teamIds,
},
@@ -112,7 +115,11 @@ export const ProjectSettings = ({
const form = useForm<TProjectUpdateInput>({
defaultValues: {
name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: true,
brandColor: { light: defaultBrandColor },
},
teamIds: [],
},
resolver: zodResolver(ZProjectUpdateInput),
@@ -107,7 +107,9 @@ export const SummaryMetadata = ({
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
defaultValue: "Average time to complete the survey.",
})}
isLoading={isLoading}
/>
@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
});
test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30);
@@ -178,19 +178,74 @@ describe("getSurveySummaryMeta", () => {
});
test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0);
});
test("handles zero responses", () => {
const meta = getSurveySummaryMeta([], 10, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0);
expect(meta.dropOffPercentage).toBe(0);
expect(meta.ttcAverage).toBe(0);
});
test("uses block-level TTC to avoid multiplying by number of elements", () => {
const surveyWithOneBlockThreeElements: TSurvey = {
...mockBaseSurvey,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q3",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q3" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
] as TSurveyElement[],
},
],
questions: [],
};
const responses = [
{
id: "r1",
data: { q1: "a", q2: "b", q3: "c" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 5000, q2: 5000, q3: 4800, _total: 14800 },
finished: true,
},
] as any;
const meta = getSurveySummaryMeta(surveyWithOneBlockThreeElements, responses, 1, mockQuotas);
expect(meta.ttcAverage).toBe(5000);
});
});
describe("getSurveySummaryDropOff", () => {
@@ -274,7 +329,7 @@ describe("getSurveySummaryDropOff", () => {
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
});
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
@@ -51,7 +51,32 @@ interface TSurveySummaryResponse {
finished: boolean;
}
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
block.elements.forEach((element) => {
acc[element.id] = block.id;
});
return acc;
}, {});
};
const getBlockTimesForResponse = (
response: TSurveySummaryResponse,
survey: TSurvey
): Record<string, number> => {
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
const elementTtc = response.ttc?.[element.id] ?? 0;
return Math.max(maxTtc, elementTtc);
}, 0);
acc[block.id] = maxElementTtc;
return acc;
}, {});
};
export const getSurveySummaryMeta = (
survey: TSurvey,
responses: TSurveySummaryResponse[],
displayCount: number,
quotas: TSurveySummary["quotas"]
@@ -60,9 +85,15 @@ export const getSurveySummaryMeta = (
let ttcResponseCount = 0;
const ttcSum = responses.reduce((acc, response) => {
if (response.ttc?._total) {
const blockTimes = getBlockTimesForResponse(response, survey);
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
// Fallback to _total for malformed surveys with no block mappings.
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
if (responseTtcTotal > 0) {
ttcResponseCount++;
return acc + response.ttc._total;
return acc + responseTtcTotal;
}
return acc;
}, 0);
@@ -117,12 +148,16 @@ export const getSurveySummaryDropOff = (
let dropOffArr = new Array(elements.length).fill(0) as number[];
let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
responses.forEach((response) => {
// Calculate total time-to-completion per element
const blockTimes = getBlockTimesForResponse(response, survey);
Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
const blockId = elementIdToBlockId[elementId];
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
if (blockTtc > 0) {
totalTtc[elementId] += blockTtc;
responseCounts[elementId]++;
}
});
@@ -974,10 +1009,8 @@ export const getSurveySummary = reactCache(
]);
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
const [meta, elementSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount, quotas),
getElementSummary(survey, elements, responses, dropOff),
]);
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
return {
meta,
@@ -1061,7 +1094,9 @@ export const getResponsesForSummary = reactCache(
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
id: responsePrisma.id,
data: (responsePrisma.data ?? {}) as TResponseData,
updatedAt: responsePrisma.updatedAt,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id as string,
@@ -1070,6 +1105,10 @@ export const getResponsesForSummary = reactCache(
)?.value as string,
}
: null,
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
language: responsePrisma.language,
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
finished: responsePrisma.finished,
};
})
);
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { captureSurveyResponsePostHogEvent } from "./posthog";
vi.mock("@/lib/posthog", () => ({
capturePostHogEvent: vi.fn(),
}));
describe("captureSurveyResponsePostHogEvent", () => {
afterEach(() => {
vi.clearAllMocks();
});
const makeParams = (responseCount: number) => ({
organizationId: "org-1",
surveyId: "survey-1",
surveyType: "link",
environmentId: "env-1",
responseCount,
});
test("fires on 1st response with milestone 'first'", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
captureSurveyResponsePostHogEvent(makeParams(1));
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
});
});
test("fires on every 100th response", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [100, 200, 300, 500, 1000, 5000]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
});
test("does NOT fire for 2nd through 99th responses", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 10, 50, 99]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("does NOT fire for non-100th counts above 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [101, 150, 250, 499, 501]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
test("sets milestone to count string for non-first milestones", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
captureSurveyResponsePostHogEvent(makeParams(200));
expect(capturePostHogEvent).toHaveBeenCalledWith(
"org-1",
"survey_response_received",
expect.objectContaining({
is_first_response: false,
milestone: "200",
})
);
});
});
@@ -0,0 +1,33 @@
import { capturePostHogEvent } from "@/lib/posthog";
interface SurveyResponsePostHogEventParams {
organizationId: string;
surveyId: string;
surveyType: string;
environmentId: string;
responseCount: number;
}
/**
* Captures a PostHog event for survey responses at milestones:
* 1st response, then every 100th (100, 200, 300, ...).
*/
export const captureSurveyResponsePostHogEvent = ({
organizationId,
surveyId,
surveyType,
environmentId,
responseCount,
}: SurveyResponsePostHogEventParams): void => {
if (responseCount !== 1 && responseCount % 100 !== 0) return;
capturePostHogEvent(organizationId, "survey_response_received", {
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
};
+9 -20
View File
@@ -1,7 +1,6 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { v7 as uuidv7 } from "uuid";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -9,12 +8,10 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { cache } from "@/lib/cache";
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
@@ -27,6 +24,7 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
export const POST = async (request: Request) => {
const requestHeaders = await headers();
@@ -302,25 +300,16 @@ export const POST = async (request: Request) => {
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
});
// Sampled PostHog tracking: first response + every 100th
if (POSTHOG_KEY) {
const responseCount = await cache.withCache(
() => getResponseCountBySurveyId(surveyId),
createCacheKey.response.countBySurveyId(surveyId),
60 * 1000
);
const responseCount = await getResponseCountBySurveyId(surveyId);
if (responseCount === 1 || responseCount % 100 === 0) {
capturePostHogEvent(organization.id, "survey_response_received", {
survey_id: surveyId,
survey_type: survey.type,
organization_id: organization.id,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
}
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
surveyId,
surveyType: survey.type,
environmentId,
responseCount,
});
}
// Send telemetry events
@@ -1,5 +1,6 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { logger } from "@formbricks/logger";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
@@ -10,6 +11,8 @@ import {
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -82,6 +85,16 @@ export const GET = async (req: Request) => {
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
@@ -70,6 +70,7 @@ const mockEnvironmentData = {
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
triggers: [],
displayPercentage: null,
delay: 0,
@@ -122,6 +123,13 @@ describe("getEnvironmentStateData", () => {
surveys: expect.any(Object),
}),
});
const prismaCall = vi.mocked(prisma.environment.findUnique).mock.calls[0][0];
expect(prismaCall.select.surveys.select).toEqual(
expect.objectContaining({
isAutoProgressingEnabled: true,
})
);
});
test("should throw ResourceNotFoundError when environment is not found", async () => {
@@ -121,6 +121,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
displayOption: true,
hiddenFields: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
triggers: {
select: {
actionClass: {
@@ -6,6 +6,8 @@ import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
const getEmail = async (token: string) => {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -86,6 +88,17 @@ export const GET = withV1ApiWrapper({
},
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -11,6 +12,8 @@ import {
import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -96,6 +99,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import {
TIntegrationSlackConfig,
TIntegrationSlackConfigData,
@@ -8,6 +9,8 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -104,6 +107,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, integration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
+1
View File
@@ -4926,6 +4926,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
showLanguageSwitch: false,
followUps: [],
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: false,
metadata: {},
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
+10
View File
@@ -51,6 +51,8 @@ checksums:
auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f
auth/login/lost_access: 917c4665b99c37377ed522ba53249006
auth/login/new_to_formbricks: 1a1d45aca05bb21eb8f795d7d62dc4e3
auth/login/oauth_account_not_linked_description: 74627dc30666699b21de093d16d83312
auth/login/oauth_account_not_linked_title: 2eb8e132ed37b3b87c1dec392c224933
auth/login/use_a_backup_code: 181e4ab6ba9e5b063b46925f1925eb2b
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
@@ -411,6 +413,7 @@ checksums:
common/team_name: 549d949de4b9adad4afd6427a60a329e
common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
@@ -1285,6 +1288,8 @@ checksums:
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
environments/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
environments/surveys/edit/auto_progress_rating_and_nps_description: cbf676789b9f3f47e36bdf35fa58282b
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
@@ -1706,6 +1711,8 @@ checksums:
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
environments/surveys/edit/use_page_font: 327c0c42a128a99af13510c24c813cf8
environments/surveys/edit/use_page_font_description: ffb511ce099a5ca25832228a85e2b4d8
environments/surveys/edit/validate_id_duplicate: f88ec35a9bd4921fb096817b9263b59a
environments/surveys/edit/validate_id_empty: 3ee25d429ed5ca9e047f9aee95496323
environments/surveys/edit/validate_id_invalid_chars: 50239938a408c04b02d77b8cd096d767
@@ -2021,6 +2028,7 @@ checksums:
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f
environments/surveys/summary/ttc_survey_tooltip: 9bd3971cb94670c54d74a4e86ee53172
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
@@ -2218,6 +2226,8 @@ checksums:
environments/workspace/look/suggested_colors_applied_please_save: a440b8e29a327822a94d9bbf8c52e2ed
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
environments/workspace/look/use_page_font_for_new_surveys: 327c0c42a128a99af13510c24c813cf8
environments/workspace/look/use_page_font_for_new_surveys_description: 010ae8817cc6c9b2aa17ba804e6ff393
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13
environments/workspace/tags/add_tag: 2cfa04ceea966149f2b5d40d9c131141
environments/workspace/tags/count: 9c5848662eb8024ddf360f7e4001a968
+5 -1
View File
@@ -81,7 +81,11 @@ export const extractChoiceIdsFromResponse = (
if (Array.isArray(responseValue)) {
// Multiple choice case - response is an array of selected choice labels
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
// Filter out empty string sentinel used as "other" marker in multipleChoiceMulti
return responseValue
.filter((v) => v !== "")
.map(findChoiceByLabel)
.filter((choiceId): choiceId is string => choiceId !== null);
} else if (typeof responseValue === "string") {
// Single choice case - response is a single choice label
const choiceId = findChoiceByLabel(responseValue);
+1
View File
@@ -93,6 +93,7 @@ const _colors = getSuggestedColors(DEFAULT_BRAND_COLOR);
*/
export const STYLE_DEFAULTS: TProjectStyling = {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: { light: _colors["brandColor.light"] },
questionColor: { light: _colors["questionColor.light"] },
inputColor: { light: _colors["inputColor.light"] },
@@ -209,6 +209,7 @@ const baseSurveyProperties = {
},
],
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
isCaptureIpEnabled: false,
endings: [
{
+1
View File
@@ -48,6 +48,7 @@ export const selectSurvey = {
isVerifyEmailEnabled: true,
isSingleResponsePerEmailEnabled: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: true,
redirectUrl: true,
projectOverwrites: true,
+69 -5
View File
@@ -7,6 +7,7 @@ describe("Styling Utilities", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: false,
isPageFontInheritedByDefault: false,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
@@ -20,13 +21,17 @@ describe("Styling Utilities", () => {
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("returns project styling when project allows style overwrite but survey does not overwrite", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
@@ -40,13 +45,17 @@ describe("Styling Utilities", () => {
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("returns survey styling when both project and survey allow style overwrite", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
@@ -60,13 +69,17 @@ describe("Styling Utilities", () => {
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(survey.styling);
expect(getStyling(project, survey)).toEqual({
...survey.styling,
isPageFontInherited: false,
});
});
test("returns project styling when project allows style overwrite but survey styling is undefined", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
@@ -76,13 +89,17 @@ describe("Styling Utilities", () => {
styling: undefined,
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("returns project styling when project allows style overwrite but survey overwriteThemeStyling is undefined", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
@@ -95,6 +112,53 @@ describe("Styling Utilities", () => {
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("keeps survey font preferences even when theme overwrite is disabled", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
brandColor: "#000000",
},
} as unknown as TJsEnvironmentStateProject;
const survey: TJsEnvironmentStateSurvey = {
styling: {
overwriteThemeStyling: false,
isPageFontInherited: true,
fontFamily: "Inter, Noto Sans Arabic, sans-serif",
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: true,
fontFamily: "Inter, Noto Sans Arabic, sans-serif",
});
});
test("inherits workspace default page-font setting when survey does not specify it", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: true,
brandColor: "#000000",
},
} as unknown as TJsEnvironmentStateProject;
const survey: TJsEnvironmentStateSurvey = {
styling: {
overwriteThemeStyling: false,
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: true,
});
});
});
+19 -3
View File
@@ -1,20 +1,36 @@
import { TJsEnvironmentStateProject, TJsEnvironmentStateSurvey } from "@formbricks/types/js";
export const getStyling = (project: TJsEnvironmentStateProject, survey: TJsEnvironmentStateSurvey) => {
const resolvedIsPageFontInherited =
survey.styling?.isPageFontInherited ?? project.styling.isPageFontInheritedByDefault ?? false;
const getFontOverrides = () => ({
isPageFontInherited: resolvedIsPageFontInherited,
...(survey.styling?.fontFamily !== undefined ? { fontFamily: survey.styling.fontFamily } : {}),
});
// allow style overwrite is disabled from the project
if (!project.styling.allowStyleOverwrite) {
return project.styling;
return {
...project.styling,
...getFontOverrides(),
};
}
// allow style overwrite is enabled from the project
if (project.styling.allowStyleOverwrite) {
// survey style overwrite is disabled
if (!survey.styling?.overwriteThemeStyling) {
return project.styling;
return {
...project.styling,
...getFontOverrides(),
};
}
// survey style overwrite is enabled
return survey.styling;
return {
...survey.styling,
isPageFontInherited: resolvedIsPageFontInherited,
};
}
return project.styling;
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "Zuweisen =",
"audience": "Publikum",
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
"auto_progress_rating_and_nps": "Bewertungs- und NPS-Fragen automatisch fortsetzen",
"auto_progress_rating_and_nps_description": "Fahre automatisch fort, sobald Befragte eine Antwort bei Bewertungs- oder NPS-Fragen auswählen. Dies gilt nur für Blöcke mit einer einzelnen Frage. Bei Pflichtfragen wird die Weiter-Schaltfläche ausgeblendet; bei optionalen Fragen bleibt sie zum Überspringen sichtbar.",
"auto_save_disabled": "Automatisches Speichern deaktiviert",
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
"auto_save_on": "Automatisches Speichern an",
@@ -1782,6 +1784,8 @@
"upper_label": "Oberes Label",
"url_filters": "URL-Filter",
"url_not_supported": "URL nicht unterstützt",
"use_page_font": "Seitenschriftart verwenden",
"use_page_font_description": "Verwende die Schriftart der Host-App oder Website für diese Umfrage.",
"validate_id_duplicate": "{type}-ID existiert bereits in Fragen, versteckten Feldern oder Variablen.",
"validate_id_empty": "Bitte gib eine {type}-ID ein.",
"validate_id_invalid_chars": "{type}-ID ist nicht erlaubt. Bitte verwende nur alphanumerische Zeichen, Bindestriche oder Unterstriche.",
@@ -2127,6 +2131,7 @@
"this_quarter": "Dieses Quartal",
"this_year": "Dieses Jahr",
"time_to_complete": "Zeit zur Fertigstellung",
"ttc_survey_tooltip": "Durchschnittliche Zeit zum Abschließen der Umfrage.",
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
"unknown_question_type": "Unbekannter Fragetyp",
"use_personal_links": "Nutze persönliche Links",
@@ -2337,7 +2342,9 @@
"suggest_colors": "Farben vorschlagen",
"suggested_colors_applied_please_save": "Vorgeschlagene Farben erfolgreich generiert. Drücke “Speichern”, um die Änderungen zu übernehmen.",
"theme": "Theme",
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren."
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren.",
"use_page_font_for_new_surveys": "Seitenschriftart verwenden",
"use_page_font_for_new_surveys_description": "Wende die Schriftart der Host-App oder Website auf alle Umfragen in diesem Workspace an."
},
"tags": {
"add": "Hinzufügen",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "Assign =",
"audience": "Audience",
"auto_close_on_inactivity": "Auto close on inactivity",
"auto_progress_rating_and_nps": "Auto-progress rating and NPS questions",
"auto_progress_rating_and_nps_description": "Automatically advance when respondents select an answer on rating or NPS questions. This only applies to single-question blocks. Required questions hide the Next button; optional questions still show it for skipping.",
"auto_save_disabled": "Auto-save disabled",
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
"auto_save_on": "Auto-save on",
@@ -1782,6 +1784,8 @@
"upper_label": "Upper Label",
"url_filters": "URL Filters",
"url_not_supported": "URL not supported",
"use_page_font": "Use page font",
"use_page_font_description": "Use the host app or website font for this survey.",
"validate_id_duplicate": "{type} ID already exists in questions, hidden fields, or variables.",
"validate_id_empty": "Please enter a {type} ID.",
"validate_id_invalid_chars": "{type} ID is not allowed. Please use only alphanumeric characters, hyphens, or underscores.",
@@ -2127,6 +2131,7 @@
"this_quarter": "This quarter",
"this_year": "This year",
"time_to_complete": "Time to Complete",
"ttc_survey_tooltip": "Average time to complete the survey.",
"ttc_tooltip": "Average time to complete the question.",
"unknown_question_type": "Unknown Question Type",
"use_personal_links": "Use personal links",
@@ -2337,7 +2342,9 @@
"suggest_colors": "Suggest colors",
"suggested_colors_applied_please_save": "Suggested colors generated successfully. Press “Save” to persist the changes.",
"theme": "Theme",
"theme_settings_description": "Create a style theme for all surveys. You can enable custom styling for each survey."
"theme_settings_description": "Create a style theme for all surveys. You can enable custom styling for each survey.",
"use_page_font_for_new_surveys": "Use page font",
"use_page_font_for_new_surveys_description": "Apply the host app or website font across surveys in this workspace."
},
"tags": {
"add": "Add",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "Asignar =",
"audience": "Audiencia",
"auto_close_on_inactivity": "Cierre automático por inactividad",
"auto_progress_rating_and_nps": "Avanzar automáticamente en preguntas de valoración y NPS",
"auto_progress_rating_and_nps_description": "Avanza automáticamente cuando los encuestados seleccionen una respuesta en preguntas de valoración o NPS. Esto solo se aplica a bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente; las preguntas opcionales aún lo muestran para omitirlas.",
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
@@ -1782,6 +1784,8 @@
"upper_label": "Etiqueta superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL no compatible",
"use_page_font": "Usar fuente de la página",
"use_page_font_description": "Utiliza la fuente de la aplicación o sitio web anfitrión para esta encuesta.",
"validate_id_duplicate": "El ID de {type} ya existe en preguntas, campos ocultos o variables.",
"validate_id_empty": "Por favor, introduce un ID de {type}.",
"validate_id_invalid_chars": "El ID de {type} no está permitido. Por favor, utiliza solo caracteres alfanuméricos, guiones o guiones bajos.",
@@ -2127,6 +2131,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este año",
"time_to_complete": "Tiempo para completar",
"ttc_survey_tooltip": "Tiempo promedio para completar la encuesta.",
"ttc_tooltip": "Tiempo medio para completar la pregunta.",
"unknown_question_type": "Tipo de pregunta desconocido",
"use_personal_links": "Usar enlaces personales",
@@ -2337,7 +2342,9 @@
"suggest_colors": "Sugerir colores",
"suggested_colors_applied_please_save": "Colores sugeridos generados correctamente. Pulsa “Guardar” para conservar los cambios.",
"theme": "Tema",
"theme_settings_description": "Crea un tema de estilo para todas las encuestas. Puedes activar el estilo personalizado para cada encuesta."
"theme_settings_description": "Crea un tema de estilo para todas las encuestas. Puedes activar el estilo personalizado para cada encuesta.",
"use_page_font_for_new_surveys": "Usar fuente de la página",
"use_page_font_for_new_surveys_description": "Aplica la fuente de la aplicación o sitio web anfitrión en todas las encuestas de este espacio de trabajo."
},
"tags": {
"add": "Añadir",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "Attribuer =",
"audience": "Public",
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
"auto_progress_rating_and_nps": "Progression automatique pour les questions d'évaluation et NPS",
"auto_progress_rating_and_nps_description": "Passe automatiquement à la question suivante lorsque les répondants sélectionnent une réponse aux questions d'évaluation ou NPS. Cela s'applique uniquement aux blocs à question unique. Les questions obligatoires masquent le bouton Suivant ; les questions facultatives l'affichent toujours pour permettre de passer la question.",
"auto_save_disabled": "Sauvegarde automatique désactivée",
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
"auto_save_on": "Sauvegarde automatique activée",
@@ -1782,6 +1784,8 @@
"upper_label": "Étiquette supérieure",
"url_filters": "Filtres d'URL",
"url_not_supported": "URL non supportée",
"use_page_font": "Utiliser la police de la page",
"use_page_font_description": "Utiliser la police de l'application hôte ou du site web pour ce sondage.",
"validate_id_duplicate": "L'ID {type} existe déjà dans les questions, champs masqués ou variables.",
"validate_id_empty": "Veuillez saisir un ID {type}.",
"validate_id_invalid_chars": "L'ID {type} n'est pas autorisé. Veuillez utiliser uniquement des caractères alphanumériques, des traits d'union ou des underscores.",
@@ -2127,6 +2131,7 @@
"this_quarter": "Ce trimestre",
"this_year": "Cette année",
"time_to_complete": "Temps à compléter",
"ttc_survey_tooltip": "Temps moyen pour compléter le sondage.",
"ttc_tooltip": "Temps moyen pour compléter la question.",
"unknown_question_type": "Type de question inconnu",
"use_personal_links": "Utilisez des liens personnels",
@@ -2337,7 +2342,9 @@
"suggest_colors": "Suggérer des couleurs",
"suggested_colors_applied_please_save": "Couleurs suggérées générées avec succès. Appuyez sur “Enregistrer” pour conserver les modifications.",
"theme": "Thème",
"theme_settings_description": "Créez un thème de style pour toutes les enquêtes. Vous pouvez activer un style personnalisé pour chaque enquête."
"theme_settings_description": "Créez un thème de style pour toutes les enquêtes. Vous pouvez activer un style personnalisé pour chaque enquête.",
"use_page_font_for_new_surveys": "Utiliser la police de la page",
"use_page_font_for_new_surveys_description": "Appliquer la police de l'application hôte ou du site web à tous les sondages de cet espace de travail."
},
"tags": {
"add": "Ajouter",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "= hozzárendelése",
"audience": "Közönség",
"auto_close_on_inactivity": "Automatikus lezárás tétlenségnél",
"auto_progress_rating_and_nps": "Automatikus továbblépés értékelési és NPS kérdéseknél",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés, amikor a válaszadók kiválasztanak egy választ az értékelési vagy NPS kérdéseknél. Ez csak az egykérdéses blokkokra vonatkozik. A kötelező kérdések elrejtik a Tovább gombot; az opcionális kérdések továbbra is megjelenítik azt a kihagyás lehetősége érdekében.",
"auto_save_disabled": "Az automatikus mentés letiltva",
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
"auto_save_on": "Automatikus mentés bekapcsolva",
@@ -1782,6 +1784,8 @@
"upper_label": "Felső címke",
"url_filters": "URL szűrők",
"url_not_supported": "Az URL nem támogatott",
"use_page_font": "Oldal betűtípusának használata",
"use_page_font_description": "A gazda alkalmazás vagy webhely betűtípusának használata ehhez a felméréshez.",
"validate_id_duplicate": "A(z) {type} azonosító már létezik a kérdések, rejtett mezők vagy változók között.",
"validate_id_empty": "Kérjük, adjon meg egy {type} azonosítót.",
"validate_id_invalid_chars": "A(z) {type} azonosító nem engedélyezett. Kérjük, csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket használjon.",
@@ -2127,6 +2131,7 @@
"this_quarter": "Ez a negyedév",
"this_year": "Ez az év",
"time_to_complete": "Kitöltéshez szükséges idő",
"ttc_survey_tooltip": "A felmérés kitöltésének átlagos ideje.",
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
"unknown_question_type": "Ismeretlen kérdéstípus",
"use_personal_links": "Személyes hivatkozások használata",
@@ -2337,7 +2342,9 @@
"suggest_colors": "Színek ajánlása",
"suggested_colors_applied_please_save": "Az ajánlott színek sikeresen előállítva. Nyomja meg a „Mentés” gombot a változtatások mentéséhez.",
"theme": "Téma",
"theme_settings_description": "Stílustéma létrehozása az összes kérdőívhez. Egyéni stílust engedélyezhet minden egyes kérdőívhez."
"theme_settings_description": "Stílustéma létrehozása az összes kérdőívhez. Egyéni stílust engedélyezhet minden egyes kérdőívhez.",
"use_page_font_for_new_surveys": "Oldal betűtípusának használata",
"use_page_font_for_new_surveys_description": "A gazda alkalmazás vagy webhely betűtípusának alkalmazása a munkaterület felmérésein."
},
"tags": {
"add": "Hozzáadás",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "割り当て =",
"audience": "オーディエンス",
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
"auto_progress_rating_and_nps": "評価とNPSの質問を自動進行",
"auto_progress_rating_and_nps_description": "評価またはNPSの質問で回答者が選択肢を選んだ際に自動的に次へ進みます。これは単一質問ブロックにのみ適用されます。必須の質問では「次へ」ボタンが非表示になり、任意の質問ではスキップ用に引き続き表示されます。",
"auto_save_disabled": "自動保存が無効",
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
"auto_save_on": "自動保存オン",
@@ -1782,6 +1784,8 @@
"upper_label": "上限ラベル",
"url_filters": "URLフィルター",
"url_not_supported": "URLはサポートされていません",
"use_page_font": "ページフォントを使用",
"use_page_font_description": "このアンケートにホストアプリまたはウェブサイトのフォントを使用します。",
"validate_id_duplicate": "{type} IDは質問、非表示フィールド、または変数に既に存在します。",
"validate_id_empty": "{type} IDを入力してください。",
"validate_id_invalid_chars": "{type} IDは使用できません。英数字、ハイフン、アンダースコアのみを使用してください。",
@@ -2127,6 +2131,7 @@
"this_quarter": "今四半期",
"this_year": "今年",
"time_to_complete": "完了までの時間",
"ttc_survey_tooltip": "アンケートの平均完了時間。",
"ttc_tooltip": "フォームを完了するまでの平均時間。",
"unknown_question_type": "不明な質問の種類",
"use_personal_links": "個人リンクを使用",
@@ -2337,7 +2342,9 @@
"suggest_colors": "カラーを提案",
"suggested_colors_applied_please_save": "推奨カラーが正常に生成されました。変更を保存するには“保存”を押してください。",
"theme": "テーマ",
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。"
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。",
"use_page_font_for_new_surveys": "ページフォントを使用",
"use_page_font_for_new_surveys_description": "このワークスペース内のアンケート全体にホストアプリまたはウェブサイトのフォントを適用します。"
},
"tags": {
"add": "追加",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "Toewijzen =",
"audience": "Publiek",
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
"auto_progress_rating_and_nps": "Automatisch doorgaan bij beoordelings- en NPS-vragen",
"auto_progress_rating_and_nps_description": "Ga automatisch verder wanneer respondenten een antwoord selecteren bij beoordelings- of NPS-vragen. Dit geldt alleen voor blokken met één vraag. Bij verplichte vragen wordt de Volgende-knop verborgen; bij optionele vragen blijft deze zichtbaar om de vraag over te slaan.",
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
"auto_save_on": "Automatisch opslaan aan",
@@ -1782,6 +1784,8 @@
"upper_label": "Bovenste etiket",
"url_filters": "URL-filters",
"url_not_supported": "URL niet ondersteund",
"use_page_font": "Gebruik paginafont",
"use_page_font_description": "Gebruik het lettertype van de host-app of website voor deze enquête.",
"validate_id_duplicate": "{type}-ID bestaat al in vragen, verborgen velden of variabelen.",
"validate_id_empty": "Voer een {type}-ID in.",
"validate_id_invalid_chars": "{type}-ID is niet toegestaan. Gebruik alleen alfanumerieke tekens, koppeltekens of underscores.",
@@ -2127,6 +2131,7 @@
"this_quarter": "Dit kwartaal",
"this_year": "Dit jaar",
"time_to_complete": "Tijd om te voltooien",
"ttc_survey_tooltip": "Gemiddelde tijd om de enquête te voltooien.",
"ttc_tooltip": "Gemiddelde tijd om de vraag te beantwoorden.",
"unknown_question_type": "Onbekend vraagtype",
"use_personal_links": "Gebruik persoonlijke links",
@@ -2337,7 +2342,9 @@
"suggest_colors": "Kleuren voorstellen",
"suggested_colors_applied_please_save": "Voorgestelde kleuren succesvol gegenereerd. Druk op “Opslaan” om de wijzigingen te behouden.",
"theme": "Thema",
"theme_settings_description": "Maak een stijlthema voor alle enquêtes. Je kunt aangepaste styling inschakelen voor elke enquête."
"theme_settings_description": "Maak een stijlthema voor alle enquêtes. Je kunt aangepaste styling inschakelen voor elke enquête.",
"use_page_font_for_new_surveys": "Gebruik paginafont",
"use_page_font_for_new_surveys_description": "Pas het lettertype van de host-app of website toe op alle enquêtes in deze workspace."
},
"tags": {
"add": "Toevoegen",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "atribuir =",
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de avaliação e NPS",
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os respondentes selecionam uma resposta em perguntas de avaliação ou NPS. Isso se aplica apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Próximo; perguntas opcionais ainda o exibem para permitir pular.",
"auto_save_disabled": "Salvamento automático desativado",
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
"auto_save_on": "Salvamento automático ativado",
@@ -1782,6 +1784,8 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportada",
"use_page_font": "Usar fonte da página",
"use_page_font_description": "Use a fonte do aplicativo ou site hospedeiro para esta pesquisa.",
"validate_id_duplicate": "O ID de {type} já existe em perguntas, campos ocultos ou variáveis.",
"validate_id_empty": "Por favor, insira um ID de {type}.",
"validate_id_invalid_chars": "O ID de {type} não é permitido. Por favor, use apenas caracteres alfanuméricos, hífens ou underscores.",
@@ -2127,6 +2131,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_survey_tooltip": "Tempo médio para completar a pesquisa.",
"ttc_tooltip": "Tempo médio para completar a pergunta.",
"unknown_question_type": "Tipo de pergunta desconhecido",
"use_personal_links": "Use links pessoais",
@@ -2337,7 +2342,9 @@
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressione “Salvar” para persistir as alterações.",
"theme": "Tema",
"theme_settings_description": "Crie um tema de estilo para todas as pesquisas. Você pode ativar estilo personalizado para cada pesquisa."
"theme_settings_description": "Crie um tema de estilo para todas as pesquisas. Você pode ativar estilo personalizado para cada pesquisa.",
"use_page_font_for_new_surveys": "Usar fonte da página",
"use_page_font_for_new_surveys_description": "Aplicar a fonte do aplicativo ou site hospedeiro em todas as pesquisas deste espaço de trabalho."
},
"tags": {
"add": "Adicionar",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "Atribuir =",
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de classificação e NPS",
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os inquiridos selecionam uma resposta em perguntas de classificação ou NPS. Isto aplica-se apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Seguinte; perguntas opcionais continuam a mostrá-lo para permitir saltar.",
"auto_save_disabled": "Guardar automático desativado",
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
"auto_save_on": "Guardar automático ativado",
@@ -1782,6 +1784,8 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportado",
"use_page_font": "Usar tipo de letra da página",
"use_page_font_description": "Utilizar o tipo de letra da aplicação ou website anfitrião para este inquérito.",
"validate_id_duplicate": "O ID {type} já existe em perguntas, campos ocultos ou variáveis.",
"validate_id_empty": "Por favor, introduza um ID {type}.",
"validate_id_invalid_chars": "O ID {type} não é permitido. Por favor, utilize apenas caracteres alfanuméricos, hífenes ou underscores.",
@@ -2127,6 +2131,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_survey_tooltip": "Tempo médio para completar o inquérito.",
"ttc_tooltip": "Tempo médio para concluir a pergunta.",
"unknown_question_type": "Tipo de Pergunta Desconhecido",
"use_personal_links": "Utilize links pessoais",
@@ -2337,7 +2342,9 @@
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Prima “Guardar” para persistir as alterações.",
"theme": "Tema",
"theme_settings_description": "Crie um tema de estilo para todos os inquéritos. Pode ativar estilos personalizados para cada inquérito."
"theme_settings_description": "Crie um tema de estilo para todos os inquéritos. Pode ativar estilos personalizados para cada inquérito.",
"use_page_font_for_new_surveys": "Usar tipo de letra da página",
"use_page_font_for_new_surveys_description": "Aplicar o tipo de letra da aplicação ou website anfitrião em todos os inquéritos desta área de trabalho."
},
"tags": {
"add": "Adicionar",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "Atribuire =",
"audience": "Public",
"auto_close_on_inactivity": "Închidere automată la inactivitate",
"auto_progress_rating_and_nps": "Avansare automată pentru întrebări de rating și NPS",
"auto_progress_rating_and_nps_description": "Avansează automat când respondenții selectează un răspuns la întrebările de rating sau NPS. Aceasta se aplică doar blocurilor cu o singură întrebare. Întrebările obligatorii ascund butonul Următorul; întrebările opționale îl afișează în continuare pentru a permite omiterea.",
"auto_save_disabled": "Salvare automată dezactivată",
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
"auto_save_on": "Salvare automată activată",
@@ -1782,6 +1784,8 @@
"upper_label": "Etichetă superioară",
"url_filters": "Filtre URL",
"url_not_supported": "URL nesuportat",
"use_page_font": "Folosește fontul paginii",
"use_page_font_description": "Folosește fontul aplicației sau site-ului gazdă pentru acest chestionar.",
"validate_id_duplicate": "ID-ul {type} există deja în întrebări, câmpuri ascunse sau variabile.",
"validate_id_empty": "Te rugăm să introduci un ID {type}.",
"validate_id_invalid_chars": "ID-ul {type} nu este permis. Te rugăm să folosești doar caractere alfanumerice, cratimă sau liniuță de subliniere.",
@@ -2127,6 +2131,7 @@
"this_quarter": "Trimestrul acesta",
"this_year": "Anul acesta",
"time_to_complete": "Timp de finalizare",
"ttc_survey_tooltip": "Timpul mediu de finalizare a sondajului.",
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
"unknown_question_type": "Tip de întrebare necunoscut",
"use_personal_links": "Folosește linkuri personale",
@@ -2337,7 +2342,9 @@
"suggest_colors": "Sugerează culori",
"suggested_colors_applied_please_save": "Culorile sugerate au fost generate cu succes. Apăsați „Save” pentru a salva modificările.",
"theme": "Temă",
"theme_settings_description": "Creează o temă de stil pentru toate sondajele. Poți activa stilizare personalizată pentru fiecare sondaj."
"theme_settings_description": "Creează o temă de stil pentru toate sondajele. Poți activa stilizare personalizată pentru fiecare sondaj.",
"use_page_font_for_new_surveys": "Folosește fontul paginii",
"use_page_font_for_new_surveys_description": "Aplică fontul aplicației sau site-ului gazdă pentru chestionarele din acest spațiu de lucru."
},
"tags": {
"add": "Adaugă",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "Назначить =",
"audience": "Аудитория",
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
"auto_progress_rating_and_nps": "Автоматический переход для вопросов с оценкой и NPS",
"auto_progress_rating_and_nps_description": "Автоматически переходить к следующему шагу, когда респонденты выбирают ответ в вопросах с оценкой или NPS. Это применяется только к блокам с одним вопросом. В обязательных вопросах кнопка «Далее» скрыта; в необязательных вопросах она остается видимой для пропуска.",
"auto_save_disabled": "Автосохранение отключено",
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
"auto_save_on": "Автосохранение включено",
@@ -1782,6 +1784,8 @@
"upper_label": "Верхняя метка",
"url_filters": "Фильтры URL",
"url_not_supported": "URL не поддерживается",
"use_page_font": "Использовать шрифт страницы",
"use_page_font_description": "Использовать шрифт приложения или веб-сайта для этого опроса.",
"validate_id_duplicate": "ID {type} уже существует в вопросах, скрытых полях или переменных.",
"validate_id_empty": "Пожалуйста, введите ID {type}.",
"validate_id_invalid_chars": "ID {type} недопустим. Пожалуйста, используйте только буквенно-цифровые символы, дефисы или подчеркивания.",
@@ -2127,6 +2131,7 @@
"this_quarter": "В этом квартале",
"this_year": "В этом году",
"time_to_complete": "Время на прохождение",
"ttc_survey_tooltip": "Среднее время прохождения опроса.",
"ttc_tooltip": "Среднее время на ответ на вопрос.",
"unknown_question_type": "Неизвестный тип вопроса",
"use_personal_links": "Использовать персональные ссылки",
@@ -2337,7 +2342,9 @@
"suggest_colors": "Предложить цвета",
"suggested_colors_applied_please_save": "Рекомендованные цвета успешно сгенерированы. Нажмите «Сохранить», чтобы применить изменения.",
"theme": "Тема",
"theme_settings_description": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса."
"theme_settings_description": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса.",
"use_page_font_for_new_surveys": "Использовать шрифт страницы",
"use_page_font_for_new_surveys_description": "Применять шрифт приложения или веб-сайта ко всем опросам в этом рабочем пространстве."
},
"tags": {
"add": "Добавить",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "Tilldela =",
"audience": "Målgrupp",
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
"auto_progress_rating_and_nps": "Gå vidare automatiskt vid betygs- och NPS-frågor",
"auto_progress_rating_and_nps_description": "Gå automatiskt vidare när respondenter väljer ett svar på betygs- eller NPS-frågor. Detta gäller endast block med en enda fråga. Obligatoriska frågor döljer Nästa-knappen; valfria frågor visar den fortfarande för att kunna hoppas över.",
"auto_save_disabled": "Automatisk sparning inaktiverad",
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
"auto_save_on": "Automatisk sparning på",
@@ -1782,6 +1784,8 @@
"upper_label": "Övre etikett",
"url_filters": "URL-filter",
"url_not_supported": "URL stöds inte",
"use_page_font": "Använd sidans typsnitt",
"use_page_font_description": "Använd värdappens eller webbplatsens typsnitt för den här enkäten.",
"validate_id_duplicate": "{type}-ID finns redan i frågor, dolda fält eller variabler.",
"validate_id_empty": "Vänligen ange ett {type}-ID.",
"validate_id_invalid_chars": "{type}-ID är inte tillåtet. Använd endast alfanumeriska tecken, bindestreck eller understreck.",
@@ -2127,6 +2131,7 @@
"this_quarter": "Detta kvartal",
"this_year": "Detta år",
"time_to_complete": "Tid att slutföra",
"ttc_survey_tooltip": "Genomsnittlig tid för att slutföra enkäten.",
"ttc_tooltip": "Genomsnittlig tid för att slutföra frågan.",
"unknown_question_type": "Okänd frågetyp",
"use_personal_links": "Använd personliga länkar",
@@ -2337,7 +2342,9 @@
"suggest_colors": "Föreslå färger",
"suggested_colors_applied_please_save": "Föreslagna färger har genererats. Tryck på ”Spara” för att spara ändringarna.",
"theme": "Tema",
"theme_settings_description": "Skapa ett stilmall för alla undersökningar. Du kan aktivera anpassad stil för varje undersökning."
"theme_settings_description": "Skapa ett stilmall för alla undersökningar. Du kan aktivera anpassad stil för varje undersökning.",
"use_page_font_for_new_surveys": "Använd sidans typsnitt",
"use_page_font_for_new_surveys_description": "Tillämpa värdappens eller webbplatsens typsnitt för alla enkäter i den här arbetsytan."
},
"tags": {
"add": "Lägg till",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "指派 =",
"audience": "受众",
"auto_close_on_inactivity": "自动关闭 在 无活动时",
"auto_progress_rating_and_nps": "自动推进评分和 NPS 问题",
"auto_progress_rating_and_nps_description": "当受访者在评分或 NPS 问题上选择答案时自动前进。这仅适用于单问题区块。必填问题会隐藏\"下一步\"按钮;可选问题仍会显示该按钮以便跳过。",
"auto_save_disabled": "自动保存已禁用",
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
"auto_save_on": "自动保存已启用",
@@ -1782,6 +1784,8 @@
"upper_label": "上限标签",
"url_filters": "URL 过滤器",
"url_not_supported": "URL 不支持",
"use_page_font": "使用页面字体",
"use_page_font_description": "为此调查问卷使用宿主应用或网站的字体。",
"validate_id_duplicate": "{type} ID 已存在于问题、隐藏字段或变量中。",
"validate_id_empty": "请输入 {type} ID。",
"validate_id_invalid_chars": "{type} ID 不允许使用。请仅使用字母数字字符、连字符或下划线。",
@@ -2127,6 +2131,7 @@
"this_quarter": "本季度",
"this_year": "今年",
"time_to_complete": "完成时间",
"ttc_survey_tooltip": "完成调查的平均时间。",
"ttc_tooltip": "完成 本 问题 的 平均 时间",
"unknown_question_type": "未知 问题 类型",
"use_personal_links": "使用 个人 链接",
@@ -2337,7 +2342,9 @@
"suggest_colors": "推荐颜色",
"suggested_colors_applied_please_save": "已成功生成建议颜色。请点击“保存”以保留更改。",
"theme": "主题",
"theme_settings_description": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。"
"theme_settings_description": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。",
"use_page_font_for_new_surveys": "使用页面字体",
"use_page_font_for_new_surveys_description": "在此工作区的所有调查问卷中应用宿主应用或网站的字体。"
},
"tags": {
"add": "添加",
+8 -1
View File
@@ -1359,6 +1359,8 @@
"assign": "等於 =",
"audience": "受眾",
"auto_close_on_inactivity": "非活動時自動關閉",
"auto_progress_rating_and_nps": "自動前進評分與 NPS 問題",
"auto_progress_rating_and_nps_description": "當受訪者在評分或 NPS 問題中選擇答案時自動前進。此設定僅適用於單一問題區塊。必填問題會隱藏「下一步」按鈕;選填問題仍會顯示該按鈕以便跳過。",
"auto_save_disabled": "自動儲存已停用",
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
"auto_save_on": "自動儲存已啟用",
@@ -1782,6 +1784,8 @@
"upper_label": "上標籤",
"url_filters": "網址篩選器",
"url_not_supported": "不支援網址",
"use_page_font": "使用頁面字型",
"use_page_font_description": "為此問卷使用主應用程式或網站字型。",
"validate_id_duplicate": "{type} ID 已存在於問題、隱藏欄位或變數中。",
"validate_id_empty": "請輸入 {type} ID。",
"validate_id_invalid_chars": "不允許使用此 {type} ID。請僅使用英數字元、連字號或底線。",
@@ -2127,6 +2131,7 @@
"this_quarter": "本季",
"this_year": "今年",
"time_to_complete": "完成時間",
"ttc_survey_tooltip": "完成問卷調查的平均時間。",
"ttc_tooltip": "完成 問題 的 平均 時間。",
"unknown_question_type": "未知的問題類型",
"use_personal_links": "使用 個人 連結",
@@ -2337,7 +2342,9 @@
"suggest_colors": "建議顏色",
"suggested_colors_applied_please_save": "已成功產生建議色彩。請按「Save」以儲存變更。",
"theme": "主題",
"theme_settings_description": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。"
"theme_settings_description": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。",
"use_page_font_for_new_surveys": "使用頁面字型",
"use_page_font_for_new_surveys_description": "在此工作區的所有問卷中套用主應用程式或網站字型。"
},
"tags": {
"add": "新增",
@@ -163,10 +163,12 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
/>
);
} else if (Array.isArray(responseData)) {
const itemsArray = responseData.map((choice) => {
const choiceId = getChoiceIdByValue(choice, element);
return { value: choice, id: choiceId };
});
const itemsArray = responseData
.filter((choice) => choice !== "")
.map((choice) => {
const choiceId = getChoiceIdByValue(choice, element);
return { value: choice, id: choiceId };
});
return (
<>
{element.type === TSurveyElementTypeEnum.Ranking ? (
@@ -98,14 +98,11 @@ describe("Users Lib", () => {
test("returns conflict error if user with email already exists", async () => {
(prisma.user.create as any).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed on the fields: (`email`)",
{
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
}
)
new Prisma.PrismaClientKnownRequestError("Unique constraint failed on the fields: (`email`)", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
})
);
const result = await createUser(
{ name: "Duplicate", email: "test@example.com", role: "member" },
+5
View File
@@ -166,6 +166,11 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
});
}
capturePostHogEvent(user.id, "organization_created", {
organization_id: organization.id,
is_first_org: true,
});
await updateUser(user.id, {
notificationSettings: {
...user.notificationSettings,
@@ -163,7 +163,7 @@ export const ThemeStyling = ({
<div className="relative flex w-1/2 flex-col pr-6">
<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="allowStyleOverwrite"
@@ -229,6 +229,11 @@ export const ThemeStyling = ({
setOpen={setFormStylingOpen}
isSettingsPage
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
usePageFontFieldName="isPageFontInheritedByDefault"
usePageFontLabel={t("environments.workspace.look.use_page_font_for_new_surveys")}
usePageFontDescription={t(
"environments.workspace.look.use_page_font_for_new_surveys_description"
)}
/>
<CardStylingSettings
@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import {
ColorField,
DimensionInput,
@@ -16,6 +17,7 @@ import {
StylingSection,
TextField,
} from "@/modules/ui/components/styling-fields";
import { Switch } from "@/modules/ui/components/switch";
type FormStylingSettingsProps = {
open: boolean;
@@ -23,6 +25,9 @@ type FormStylingSettingsProps = {
isSettingsPage?: boolean;
disabled?: boolean;
form: UseFormReturn<TProjectStyling | TSurveyStyling>;
usePageFontFieldName?: "isPageFontInherited" | "isPageFontInheritedByDefault";
usePageFontLabel?: string;
usePageFontDescription?: string;
};
export const FormStylingSettings = ({
@@ -31,6 +36,9 @@ export const FormStylingSettings = ({
disabled = false,
setOpen,
form,
usePageFontFieldName,
usePageFontLabel,
usePageFontDescription,
}: FormStylingSettingsProps) => {
const { t } = useTranslation();
@@ -86,6 +94,27 @@ export const FormStylingSettings = ({
open={headlinesOpen}
setOpen={setHeadlinesOpen}>
<div className="grid grid-cols-2 gap-4">
{usePageFontFieldName && (
<FormField
control={form.control}
name={usePageFontFieldName as never}
render={({ field }) => (
<FormItem className="col-span-2 flex w-full items-center gap-2 space-y-0 rounded-lg border border-slate-200 p-3">
<FormControl>
<Switch checked={!!field.value} onCheckedChange={(value) => field.onChange(value)} />
</FormControl>
<div>
<FormLabel>
{usePageFontLabel ?? t("environments.surveys.edit.use_page_font")}
</FormLabel>
<FormDescription>
{usePageFontDescription ?? t("environments.surveys.edit.use_page_font_description")}
</FormDescription>
</div>
</FormItem>
)}
/>
)}
<ColorField
form={form}
name="elementHeadlineColor.light"
@@ -118,6 +118,10 @@ export const ResponseOptionsCard = ({
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
};
const handleAutoProgressToggle = () => {
setLocalSurvey({ ...localSurvey, isAutoProgressingEnabled: !localSurvey.isAutoProgressingEnabled });
};
const handleCaptureIpToggle = () => {
setCaptureIpToggle(!captureIpToggle);
setLocalSurvey({ ...localSurvey, isCaptureIpEnabled: !localSurvey.isCaptureIpEnabled });
@@ -384,6 +388,13 @@ export const ResponseOptionsCard = ({
</AdvancedOptionToggle>
</>
)}
<AdvancedOptionToggle
htmlId="autoProgressRatingNps"
isChecked={Boolean(localSurvey.isAutoProgressingEnabled)}
onToggle={handleAutoProgressToggle}
title={t("environments.surveys.edit.auto_progress_rating_and_nps")}
description={t("environments.surveys.edit.auto_progress_rating_and_nps_description")}
/>
<AdvancedOptionToggle
htmlId="hideBackButton"
isChecked={localSurvey.isBackButtonHidden}
@@ -64,25 +64,39 @@ export const StylingView = ({
const { t } = useTranslation();
const savedProjectStyling = project.styling as Partial<TProjectStyling> | null;
const { isPageFontInheritedByDefault: styleDefaultIsPageFontInheritedByDefault, ...surveyStyleDefaults } =
STYLE_DEFAULTS;
// Strip null/undefined values so they don't override STYLE_DEFAULTS.
const cleanProject = savedProjectStyling
? Object.fromEntries(Object.entries(savedProjectStyling).filter(([, v]) => v != null))
const cleanProject: Partial<TProjectStyling> = savedProjectStyling
? (Object.fromEntries(
Object.entries(savedProjectStyling).filter(([, v]) => v != null)
) as Partial<TProjectStyling>)
: {};
const cleanSurvey = localSurvey.styling
? Object.fromEntries(Object.entries(localSurvey.styling).filter(([, v]) => v != null))
const {
isPageFontInheritedByDefault: projectIsPageFontInheritedByDefault,
...cleanProjectWithoutFontDefault
} = cleanProject;
const cleanSurvey: Partial<TSurveyStyling> = localSurvey.styling
? (Object.fromEntries(
Object.entries(localSurvey.styling).filter(([, v]) => v != null)
) as Partial<TSurveyStyling>)
: {};
const projectLegacyFills = deriveNewFieldsFromLegacy(cleanProject);
const projectLegacyFills = deriveNewFieldsFromLegacy(cleanProjectWithoutFontDefault);
const surveyLegacyFills = deriveNewFieldsFromLegacy(cleanSurvey);
const form = useForm<TSurveyStyling>({
defaultValues: {
...STYLE_DEFAULTS,
...surveyStyleDefaults,
...projectLegacyFills,
...cleanProject,
...cleanProjectWithoutFontDefault,
...surveyLegacyFills,
...cleanSurvey,
isPageFontInherited:
cleanSurvey.isPageFontInherited ??
projectIsPageFontInheritedByDefault ??
styleDefaultIsPageFontInheritedByDefault,
},
});
@@ -110,16 +124,19 @@ export const StylingView = ({
};
const onResetThemeStyling = () => {
const { allowStyleOverwrite, ...baseStyling } = project.styling ?? {};
const { allowStyleOverwrite, isPageFontInheritedByDefault, ...baseStyling } = project.styling ?? {};
const isPageFontInherited = form.getValues("isPageFontInherited") ?? false;
setStyling({
...baseStyling,
overwriteThemeStyling: true,
isPageFontInherited,
});
form.reset({
...baseStyling,
overwriteThemeStyling: true,
isPageFontInherited,
});
setConfirmResetStylingModalOpen(false);
@@ -151,7 +168,7 @@ export const StylingView = ({
const defaultProjectStyling = useMemo(() => {
const { styling: projectStyling } = project;
const { allowStyleOverwrite, ...baseStyling } = projectStyling ?? {};
const { allowStyleOverwrite, isPageFontInheritedByDefault, ...baseStyling } = projectStyling ?? {};
return baseStyling;
}, [project]);
@@ -166,9 +183,11 @@ export const StylingView = ({
if (value) {
if (!styling) {
// copy the project styling to the survey styling
const isPageFontInherited = form.getValues("isPageFontInherited") ?? false;
setStyling({
...defaultProjectStyling,
overwriteThemeStyling: true,
isPageFontInherited,
});
return;
}
@@ -179,9 +198,11 @@ export const StylingView = ({
}
// if there are no local styling changes, we set the styling to the project styling
else {
const isPageFontInherited = form.getValues("isPageFontInherited") ?? false;
setStyling({
...defaultProjectStyling,
overwriteThemeStyling: true,
isPageFontInherited,
});
}
}
@@ -192,9 +213,11 @@ export const StylingView = ({
setLocalStylingChanges(styling);
// copy the project styling to the survey styling
const isPageFontInherited = form.getValues("isPageFontInherited") ?? false;
setStyling({
...defaultProjectStyling,
overwriteThemeStyling: false,
isPageFontInherited,
});
}
};
@@ -205,26 +228,36 @@ export const StylingView = ({
<div className="mt-12 space-y-3 p-5">
{!isCxMode && (
<div className="flex items-center gap-4 py-4">
<FormField
control={form.control}
name="overwriteThemeStyling"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
</FormControl>
<div className="flex w-full flex-col gap-4">
<FormField
control={form.control}
name="overwriteThemeStyling"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch
id="overwrite-theme-styling"
checked={!!field.value}
onCheckedChange={handleOverwriteToggle}
/>
</FormControl>
<div>
<FormLabel className="text-base font-semibold text-slate-900">
{t("environments.surveys.edit.add_custom_styles")}
</FormLabel>
<FormDescription className="text-sm text-slate-800">
{t("environments.surveys.edit.override_theme_with_individual_styles_for_this_survey")}
</FormDescription>
</div>
</FormItem>
)}
/>
<div>
<FormLabel
htmlFor="overwrite-theme-styling"
className="text-base font-semibold text-slate-900">
{t("environments.surveys.edit.add_custom_styles")}
</FormLabel>
<FormDescription className="text-sm text-slate-800">
{t(
"environments.surveys.edit.override_theme_with_individual_styles_for_this_survey"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
</div>
)}
@@ -265,6 +298,9 @@ export const StylingView = ({
setOpen={setFormStylingOpen}
disabled={!overwriteThemeStyling}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
usePageFontFieldName="isPageFontInherited"
usePageFontLabel={t("environments.surveys.edit.use_page_font")}
usePageFontDescription={t("environments.surveys.edit.use_page_font_description")}
/>
<CardStylingSettings
+1
View File
@@ -41,6 +41,7 @@ export const selectSurvey = {
showLanguageSwitch: true,
recaptcha: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
metadata: true,
slug: true,
customHeadScripts: true,
@@ -189,10 +189,19 @@ function computeStyling(
projectStyling: TProjectStyling,
surveyStyling?: TSurveyStyling | null
): TProjectStyling | TSurveyStyling {
const resolvedIsPageFontInherited =
surveyStyling?.isPageFontInherited ?? projectStyling.isPageFontInheritedByDefault ?? false;
const fontOverrides = {
isPageFontInherited: resolvedIsPageFontInherited,
...(surveyStyling?.fontFamily !== undefined ? { fontFamily: surveyStyling.fontFamily } : {}),
};
if (!projectStyling.allowStyleOverwrite) {
return projectStyling;
return { ...projectStyling, ...fontOverrides };
}
return surveyStyling?.overwriteThemeStyling ? surveyStyling : projectStyling;
return surveyStyling?.overwriteThemeStyling
? { ...surveyStyling, isPageFontInherited: resolvedIsPageFontInherited }
: { ...projectStyling, ...fontOverrides };
}
/**
@@ -79,6 +79,7 @@ describe("data", () => {
redirectUrl: null,
pin: null,
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
singleUse: null,
projectOverwrites: null,
styling: null,
@@ -116,6 +117,11 @@ describe("data", () => {
type: true,
}),
});
expect(vi.mocked(prisma.survey.findUnique).mock.calls[0][0].select).toEqual(
expect.objectContaining({
isAutoProgressingEnabled: true,
})
);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockSurveyData);
});
@@ -196,6 +202,7 @@ describe("data", () => {
redirectUrl: null,
pin: null,
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
singleUse: null,
projectOverwrites: null,
surveyClosedMessage: null,
+1
View File
@@ -47,6 +47,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
redirectUrl: true,
pin: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
isCaptureIpEnabled: true,
// Single use configuration
@@ -41,6 +41,7 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
variables: [],
followUps: [],
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
metadata: {},
slug: null,
isCaptureIpEnabled: false,
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { getStyling as getEffectiveStyling } from "@/lib/utils/styling";
import { ClientLogo } from "@/modules/ui/components/client-logo";
import { MediaBackground } from "@/modules/ui/components/media-background";
import { ResetProgressButton } from "@/modules/ui/components/reset-progress-button";
@@ -59,23 +60,7 @@ export const PreviewSurvey = ({
const clickOutsideClose = surveyClickOutsideClose ?? project.clickOutsideClose;
const styling: TSurveyStyling | TProjectStyling = useMemo(() => {
// allow style overwrite is disabled from the project
if (!project.styling.allowStyleOverwrite) {
return project.styling;
}
// allow style overwrite is enabled from the project
if (project.styling.allowStyleOverwrite) {
// survey style overwrite is disabled
if (!survey.styling?.overwriteThemeStyling) {
return project.styling;
}
// survey style overwrite is enabled
return survey.styling;
}
return project.styling;
return getEffectiveStyling(project, survey);
}, [project.styling, survey.styling]);
const updateElementId = useCallback(
@@ -329,6 +329,19 @@ test.describe("Survey Styling", async () => {
// Navigate to Styling tab
await page.getByRole("button", { name: "Styling" }).click();
await openAccordion(page, "Survey styling");
await openAccordion(page, "Headlines & Descriptions");
const usePageFontToggle = page.getByLabel("Use page font");
await expect(usePageFontToggle).toBeVisible();
await expect(usePageFontToggle).toBeChecked();
let fontCss = await page.evaluate(() => document.getElementById("formbricks__css__custom")?.innerHTML);
expect(fontCss).not.toContain("--fb-font-family");
await usePageFontToggle.click();
await page.waitForTimeout(800);
fontCss = await page.evaluate(() => document.getElementById("formbricks__css__custom")?.innerHTML);
expect(fontCss).toContain("--fb-font-family: Inter, Helvetica, Arial, sans-serif");
// Toggle "Enable custom styling" (Survey override)
// Note: The label text might be "Add custom styles" in survey editor?
// Checking previous file: `page.getByLabel("Add custom styles")`
+4
View File
@@ -5631,6 +5631,9 @@ components:
isBackButtonHidden:
type: boolean
description: Whether the back button is hidden
isAutoProgressingEnabled:
type: boolean
description: Whether auto-progress is enabled for eligible question types
recaptcha:
type:
- object
@@ -5706,6 +5709,7 @@ components:
- isSingleResponsePerEmailEnabled
- inlineTriggers
- isBackButtonHidden
- isAutoProgressingEnabled
- recaptcha
- metadata
- displayPercentage
@@ -28,6 +28,7 @@ Fine-tune how question headlines, descriptions, and upper labels appear:
| Property | Description |
|---|---|
| **Use page font** | Applies the host app or website font across surveys in this workspace. This is a workspace-level setting and affects all surveys unless a survey explicitly overrides it in the Survey Editor. |
| **Headline Color** | Color of the question headline text |
| **Description Color** | Color of the question description text |
| **Headline Font Size** | Font size for headlines (in `px` or any CSS unit) |
@@ -38,6 +39,11 @@ Fine-tune how question headlines, descriptions, and upper labels appear:
| **Upper Label Font Size** | Font size for upper labels |
| **Upper Label Font Weight** | Numeric font weight for upper labels |
<Note>
New workspaces have <strong>Use page font</strong> enabled by default. Existing workspaces keep their current
default until you change it.
</Note>
### Inputs
Control the appearance of text inputs, textareas, and other form fields:
@@ -27,6 +27,12 @@ Overwrite the global styling theme for individual surveys to create unique style
Just hit the **Save** button to apply your changes. Your survey is now ready to impress with its unique look!
<Note>
The workspace-level <strong>Use page font</strong> setting (in <strong>Look & Feel → Survey styling → Headlines & Descriptions</strong>)
applies to all surveys in the workspace by default. In this survey-level styling view, you can enable or disable
<strong>Use page font</strong> to override that default for a specific survey.
</Note>
## Overwrite CSS Styles for App & Website Surveys
You can overwrite the default CSS styles for app and website surveys by adding the following CSS to your global CSS file (e.g., `globals.css`):
@@ -0,0 +1,2 @@
ALTER TABLE "Survey"
ADD COLUMN "isAutoProgressingEnabled" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -395,6 +395,7 @@ model Survey {
isVerifyEmailEnabled Boolean @default(false)
isSingleResponsePerEmailEnabled Boolean @default(false)
isBackButtonHidden Boolean @default(false)
isAutoProgressingEnabled Boolean @default(false)
isCaptureIpEnabled Boolean @default(false)
pin String?
displayPercentage Decimal?
+1
View File
@@ -138,6 +138,7 @@ const ZSurveyBase = z.object({
isSingleResponsePerEmailEnabled: z.boolean().describe("Whether single response per email is enabled"),
inlineTriggers: z.array(z.any()).nullable().describe("Inline triggers configuration"),
isBackButtonHidden: z.boolean().describe("Whether the back button is hidden"),
isAutoProgressingEnabled: z.boolean().describe("Whether auto-progress is enabled for eligible questions"),
recaptcha: ZSurveyRecaptcha.describe("Google reCAPTCHA configuration"),
metadata: ZSurveyMetadata.describe("Custom link metadata for social sharing"),
displayPercentage: z.number().nullable().describe("The display percentage of the survey"),
@@ -311,7 +311,11 @@ describe("utils.ts", () => {
test("returns project styling if allowStyleOverwrite=false", () => {
const project = {
id: "p1",
styling: { allowStyleOverwrite: false, brandColor: { light: "#fff" } },
styling: {
allowStyleOverwrite: false,
isPageFontInheritedByDefault: false,
brandColor: { light: "#fff" },
},
} as TEnvironmentStateProject;
const survey = {
styling: {
@@ -322,13 +326,20 @@ describe("utils.ts", () => {
const result = getStyling(project, survey);
// should get project styling
expect(result).toEqual(project.styling);
expect(result).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("returns project styling if allowStyleOverwrite=true but survey overwriteThemeStyling=false", () => {
const project = {
id: "p1",
styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: { light: "#fff" },
},
} as TEnvironmentStateProject;
const survey = {
styling: {
@@ -339,13 +350,20 @@ describe("utils.ts", () => {
const result = getStyling(project, survey);
// should get project styling still
expect(result).toEqual(project.styling);
expect(result).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("returns survey styling if allowStyleOverwrite=true and survey overwriteThemeStyling=true", () => {
const project = {
id: "p1",
styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: { light: "#fff" },
},
} as TEnvironmentStateProject;
const survey = {
styling: {
@@ -355,7 +373,53 @@ describe("utils.ts", () => {
} as TEnvironmentStateSurvey;
const result = getStyling(project, survey);
expect(result).toEqual(survey.styling);
expect(result).toEqual({
...survey.styling,
isPageFontInherited: false,
});
});
test("keeps survey font preferences when using project theme", () => {
const project = {
id: "p1",
styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
} as TEnvironmentStateProject;
const survey = {
styling: {
overwriteThemeStyling: false,
isPageFontInherited: true,
fontFamily: "Inter, Noto Sans Arabic, sans-serif",
} as TSurveyStyling,
} as TEnvironmentStateSurvey;
const result = getStyling(project, survey);
expect(result).toEqual({
...project.styling,
isPageFontInherited: true,
fontFamily: "Inter, Noto Sans Arabic, sans-serif",
});
});
test("inherits workspace default page-font setting when survey does not specify it", () => {
const project = {
id: "p1",
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: true,
brandColor: { light: "#fff" },
},
} as TEnvironmentStateProject;
const survey = {
styling: {
overwriteThemeStyling: false,
} as TSurveyStyling,
} as TEnvironmentStateSurvey;
const result = getStyling(project, survey);
expect(result).toEqual({
...project.styling,
isPageFontInherited: true,
});
});
});
+19 -3
View File
@@ -142,19 +142,35 @@ export const getStyling = (
project: TEnvironmentStateProject,
survey: TEnvironmentStateSurvey
): TProjectStyling | TSurveyStyling => {
const resolvedIsPageFontInherited =
survey.styling?.isPageFontInherited ?? project.styling.isPageFontInheritedByDefault ?? false;
const getFontOverrides = () => ({
isPageFontInherited: resolvedIsPageFontInherited,
...(survey.styling?.fontFamily !== undefined ? { fontFamily: survey.styling.fontFamily } : {}),
});
// allow style overwrite is enabled from the project
if (project.styling.allowStyleOverwrite) {
// survey style overwrite is disabled
if (!survey.styling?.overwriteThemeStyling) {
return project.styling;
return {
...project.styling,
...getFontOverrides(),
};
}
// survey style overwrite is enabled
return survey.styling;
return {
...survey.styling,
isPageFontInherited: resolvedIsPageFontInherited,
};
}
// allow style overwrite is disabled from the project
return project.styling;
return {
...project.styling,
...getFontOverrides(),
};
};
export const getDefaultLanguageCode = (survey: TEnvironmentStateSurvey): string | undefined => {
@@ -79,4 +79,5 @@ export const mockSurvey: TEnvironmentStateSurvey = {
brandColor: { light: "#2B6CB0" },
},
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
};
+5 -1
View File
@@ -20,6 +20,7 @@ export type TEnvironmentStateSurvey = Pick<
| "delay"
| "projectOverwrites"
| "isBackButtonHidden"
| "isAutoProgressingEnabled"
| "recaptcha"
> & {
languages: (SurveyLanguage & { language: Language })[];
@@ -90,11 +91,12 @@ export interface TConfigInput {
export interface TStylingColor {
light: string;
dark?: string | null | undefined;
dark?: string | null;
}
export interface TBaseStyling {
brandColor?: TStylingColor | null;
fontFamily?: string | null;
questionColor?: TStylingColor | null;
inputColor?: TStylingColor | null;
inputBorderColor?: TStylingColor | null;
@@ -118,10 +120,12 @@ export interface TBaseStyling {
export interface TProjectStyling extends TBaseStyling {
allowStyleOverwrite: boolean;
isPageFontInheritedByDefault?: boolean | null;
}
export interface TSurveyStyling extends TBaseStyling {
overwriteThemeStyling?: boolean | null;
isPageFontInherited?: boolean | null;
}
export interface TUpdates {
+9
View File
@@ -1,6 +1,15 @@
import { S3Client, type S3ClientConfig } from "@aws-sdk/client-s3";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
// Mock the AWS SDK S3Client
vi.mock("@aws-sdk/client-s3", () => ({
S3Client: vi.fn(function MockS3Client(
+9
View File
@@ -11,6 +11,15 @@ import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
type Paginator<T> = AsyncGenerator<T, undefined, unknown>;
// Mock AWS SDK modules
@@ -280,6 +280,7 @@ function DropdownVariant({
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-required={required}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
/>
@@ -401,6 +402,7 @@ function ListVariant({
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-required={required}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
ref={otherInputRef}
@@ -272,6 +272,7 @@ function SingleSelect({
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
/>
@@ -334,6 +335,7 @@ function SingleSelect({
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-required={required}
aria-invalid={Boolean(errorMessage)}
dir={dir}
className="mt-2 w-full"
/>
+5 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { cn } from "./utils";
vi.mock("isomorphic-dompurify", () => ({
sanitize: vi.fn((value: string) => value),
}));
describe("cn", () => {
test("merges class names correctly", () => {
expect(cn("foo", "bar")).toBe("foo bar");
+1
View File
@@ -12,6 +12,7 @@
"da",
"de",
"es",
"et",
"fr",
"hi",
"hu",
+1
View File
@@ -34,6 +34,7 @@ checksums:
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
common/welcome_video: 1f87e84c0a563c2522eef5cb71a1f95c
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
errors/all_options_must_be_ranked: 360a2edff623496f7047907bad115ea1
errors/all_rows_must_be_answered: 295f41a0ef04cbb3491c798053c61abd
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "شروط الخدمة",
"the_servers_cannot_be_reached_at_the_moment": "لا يمكن الوصول إلى الخوادم في الوقت الحالي.",
"they_will_be_redirected_immediately": "سيتم إعادة توجيههم فورًا",
"welcome_video": "فيديو بطاقة الترحيب",
"your_feedback_is_stuck": "تعليقك عالق :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Vilkår for brug",
"the_servers_cannot_be_reached_at_the_moment": "Serverne kan ikke kontaktes lige nu.",
"they_will_be_redirected_immediately": "De bliver straks omdirigeret",
"welcome_video": "Velkomstkortvideo",
"your_feedback_is_stuck": "Din feedback sidder fast :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Nutzungsbedingungen",
"the_servers_cannot_be_reached_at_the_moment": "Die Server sind momentan nicht erreichbar.",
"they_will_be_redirected_immediately": "Sie werden sofort weitergeleitet",
"welcome_video": "Willkommenskarten-Video",
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Terms of Service",
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
"they_will_be_redirected_immediately": "They will be redirected immediately",
"welcome_video": "Welcome Card video",
"your_feedback_is_stuck": "Your feedback is stuck :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Términos de servicio",
"the_servers_cannot_be_reached_at_the_moment": "Los servidores no pueden ser alcanzados en este momento.",
"they_will_be_redirected_immediately": "Serán redirigidos inmediatamente",
"welcome_video": "Vídeo de la tarjeta de bienvenida",
"your_feedback_is_stuck": "Tu feedback está atascado :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Teenusetingimused",
"the_servers_cannot_be_reached_at_the_moment": "Serveritega ei saa hetkel ühendust.",
"they_will_be_redirected_immediately": "Nad suunatakse kohe ümber",
"welcome_video": "Tervituskaardi video",
"your_feedback_is_stuck": "Sinu tagasiside on kinni jäänud :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Conditions d'utilisation",
"the_servers_cannot_be_reached_at_the_moment": "Les serveurs ne sont pas accessibles pour le moment.",
"they_will_be_redirected_immediately": "Ils seront redirigés immédiatement",
"welcome_video": "Vidéo de la carte de bienvenue",
"your_feedback_is_stuck": "Votre feedback est bloqué :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "सेवा की शर्तें",
"the_servers_cannot_be_reached_at_the_moment": "इस समय सर्वर तक पहुंचा नहीं जा सकता है।",
"they_will_be_redirected_immediately": "उन्हें तुरंत रीडायरेक्ट किया जाएगा",
"welcome_video": "स्वागत कार्ड वीडियो",
"your_feedback_is_stuck": "आपकी प्रतिक्रिया अटक गई है :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Használati feltételek",
"the_servers_cannot_be_reached_at_the_moment": "Jelenleg nem lehet elérni a kiszolgálókat.",
"they_will_be_redirected_immediately": "Azonnal át lesznek irányítva",
"welcome_video": "Üdvözlő kártya videó",
"your_feedback_is_stuck": "A visszajelzése elakadt :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Termini di servizio",
"the_servers_cannot_be_reached_at_the_moment": "I server non sono raggiungibili al momento.",
"they_will_be_redirected_immediately": "Saranno reindirizzati immediatamente",
"welcome_video": "Video della scheda di benvenuto",
"your_feedback_is_stuck": "Il tuo feedback è bloccato :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "利用規約",
"the_servers_cannot_be_reached_at_the_moment": "現在サーバーに接続できません。",
"they_will_be_redirected_immediately": "すぐにリダイレクトされます",
"welcome_video": "ウェルカムカード動画",
"your_feedback_is_stuck": "フィードバックが送信できません :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Servicevoorwaarden",
"the_servers_cannot_be_reached_at_the_moment": "De servers zijn momenteel niet bereikbaar.",
"they_will_be_redirected_immediately": "Ze worden onmiddellijk doorgestuurd",
"welcome_video": "Welkomstkaart video",
"your_feedback_is_stuck": "Je feedback blijft hangen :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Termos de serviço",
"the_servers_cannot_be_reached_at_the_moment": "Os servidores não podem ser alcançados no momento.",
"they_will_be_redirected_immediately": "Eles serão redirecionados imediatamente",
"welcome_video": "Vídeo do Cartão de Boas-vindas",
"your_feedback_is_stuck": "Seu feedback está preso :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Termeni și condiții",
"the_servers_cannot_be_reached_at_the_moment": "Serverele nu pot fi accesate momentan.",
"they_will_be_redirected_immediately": "Vor fi redirecționați imediat",
"welcome_video": "Videoclip Card de bun venit",
"your_feedback_is_stuck": "Feedback-ul tău este blocat :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Условия использования",
"the_servers_cannot_be_reached_at_the_moment": "Сервера в данный момент недоступны.",
"they_will_be_redirected_immediately": "Они будут немедленно перенаправлены",
"welcome_video": "Видео приветственной карточки",
"your_feedback_is_stuck": "Ваш отзыв застрял :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Användarvillkor",
"the_servers_cannot_be_reached_at_the_moment": "Servrarna kan inte nås för tillfället.",
"they_will_be_redirected_immediately": "De kommer att omdirigeras omedelbart",
"welcome_video": "Välkomstkortvideo",
"your_feedback_is_stuck": "Din feedback fastnade :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "Xizmat ko'rsatish shartlari",
"the_servers_cannot_be_reached_at_the_moment": "Hozirda serverlarga ulanish imkoni yo'q.",
"they_will_be_redirected_immediately": "Ular darhol yo'naltiriladi",
"welcome_video": "Xush kelibsiz kartasi videosi",
"your_feedback_is_stuck": "Sizning fikr-mulohazangiz qotib qoldi :("
},
"errors": {
+1
View File
@@ -33,6 +33,7 @@
"terms_of_service": "服务条款",
"the_servers_cannot_be_reached_at_the_moment": "目前无法连接到服务器。",
"they_will_be_redirected_immediately": "他们将立即被重定向",
"welcome_video": "欢迎卡片视频",
"your_feedback_is_stuck": "您的反馈卡住了 :("
},
"errors": {
+3 -1
View File
@@ -49,7 +49,8 @@
"i18next-icu": "2.4.3",
"isomorphic-dompurify": "3.1.0",
"preact": "10.29.0",
"react-i18next": "16.5.8"
"react-i18next": "16.5.8",
"tailwind-merge": "3.5.0"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
@@ -63,6 +64,7 @@
"autoprefixer": "10.4.27",
"concurrently": "9.2.1",
"fake-indexeddb": "6.2.5",
"happy-dom": "20.8.9",
"postcss": "8.5.8",
"rollup-plugin-visualizer": "7.0.1",
"tailwindcss": "4.2.1",
@@ -169,8 +169,7 @@ export function MultipleChoiceMultiElement({
setOtherValue(newOtherValue);
const baseLabels = getNormalizedSelectedLabels();
const nextValue = [...baseLabels, ""];
if (newOtherValue.trim()) nextValue.push(newOtherValue);
const nextValue = [...baseLabels, newOtherValue];
onChange({ [element.id]: nextValue });
};
@@ -227,8 +226,7 @@ export function MultipleChoiceMultiElement({
});
if (isOtherNowSelected) {
nextLabels.push("");
if (otherValue.trim()) nextLabels.push(otherValue);
nextLabels.push(otherValue);
} else if (otherValue) {
// If other was deselected, clear any stale other value
setOtherValue("");
@@ -11,6 +11,11 @@ import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { ElementConditional } from "@/components/general/element-conditional";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import {
getAutoProgressElement,
shouldHideSubmitButtonForAutoProgress,
shouldTriggerAutoProgress,
} from "@/lib/auto-progress";
import { getLocalizedValue } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import { getFirstErrorMessage, validateBlockResponses } from "@/lib/validation/evaluator";
@@ -32,6 +37,7 @@ interface BlockConditionalProps {
surveyId: string;
autoFocusEnabled: boolean;
isBackButtonHidden: boolean;
isAutoProgressingEnabled: boolean;
onOpenExternalURL?: (url: string) => void | Promise<void>;
dir?: "ltr" | "rtl" | "auto";
fullSizeCards: boolean;
@@ -55,6 +61,7 @@ export function BlockConditional({
onFileUpload,
autoFocusEnabled,
isBackButtonHidden,
isAutoProgressingEnabled,
onOpenExternalURL,
dir,
fullSizeCards,
@@ -71,6 +78,12 @@ export function BlockConditional({
// Ref to collect TTC values synchronously (state updates are async)
const ttcCollectorRef = useRef<TResponseTtc>({});
const autoProgressingInFlightRef = useRef(false);
const autoProgressElement = getAutoProgressElement(block.elements, isAutoProgressingEnabled);
const shouldHideSubmitButton = shouldHideSubmitButtonForAutoProgress(
block.elements,
isAutoProgressingEnabled
);
// Handle change for an individual element
const handleElementChange = (elementId: string, responseData: TResponseData) => {
@@ -86,8 +99,37 @@ export function BlockConditional({
return updated;
});
}
const mergedValue = { ...value, ...responseData };
const blockResponses = block.elements.reduce<TResponseData>((acc, element) => {
const elementValue = mergedValue[element.id];
if (elementValue !== undefined) {
acc[element.id] = elementValue;
}
return acc;
}, {});
// Merge with existing block data to preserve other element values
onChange({ ...value, ...responseData });
onChange(mergedValue);
if (
shouldTriggerAutoProgress({
changedElementId: elementId,
mergedValue,
autoProgressElement,
isAlreadyInFlight: autoProgressingInFlightRef.current,
})
) {
autoProgressingInFlightRef.current = true;
// Defer submission so element-level change handlers can finalize TTC updates first.
setTimeout(() => {
try {
const blockTtc = collectTtcValues();
onSubmit(blockResponses, blockTtc);
} finally {
autoProgressingInFlightRef.current = false;
}
}, 0);
}
};
// Handler to collect TTC values synchronously (called from element form submissions)
@@ -218,9 +260,7 @@ export function BlockConditional({
for (const element of block.elements) {
const form = elementFormRefs.current.get(element.id);
if (form && !validateElementForm(element, form)) {
if (!firstInvalidForm) {
firstInvalidForm = form;
}
firstInvalidForm ??= form;
}
}
@@ -350,14 +390,19 @@ export function BlockConditional({
fullSizeCards ? "bg-survey-bg sticky bottom-0" : ""
)}>
<div>
<SubmitButton
buttonLabel={
block.buttonLabel ? getLocalizedValue(block.buttonLabel, languageCode) : undefined
}
isLastQuestion={isLastBlock}
onClick={handleBlockSubmit}
tabIndex={0}
/>
{shouldHideSubmitButton ? (
// Keep layout symmetry for Back button positioning (LTR/RTL).
<div aria-hidden="true" className="mb-1 h-(--fb-button-height)" />
) : (
<SubmitButton
buttonLabel={
block.buttonLabel ? getLocalizedValue(block.buttonLabel, languageCode) : undefined
}
isLastQuestion={isLastBlock}
onClick={handleBlockSubmit}
tabIndex={0}
/>
)}
</div>
{!isFirstBlock && !isBackButtonHidden && (
<BackButton
@@ -23,15 +23,16 @@ interface ElementMediaProps {
imgUrl?: string;
videoUrl?: string;
altText?: string;
className?: string;
}
export function ElementMedia({ imgUrl, videoUrl, altText = "Image" }: ElementMediaProps) {
export function ElementMedia({ imgUrl, videoUrl, altText = "Image", className }: ElementMediaProps) {
const { t } = useTranslation();
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
const [isLoading, setIsLoading] = useState(true);
return (
<div className="group/image relative mb-6 block min-h-40 rounded-md">
<div className={cn("group/image relative mb-6 block min-h-40 rounded-md", className)}>
{isLoading ? (
<div className="absolute inset-auto flex h-full w-full animate-pulse items-center justify-center rounded-md bg-slate-200" />
) : null}
@@ -539,8 +539,8 @@ export function Survey({
// --- Warn before leaving mid-survey or with unsent offline responses ---
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
// Warn if user has started answering but hasn't finished the survey
if (history.length > 0 && !isSurveyFinished) {
// Warn if user has started answering but hasn't finished the survey (only when offline support is active)
if (offlinePersistEnabled && history.length > 0 && !isSurveyFinished) {
e.preventDefault();
return;
}
@@ -1080,6 +1080,7 @@ export function Survey({
languageCode={selectedLanguage}
autoFocusEnabled={autoFocusEnabled}
isBackButtonHidden={localSurvey.isBackButtonHidden}
isAutoProgressingEnabled={localSurvey.isAutoProgressingEnabled}
onOpenExternalURL={onOpenExternalURL}
dir={dir}
fullSizeCards={fullSizeCards}
@@ -147,8 +147,10 @@ export function WelcomeCard({
return (
<ScrollableContainer fullSizeCards={fullSizeCards}>
<div>
{fileUrl || videoUrl ? (
<ElementMedia imgUrl={fileUrl} videoUrl={videoUrl} altText={t("common.company_logo")} />
{fileUrl ? (
<ElementMedia imgUrl={fileUrl} altText={t("common.company_logo")} className="mb-8 min-h-0 w-1/4" />
) : videoUrl ? (
<ElementMedia videoUrl={videoUrl} altText={t("common.welcome_video")} />
) : null}
<Headline
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
getAutoProgressElement,
shouldHideSubmitButtonForAutoProgress,
shouldTriggerAutoProgress,
} from "./auto-progress";
const createElement = (id: string, type: TSurveyElementTypeEnum, required: boolean): TSurveyElement =>
({
id,
type,
required,
}) as unknown as TSurveyElement;
describe("auto-progress helpers", () => {
test("returns auto-progress element for single rating/nps blocks only", () => {
const ratingElement = createElement("rating_1", TSurveyElementTypeEnum.Rating, true);
const npsElement = createElement("nps_1", TSurveyElementTypeEnum.NPS, false);
const openTextElement = createElement("text_1", TSurveyElementTypeEnum.OpenText, false);
expect(getAutoProgressElement([ratingElement], true)).toEqual(ratingElement);
expect(getAutoProgressElement([npsElement], true)).toEqual(npsElement);
expect(getAutoProgressElement([openTextElement], true)).toBeNull();
expect(getAutoProgressElement([ratingElement], false)).toBeNull();
expect(getAutoProgressElement([ratingElement, npsElement], true)).toBeNull();
});
test("hides submit button only for required auto-progress elements", () => {
const requiredRating = createElement("rating_required", TSurveyElementTypeEnum.Rating, true);
const optionalRating = createElement("rating_optional", TSurveyElementTypeEnum.Rating, false);
expect(shouldHideSubmitButtonForAutoProgress([requiredRating], true)).toBe(true);
expect(shouldHideSubmitButtonForAutoProgress([optionalRating], true)).toBe(false);
expect(shouldHideSubmitButtonForAutoProgress([requiredRating], false)).toBe(false);
});
test("triggers auto-progress only when an eligible response was changed", () => {
const autoProgressElement = createElement("rating_1", TSurveyElementTypeEnum.Rating, true);
expect(
shouldTriggerAutoProgress({
changedElementId: "rating_1",
mergedValue: { rating_1: 5 },
autoProgressElement,
isAlreadyInFlight: false,
})
).toBe(true);
expect(
shouldTriggerAutoProgress({
changedElementId: "other",
mergedValue: { rating_1: 5 },
autoProgressElement,
isAlreadyInFlight: false,
})
).toBe(false);
expect(
shouldTriggerAutoProgress({
changedElementId: "rating_1",
mergedValue: {},
autoProgressElement,
isAlreadyInFlight: false,
})
).toBe(false);
expect(
shouldTriggerAutoProgress({
changedElementId: "rating_1",
mergedValue: { rating_1: 5 },
autoProgressElement,
isAlreadyInFlight: true,
})
).toBe(false);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { type TResponseData } from "@formbricks/types/responses";
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
const isAutoProgressElementType = (type: TSurveyElementTypeEnum): boolean =>
type === TSurveyElementTypeEnum.Rating || type === TSurveyElementTypeEnum.NPS;
export const getAutoProgressElement = (
elements: TSurveyElement[],
isAutoProgressingEnabled: boolean
): TSurveyElement | null => {
if (!isAutoProgressingEnabled || elements.length !== 1) {
return null;
}
const [element] = elements;
return isAutoProgressElementType(element.type) ? element : null;
};
export const shouldHideSubmitButtonForAutoProgress = (
elements: TSurveyElement[],
isAutoProgressingEnabled: boolean
): boolean => {
const autoProgressElement = getAutoProgressElement(elements, isAutoProgressingEnabled);
return Boolean(autoProgressElement?.required);
};
export const shouldTriggerAutoProgress = ({
changedElementId,
mergedValue,
autoProgressElement,
isAlreadyInFlight,
}: {
changedElementId: string;
mergedValue: TResponseData;
autoProgressElement: TSurveyElement | null;
isAlreadyInFlight: boolean;
}): boolean => {
if (!autoProgressElement || isAlreadyInFlight || changedElementId !== autoProgressElement.id) {
return false;
}
return mergedValue[autoProgressElement.id] !== undefined;
};
+14 -1
View File
@@ -1,4 +1,4 @@
// @vitest-environment jsdom
// @vitest-environment happy-dom
import { describe, expect, test } from "vitest";
import { isValidHTML, stripInlineStyles } from "./html-utils";
@@ -42,6 +42,19 @@ describe("html-utils", () => {
test("should handle empty string", () => {
expect(stripInlineStyles("")).toBe("");
});
test("should remove script tags and dangerous event handler attributes", () => {
const input =
'<script>alert("x")</script><img src="x" onerror="alert(1)" /><a href="https://example.com" target="_blank" onclick="alert(1)" style="color:red">Go</a>';
const sanitized = stripInlineStyles(input);
expect(sanitized).not.toContain("<script");
expect(sanitized).not.toContain("</script>");
expect(sanitized).not.toContain("onerror=");
expect(sanitized).not.toContain("onclick=");
expect(sanitized).not.toContain("style=");
expect(sanitized).toContain('target="_blank"');
});
});
describe("isValidHTML", () => {
+1
View File
@@ -136,6 +136,7 @@ describe("Survey Logic", () => {
displayPercentage: 0,
recaptcha: { enabled: false, threshold: 0.5 },
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
segment: null,
welcomeCard: {
enabled: true,
@@ -1,4 +1,4 @@
// @vitest-environment jsdom
// @vitest-environment happy-dom
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { err, ok } from "@formbricks/types/error-handlers";
import { TResponseUpdate } from "@formbricks/types/responses";
+38 -1
View File
@@ -1,4 +1,4 @@
// @vitest-environment jsdom
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { type TProjectStyling } from "@formbricks/types/project";
import { type TSurveyStyling } from "@formbricks/types/surveys/types";
@@ -278,6 +278,43 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-border-radius"]).toBe("8px"); // Default roundness
});
test("uses the legacy font stack when page-font inheritance is disabled", () => {
const styling = getBaseProjectStyling({});
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
const variables = getCssVariables(styleElement);
expect(variables["--fb-font-family"]).toBe("Inter, Helvetica, Arial, sans-serif");
expect(styleElement.innerHTML).toContain("font-family: var(--fb-font-family) !important;");
});
test("inherits host font when page-font inheritance is enabled", () => {
const styling: TSurveyStyling = {
isPageFontInherited: true,
};
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
const variables = getCssVariables(styleElement);
expect(variables["--fb-font-family"]).toBeUndefined();
expect(styleElement.innerHTML).not.toContain("font-family: var(--fb-font-family) !important;");
});
test("prefers explicit fontFamily over page-font inheritance", () => {
const styling: TSurveyStyling = {
isPageFontInherited: true,
fontFamily: "Inter, Noto Sans Arabic, sans-serif",
};
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
const variables = getCssVariables(styleElement);
expect(variables["--fb-font-family"]).toBe("Inter, Noto Sans Arabic, sans-serif");
expect(styleElement.innerHTML).toContain("font-family: var(--fb-font-family) !important;");
});
test("should apply brand-text-color as black for light brandColor", () => {
const styling = getBaseProjectStyling({ brandColor: { light: "#FFFF00" } }); // A light color
addCustomThemeToDom({ styling });
+15
View File
@@ -7,6 +7,8 @@ import global from "@/styles/global.css?inline";
import preflight from "@/styles/preflight.css?inline";
import editorCss from "../../../../apps/web/modules/ui/components/editor/styles-editor-frontend.css?inline";
const LEGACY_FONT_FAMILY_STACK = "Inter, Helvetica, Arial, sans-serif";
// Store the nonce globally for style elements
let styleNonce: string | undefined;
@@ -81,6 +83,14 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// Start the innerHTML string with #fbjs
let cssVariables = "#fbjs {\n";
const explicitFontFamily = styling.fontFamily?.trim();
const isPageFontInherited =
(styling as TSurveyStyling).isPageFontInherited ??
(styling as TProjectStyling).isPageFontInheritedByDefault ??
false;
const resolvedFontFamily =
explicitFontFamily || (isPageFontInherited ? undefined : LEGACY_FONT_FAMILY_STACK);
// Helper function to append the variable if it's not undefined
const appendCssVariable = (variableName: string, value?: string | null) => {
if (value !== undefined && value !== null) {
@@ -299,6 +309,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
"progress-indicator-bg-color",
styling.progressIndicatorBgColor?.light ?? styling.brandColor?.light
);
appendCssVariable("font-family", resolvedFontFamily);
// Close the #fbjs variable block
cssVariables += "}\n";
@@ -319,6 +330,10 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
};
// --- Headlines ---
if (resolvedFontFamily) {
addRule("#fbjs", " font-family: var(--fb-font-family) !important;\n");
}
let headlineDecls = "";
if (styling.elementHeadlineFontSize !== undefined)
headlineDecls += " font-size: var(--fb-element-headline-font-size) !important;\n";
+1 -1
View File
@@ -1,4 +1,4 @@
// @vitest-environment jsdom
// @vitest-environment happy-dom
import { act, renderHook } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { TResponseTtc } from "@formbricks/types/responses";
@@ -1,4 +1,4 @@
// @vitest-environment jsdom
// @vitest-environment happy-dom
import { act, renderHook } from "@testing-library/preact";
import { describe, expect, test } from "vitest";
import { useOnlineStatus } from "./use-online-status";
+43
View File
@@ -4,6 +4,7 @@ import type { TJsEnvironmentStateSurvey } from "../../../types/js";
import { type TAllowedFileExtension, mimeTypes } from "../../../types/storage";
import type { TSurveyLanguage } from "../../../types/surveys/types";
import {
cn,
findBlockByElementId,
getDefaultLanguageCode,
getElementsFromSurveyBlocks,
@@ -510,3 +511,45 @@ describe("isRTLLanguage", () => {
expect(isRTLLanguage(survey, "default")).toBe(true);
});
});
describe("cn", () => {
test("joins multiple classes", () => {
expect(cn("foo", "bar")).toBe("foo bar");
});
test("filters out undefined values", () => {
expect(cn("foo", undefined, "bar")).toBe("foo bar");
});
test("filters out empty strings", () => {
expect(cn("foo", "", "bar")).toBe("foo bar");
});
test("merges conflicting tailwind classes (last wins)", () => {
expect(cn("mb-6", "mb-8")).toBe("mb-8");
});
test("merges conflicting min-h classes", () => {
expect(cn("min-h-40", "min-h-0")).toBe("min-h-0");
});
test("merges conflicting padding classes", () => {
expect(cn("p-4", "p-2")).toBe("p-2");
});
test("keeps non-conflicting classes", () => {
expect(cn("mb-6 block rounded-md", "w-1/4")).toBe("mb-6 block rounded-md w-1/4");
});
test("handles single class", () => {
expect(cn("foo")).toBe("foo");
});
test("handles no arguments", () => {
expect(cn()).toBe("");
});
test("handles all undefined", () => {
expect(cn(undefined, undefined)).toBe("");
});
});

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