Compare commits

...

11 Commits

Author SHA1 Message Date
Dhruwang 36cc4baaa2 fix: suppress SonarCloud S5852 false positive on REM_REGEX
The regex uses a single character-class quantifier on trusted PostCSS
input — no backtracking risk. Add NOSONAR comment with rationale.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:57:41 +05:30
Dhruwang 701c6ec193 fix: simplify REM_REGEX to single character-class quantifier
Use ([\d.]+) instead of (\d[\d.]*) to eliminate any character overlap
between the leading \d and the quantified class. This is the simplest
possible form with zero backtracking risk (SonarCloud S5852).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:47:13 +05:30
Dhruwang 19b96c95cb fix: replace backtracking-prone regex with linear-time pattern
Rewrite REM_REGEX to use a single character-class quantifier (\d[\d.]*)
instead of nested quantifiers (\d+(\.\d+)?) to avoid super-linear
backtracking. Resolves SonarCloud security hotspot S5852.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 16:00:38 +05:30
Dhruwang bb2da4a362 fix: restore cn() tests reverted in wrong PR
The utils.test.ts revert belongs in PR #7720, not here.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 14:48:11 +05:30
Dhruwang cff3914d04 fix: remove control characters from regex and revert unrelated test changes
Remove invisible backspace (0x08) control characters from REM_REGEX in
postcss.config.cjs that were left over when \b word boundaries were
removed. Revert unrelated cn() test additions from utils.test.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 14:44:49 +05:30
Dhruwang 40ce92d584 Merge branch 'main' of https://github.com/formbricks/formbricks into fix/surveys-layer-properties-css-pollution 2026-04-13 14:32:18 +05:30
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
Marius 8bf7fe9d40 fix(surveys): strip @layer properties to prevent host page CSS pollution
Tailwind v4 emits an `@layer properties` block containing a bare
`*, :before, :after, ::backdrop` selector that resets all `--tw-*`
CSS custom properties on every element of the host page. Because CSS
`@layer` at-rules are globally scoped by the CSS spec, this block
cannot be confined to `#fbjs` and leaks into the host document,
breaking shadows, rings, transforms, and other Tailwind v4 utilities
on any site using the Formbricks SDK.

Add a PostCSS plugin `stripLayerProperties` that removes any
`@layer properties { ... }` block from the compiled CSS output.
The `@property` declarations already present in the same stylesheet
provide the same browser-compatibility fallback for supporting
browsers, so survey rendering is unaffected.

Fixes: https://github.com/formbricks/js/issues/46
2026-04-07 20:15:03 +02:00
54 changed files with 309 additions and 43 deletions
@@ -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,
+4
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
@@ -2021,6 +2024,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);
+1
View File
@@ -2127,6 +2127,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",
+1
View File
@@ -2127,6 +2127,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",
+1
View File
@@ -2127,6 +2127,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",
+1
View File
@@ -2127,6 +2127,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",
+1
View File
@@ -2127,6 +2127,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",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "今四半期",
"this_year": "今年",
"time_to_complete": "完了までの時間",
"ttc_survey_tooltip": "アンケートの平均完了時間。",
"ttc_tooltip": "フォームを完了するまでの平均時間。",
"unknown_question_type": "不明な質問の種類",
"use_personal_links": "個人リンクを使用",
+1
View File
@@ -2127,6 +2127,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",
+1
View File
@@ -2127,6 +2127,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",
+1
View File
@@ -2127,6 +2127,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",
+1
View File
@@ -2127,6 +2127,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",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "В этом квартале",
"this_year": "В этом году",
"time_to_complete": "Время на прохождение",
"ttc_survey_tooltip": "Среднее время прохождения опроса.",
"ttc_tooltip": "Среднее время на ответ на вопрос.",
"unknown_question_type": "Неизвестный тип вопроса",
"use_personal_links": "Использовать персональные ссылки",
+1
View File
@@ -2127,6 +2127,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",
+1
View File
@@ -2127,6 +2127,7 @@
"this_quarter": "本季度",
"this_year": "今年",
"time_to_complete": "完成时间",
"ttc_survey_tooltip": "完成调查的平均时间。",
"ttc_tooltip": "完成 本 问题 的 平均 时间",
"unknown_question_type": "未知 问题 类型",
"use_personal_links": "使用 个人 链接",
+1
View File
@@ -2127,6 +2127,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 ? (
@@ -98,14 +98,11 @@ describe("Users Lib", () => {
test("returns conflict error if user with email already exists", async () => {
(prisma.user.create as any).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed on the fields: (`email`)",
{
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
}
)
new Prisma.PrismaClientKnownRequestError("Unique constraint failed on the fields: (`email`)", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
})
);
const result = await createUser(
{ name: "Duplicate", email: "test@example.com", role: "member" },
@@ -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"
/>
+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": {
+2 -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:*",
+35 -4
View File
@@ -1,5 +1,6 @@
// basic regex -- [whitespace](number)(rem)[whitespace or ;]
const REM_REGEX = /\b(\d+(\.\d+)?)(rem)\b/gi;
// Matches a CSS numeric value followed by "rem" — e.g. "1rem", "1.5rem", "16rem".
// Single character-class + single quantifier: no nested quantifiers, no backtracking risk.
const REM_REGEX = /([\d.]+)(rem)/gi; // NOSONAR -- single character-class quantifier on trusted CSS input; no backtracking risk
const PROCESSED = Symbol("processed");
const remtoEm = (opts = {}) => {
@@ -26,6 +27,36 @@ const remtoEm = (opts = {}) => {
};
};
module.exports = {
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm()],
// Strips the `@layer properties { ... }` block that Tailwind v4 emits as a
// browser-compatibility fallback for `@property` declarations.
//
// Problem: CSS `@layer` at-rules are globally scoped by spec — they cannot be
// confined by a surrounding selector. Even though all other Formbricks survey
// styles are correctly scoped to `#fbjs`, the `@layer properties` block
// contains a bare `*, :before, :after, ::backdrop` selector that resets all
// `--tw-*` CSS custom properties on every element of the host page. This
// breaks shadows, rings, transforms, and other Tailwind utilities on any site
// that uses Tailwind v4 alongside the Formbricks SDK.
//
// The `@property` declarations already present in the same stylesheet cover
// the same browser-compatibility need for all supporting browsers, so removing
// `@layer properties` does not affect survey rendering.
//
// See: https://github.com/formbricks/js/issues/46
const stripLayerProperties = () => {
return {
postcssPlugin: "postcss-strip-layer-properties",
AtRule: {
layer: (atRule) => {
if (atRule.params === "properties") {
atRule.remove();
}
},
},
};
};
stripLayerProperties.postcss = true;
module.exports = {
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm(), stripLayerProperties()],
};
@@ -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("");
@@ -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;
}
@@ -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
+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;
};
+3
View File
@@ -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:*