Compare commits

...

10 Commits

Author SHA1 Message Date
Dhruwang 5d68d1f524 refactor: adjust padding and text handling in OpenID button
Updated the OpenID button to include horizontal padding and simplified the text rendering logic. This change ensures better alignment and visual consistency, while maintaining the truncation behavior for long text. The 'last used' indicator remains unaffected.
2026-04-15 14:55:05 +05:30
xhamzax ed0c36a87f refactor: use flex layout instead of absolute positioning for last-used badge
Addresses coderabbitai feedback: replace the absolute-positioned badge
with a flex sibling layout (gap-2) so the label can truncate naturally
and the badge gets a stable gap regardless of text length. Removes the
magic pr-16 padding and absolute right-3 that broke on long translations.
2026-04-14 05:36:42 +01:00
xhamzax c0ed93be7a fix: prevent OIDC button text overlap with 'last used' indicator
When OIDC_DISPLAY_NAME is set to a long value (e.g. 'OIDC lemonldap'),
the centered button text can extend into the area where the absolutely-
positioned 'last used' indicator sits (right-3, opacity-50). In Firefox,
this causes visible text bleed-through where the two labels overlap.

Wrap the main text in a truncate span so it ellipsis-truncates before
reaching the indicator area, and add overflow-hidden on the button to
clip any remaining overflow. When lastUsed is shown, apply pr-16 to
leave room for the indicator. Add shrink-0 on the indicator span so
it doesn't collapse.

Fixes #7539
2026-04-14 05:36:42 +01:00
Dhruwang Jariwala 439dd0b44e fix: add loading skeleton for responses page (#7700)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-13 16:56:20 +00: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
95 changed files with 905 additions and 104 deletions
@@ -0,0 +1,22 @@
"use client";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle="" />
<div className="flex h-9 animate-pulse gap-2">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="summary" />
</PageContentWrapper>
);
};
export default Loading;
@@ -0,0 +1,23 @@
"use client";
import { useTranslation } from "react-i18next";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
const { t } = useTranslation();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.responses")} />
<div className="flex h-9 animate-pulse gap-1.5">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="responseTable" />
</PageContentWrapper>
);
};
export default Loading;
@@ -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)
+6
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
@@ -2021,6 +2026,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
+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);
@@ -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,
+3
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",
@@ -2127,6 +2129,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",
+3
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",
@@ -2127,6 +2129,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",
+3
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",
@@ -2127,6 +2129,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",
+3
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",
@@ -2127,6 +2129,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",
+3
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",
@@ -2127,6 +2129,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",
+3
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": "自動保存オン",
@@ -2127,6 +2129,7 @@
"this_quarter": "今四半期",
"this_year": "今年",
"time_to_complete": "完了までの時間",
"ttc_survey_tooltip": "アンケートの平均完了時間。",
"ttc_tooltip": "フォームを完了するまでの平均時間。",
"unknown_question_type": "不明な質問の種類",
"use_personal_links": "個人リンクを使用",
+3
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",
@@ -2127,6 +2129,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",
+3
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",
@@ -2127,6 +2129,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",
+3
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",
@@ -2127,6 +2129,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",
+3
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ă",
@@ -2127,6 +2129,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",
+3
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": "Автосохранение включено",
@@ -2127,6 +2129,7 @@
"this_quarter": "В этом квартале",
"this_year": "В этом году",
"time_to_complete": "Время на прохождение",
"ttc_survey_tooltip": "Среднее время прохождения опроса.",
"ttc_tooltip": "Среднее время на ответ на вопрос.",
"unknown_question_type": "Неизвестный тип вопроса",
"use_personal_links": "Использовать персональные ссылки",
+3
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å",
@@ -2127,6 +2129,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",
+3
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": "自动保存已启用",
@@ -2127,6 +2129,7 @@
"this_quarter": "本季度",
"this_year": "今年",
"time_to_complete": "完成时间",
"ttc_survey_tooltip": "完成调查的平均时间。",
"ttc_tooltip": "完成 本 问题 的 平均 时间",
"unknown_question_type": "未知 问题 类型",
"use_personal_links": "使用 个人 链接",
+3
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": "自動儲存已啟用",
@@ -2127,6 +2129,7 @@
"this_quarter": "本季",
"this_year": "今年",
"time_to_complete": "完成時間",
"ttc_survey_tooltip": "完成問卷調查的平均時間。",
"ttc_tooltip": "完成 問題 的 平均 時間。",
"unknown_question_type": "未知的問題類型",
"use_personal_links": "使用 個人 連結",
@@ -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 ? (
+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,
@@ -46,9 +46,9 @@ export const OpenIdButton = ({
type="button"
onClick={handleLogin}
variant="secondary"
className="relative w-full justify-center">
{text ? text : t("auth.continue_with_openid")}
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
className="w-full items-center justify-center gap-2 px-2">
<span className="truncate">{text || t("auth.continue_with_openid")}</span>
{lastUsed && <span className="shrink-0 text-xs opacity-50">{t("auth.last_used")}</span>}
</Button>
);
};
@@ -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}
@@ -211,11 +211,15 @@ export const StylingView = ({
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
<Switch
id="overwrite-theme-styling"
checked={!!field.value}
onCheckedChange={handleOverwriteToggle}
/>
</FormControl>
<div>
<FormLabel className="text-base font-semibold text-slate-900">
<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">
+1
View File
@@ -41,6 +41,7 @@ export const selectSurvey = {
showLanguageSwitch: true,
recaptcha: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
metadata: true,
slug: true,
customHeadScripts: true,
@@ -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,
@@ -1,7 +1,7 @@
import { Skeleton } from "@/modules/ui/components/skeleton";
type SkeletonLoaderProps = {
type: "response" | "summary";
type: "response" | "responseTable" | "summary";
};
export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
@@ -25,6 +25,43 @@ export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
);
}
if (type === "responseTable") {
const renderTableCells = () => (
<>
<Skeleton className="h-4 w-4 rounded-xl bg-slate-400" />
<Skeleton className="h-4 w-24 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
</>
);
return (
<div className="animate-pulse space-y-4" data-testid="skeleton-loader-response-table">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-md bg-slate-300" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
</div>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200">
<div className="flex h-12 items-center gap-4 border-b border-slate-200 bg-slate-100 px-4">
{renderTableCells()}
</div>
{Array.from({ length: 10 }, (_, i) => (
<div
key={i}
className="flex h-12 items-center gap-4 border-b border-slate-100 px-4 last:border-b-0">
{renderTableCells()}
</div>
))}
</div>
</div>
);
}
if (type === "response") {
return (
<div className="group space-y-4 rounded-lg bg-white p-6" data-testid="skeleton-loader-response">
+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
@@ -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"),
@@ -79,4 +79,5 @@ export const mockSurvey: TEnvironmentStateSurvey = {
brandColor: { light: "#2B6CB0" },
},
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
};
+1
View File
@@ -20,6 +20,7 @@ export type TEnvironmentStateSurvey = Pick<
| "delay"
| "projectOverwrites"
| "isBackButtonHidden"
| "isAutoProgressingEnabled"
| "recaptcha"
> & {
languages: (SurveyLanguage & { language: Language })[];
+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";
+1 -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";
+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("");
});
});
+3 -2
View File
@@ -1,3 +1,4 @@
import { twMerge } from "tailwind-merge";
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
@@ -11,8 +12,8 @@ import { type TSurveyElement, type TSurveyElementChoice } from "@formbricks/type
import { type TShuffleOption } from "@formbricks/types/surveys/types";
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
export const cn = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
export const cn = (...classes: (string | undefined)[]) => {
return twMerge(classes.filter(Boolean).join(" "));
};
export const getSecureRandom = (): number => {
@@ -88,6 +88,46 @@ describe("validateElementResponse", () => {
expect(result.valid).toBe(true);
});
test("should return error when required multi-select has other selected but no text", () => {
const element = {
id: "mc1",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Pick" },
required: true,
choices: [{ id: "opt1", label: { default: "Option 1" } }],
} as unknown as TSurveyElement;
const result = validateElementResponse(element, ["opt1", ""], "en");
expect(result.valid).toBe(false);
expect(result.errors[0].ruleId).toBe("required");
});
test("should return valid when required multi-select has other with text (legacy sentinel)", () => {
const element = {
id: "mc1",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Pick" },
required: true,
choices: [{ id: "opt1", label: { default: "Option 1" } }],
} as unknown as TSurveyElement;
const result = validateElementResponse(element, ["opt1", "", "custom"], "en");
expect(result.valid).toBe(true);
});
test("should return valid when required multi-select has other text without sentinel", () => {
const element = {
id: "mc1",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Pick" },
required: true,
choices: [{ id: "opt1", label: { default: "Option 1" } }],
} as unknown as TSurveyElement;
const result = validateElementResponse(element, ["opt1", "custom"], "en");
expect(result.valid).toBe(true);
});
test("should handle required ranking element - at least one ranked", () => {
const element: TSurveyElement = {
id: "rank1",
@@ -154,6 +154,17 @@ const checkRequiredField = (
return createRequiredError(t);
}
// For multi-select: if "other" is selected (sentinel ""), require the other text to be non-empty
if (element.type === TSurveyElementTypeEnum.MultipleChoiceMulti && Array.isArray(value)) {
const sentinelIndex = value.indexOf("");
if (sentinelIndex !== -1) {
const otherText = value[sentinelIndex + 1];
if (!otherText || (typeof otherText === "string" && otherText.trim() === "")) {
return createRequiredError(t);
}
}
}
return null;
};
+1
View File
@@ -29,6 +29,7 @@ export const ZJsEnvironmentStateSurvey = ZSurveyBase.pick({
delay: true,
projectOverwrites: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
recaptcha: true,
}).superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
+3
View File
@@ -914,6 +914,7 @@ export const ZSurveyBase = z.object({
recaptcha: ZSurveyRecaptcha.nullable(),
isSingleResponsePerEmailEnabled: z.boolean(),
isBackButtonHidden: z.boolean(),
isAutoProgressingEnabled: z.boolean().optional().prefault(false),
isCaptureIpEnabled: z.boolean(),
pin: z
.string()
@@ -3798,6 +3799,7 @@ export const ZSurveyCreateInput = makeSchemaOptional(ZSurveyBase)
endings: ZSurveyEndings.prefault([]),
type: ZSurveyType.prefault("link"),
followUps: z.array(ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true })).prefault([]),
isAutoProgressingEnabled: z.boolean().prefault(false),
})
.superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
@@ -3846,6 +3848,7 @@ export const ZSurveyCreateInputWithEnvironmentId = makeSchemaOptional(ZSurveyBas
endings: ZSurveyEndings.prefault([]),
type: ZSurveyType.prefault("link"),
followUps: z.array(ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true })).prefault([]),
isAutoProgressingEnabled: z.boolean().prefault(false),
})
.superRefine((survey, ctx) => {
surveyRefinement(survey as z.infer<typeof ZSurveyBase>, ctx);
+62 -28
View File
@@ -495,7 +495,7 @@ importers:
version: 1.2.0
'@vitest/coverage-v8':
specifier: 4.0.18
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
autoprefixer:
specifier: 10.4.27
version: 10.4.27(postcss@8.5.8)
@@ -519,10 +519,10 @@ importers:
version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest-mock-extended:
specifier: 3.1.0
version: 3.1.0(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 3.1.0(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
packages/ai:
dependencies:
@@ -556,7 +556,7 @@ importers:
version: link:../config-eslint
'@vitest/coverage-v8':
specifier: 4.0.18
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vite:
specifier: 8.0.0
version: 8.0.0(@emnapi/core@1.7.1)(@emnapi/runtime@1.8.1)(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
@@ -565,7 +565,7 @@ importers:
version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@8.0.0(@emnapi/core@1.7.1)(@emnapi/runtime@1.8.1)(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/cache:
dependencies:
@@ -587,13 +587,13 @@ importers:
version: link:../config-eslint
'@vitest/coverage-v8':
specifier: 4.0.18
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vite:
specifier: 7.3.1
version: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest:
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/config-eslint:
devDependencies:
@@ -608,10 +608,10 @@ importers:
version: 8.57.0(eslint@8.57.1)(typescript@5.9.3)
'@vercel/style-guide':
specifier: 6.0.0
version: 6.0.0(@next/eslint-plugin-next@15.5.12)(eslint@8.57.1)(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 6.0.0(@next/eslint-plugin-next@15.5.12)(eslint@8.57.1)(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/eslint-plugin':
specifier: 1.6.10
version: 1.6.10(eslint@8.57.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 1.6.10(eslint@8.57.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
eslint-config-next:
specifier: 15.5.12
version: 15.5.12(eslint@8.57.1)(typescript@5.9.3)
@@ -776,7 +776,7 @@ importers:
version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/js-core:
devDependencies:
@@ -788,7 +788,7 @@ importers:
version: link:../config-eslint
'@vitest/coverage-v8':
specifier: 4.0.18
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
terser:
specifier: 5.46.0
version: 5.46.0
@@ -800,7 +800,7 @@ importers:
version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/logger:
dependencies:
@@ -831,7 +831,7 @@ importers:
version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/storage:
dependencies:
@@ -856,7 +856,7 @@ importers:
version: link:../config-eslint
'@vitest/coverage-v8':
specifier: 4.0.18
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vite:
specifier: 7.3.1
version: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
@@ -865,7 +865,7 @@ importers:
version: 4.5.4(@types/node@25.4.0)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/survey-ui:
dependencies:
@@ -941,7 +941,7 @@ importers:
version: 5.1.4(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/coverage-v8':
specifier: 4.0.18
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
react:
specifier: 19.2.4
version: 19.2.4
@@ -965,7 +965,7 @@ importers:
version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/surveys:
dependencies:
@@ -990,6 +990,9 @@ importers:
react-i18next:
specifier: 16.5.8
version: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
tailwind-merge:
specifier: 3.5.0
version: 3.5.0
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*
@@ -1024,6 +1027,9 @@ importers:
fake-indexeddb:
specifier: 6.2.5
version: 6.2.5
happy-dom:
specifier: 20.8.9
version: 20.8.9
postcss:
specifier: 8.5.8
version: 8.5.8
@@ -5760,6 +5766,9 @@ packages:
'@types/webidl-conversions@7.0.3':
resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/whatwg-url@11.0.5':
resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==}
@@ -7952,6 +7961,10 @@ packages:
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
engines: {node: '>=14.0.0'}
happy-dom@20.8.9:
resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==}
engines: {node: '>=20.0.0'}
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -11277,6 +11290,10 @@ packages:
webpack-cli:
optional: true
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
@@ -17875,6 +17892,8 @@ snapshots:
'@types/webidl-conversions@7.0.3': {}
'@types/whatwg-mimetype@3.0.2': {}
'@types/whatwg-url@11.0.5':
dependencies:
'@types/webidl-conversions': 7.0.3
@@ -18218,7 +18237,7 @@ snapshots:
'@vercel/oidc@3.1.0': {}
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@15.5.12)(eslint@8.57.1)(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@15.5.12)(eslint@8.57.1)(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@babel/core': 7.28.5
'@babel/eslint-parser': 7.28.5(@babel/core@7.28.5)(eslint@8.57.1)
@@ -18238,7 +18257,7 @@ snapshots:
eslint-plugin-testing-library: 6.5.0(eslint@8.57.1)(typescript@5.9.3)
eslint-plugin-tsdoc: 0.2.17
eslint-plugin-unicorn: 51.0.1(eslint@8.57.1)
eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
prettier-plugin-packagejson: 2.5.20(prettier@3.8.1)
optionalDependencies:
'@next/eslint-plugin-next': 15.5.12
@@ -18264,7 +18283,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18
@@ -18276,16 +18295,16 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/eslint-plugin@1.6.10(eslint@8.57.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
'@vitest/eslint-plugin@1.6.10(eslint@8.57.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/utils': 8.56.1(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
optionalDependencies:
typescript: 5.9.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -19925,13 +19944,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
optionalDependencies:
'@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
- typescript
@@ -20465,6 +20484,18 @@ snapshots:
- encoding
- supports-color
happy-dom@20.8.9:
dependencies:
'@types/node': 25.4.0
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
entities: 7.0.1
whatwg-mimetype: 3.0.0
ws: 8.18.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -23898,13 +23929,13 @@ snapshots:
- '@emnapi/core'
- '@emnapi/runtime'
vitest-mock-extended@3.1.0(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
vitest-mock-extended@3.1.0(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
ts-essentials: 10.1.1(typescript@5.9.3)
typescript: 5.9.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
@@ -23929,6 +23960,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 25.4.0
happy-dom: 20.8.9
jsdom: 28.1.0(@noble/hashes@2.0.1)
transitivePeerDependencies:
- jiti
@@ -24039,6 +24071,8 @@ snapshots:
- uglify-js
optional: true
whatwg-mimetype@3.0.0: {}
whatwg-mimetype@5.0.0: {}
whatwg-url@14.2.0: