Compare commits

...

1 Commits

Author SHA1 Message Date
Johannes fe07a0689c bring back 2nd merge state 2025-10-16 15:16:09 +02:00
98 changed files with 783 additions and 1525 deletions
@@ -105,7 +105,7 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
}),
buildCTAQuestion({
id: reusableQuestionIds[1],
subheader: t("templates.star_rating_survey_question_2_html"),
html: t("templates.star_rating_survey_question_2_html"),
logic: [
{
id: createId(),
@@ -322,7 +322,7 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
}),
buildCTAQuestion({
id: reusableQuestionIds[1],
subheader: t("templates.smileys_survey_question_2_html"),
html: t("templates.smileys_survey_question_2_html"),
logic: [
{
id: createId(),
@@ -14,7 +14,6 @@ import {
TIntegrationAirtableTables,
} from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
@@ -118,9 +117,7 @@ const renderQuestionSelection = ({
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">
{getTextContent(getLocalizedValue(question.headline, "default"))}
</span>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)}
@@ -11,7 +11,6 @@ import {
TIntegrationGoogleSheetsInput,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
import {
@@ -277,7 +276,7 @@ export const AddIntegrationModal = ({
}}
/>
<span className="ml-2 w-[30rem] truncate">
{getTextContent(getLocalizedValue(question.headline, "default"))}
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
@@ -14,7 +14,6 @@ import {
TIntegrationSlackInput,
} from "@formbricks/types/integration/slack";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import SlackLogo from "@/images/slacklogo.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -282,9 +281,7 @@ export const AddChannelMappingModal = ({
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2">
{getTextContent(getLocalizedValue(question.headline, "default"))}
</span>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
))}
@@ -6,7 +6,6 @@ import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
@@ -55,9 +54,7 @@ const getQuestionColumnsData = (
// Helper function to get localized question headline
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
return getTextContent(
getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default")
);
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
};
// Helper function to render choice ID badges
@@ -86,7 +83,7 @@ const getQuestionColumnsData = (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
<span className="truncate">
{getTextContent(getLocalizedValue(question.headline, "default")) +
{getLocalizedValue(question.headline, "default") +
" - " +
getLocalizedValue(matrixRow.label, "default")}
</span>
@@ -202,11 +199,9 @@ const getQuestionColumnsData = (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[question.type]}</span>
<span className="truncate">
{getTextContent(
getLocalizedValue(
recallToHeadline(question.headline, survey, false, "default"),
"default"
)
{getLocalizedValue(
recallToHeadline(question.headline, survey, false, "default"),
"default"
)}
</span>
</div>
@@ -4,7 +4,6 @@ import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
@@ -31,9 +30,7 @@ export const QuestionSummaryHeader = ({
<div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes(
getTextContent(
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
),
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"],
"@",
["text-lg"]
)}
@@ -33,7 +33,6 @@ import {
TSurveyQuestionTypeEnum,
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
@@ -260,7 +259,7 @@ export const getSurveySummaryDropOff = (
return {
questionId: question.id,
questionType: question.type,
headline: getTextContent(getLocalizedValue(question.headline, "default")),
headline: getLocalizedValue(question.headline, "default"),
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
impressions: impressionsArr[index] || 0,
dropOffCount: dropOffArr[index] || 0,
@@ -22,7 +22,7 @@ const mockSurvey: TSurvey = {
welcomeCard: {
enabled: false,
headline: { default: "Welcome" },
subheader: { default: "" },
html: { default: "" },
timeToFinish: false,
showResponseCount: false,
buttonLabel: { default: "Start" },
@@ -91,7 +91,7 @@ export const mockSurvey: TSurvey = {
createdBy: "cm98dg3xm000019hpubj39vfi",
status: "inProgress",
welcomeCard: {
subheader: {
html: {
default: "Thanks for providing your feedback - let's go!",
},
enabled: false,
@@ -168,7 +168,6 @@ export const mockSurvey: TSurvey = {
triggers: [],
segment: null,
followUps: mockFollowUps,
metadata: {},
};
export const mockContactQuestion: TSurveyContactInfoQuestion = {
@@ -62,7 +62,6 @@ const baseSurvey: TSurvey = {
autoComplete: null,
segment: null,
pin: null,
metadata: {},
};
const attributes: TAttributes = {
@@ -103,7 +102,7 @@ describe("replaceAttributeRecall", () => {
welcomeCard: {
enabled: true,
headline: { default: "Welcome, recall:name!" },
subheader: { default: "<p>Some content</p>" },
html: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
@@ -207,7 +206,7 @@ describe("replaceAttributeRecall", () => {
welcomeCard: {
enabled: true,
headline: { default: "Welcome!" },
subheader: { default: "<p>Some content</p>" },
html: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
+3 -8
View File
@@ -313,7 +313,6 @@ describe("Survey Builder", () => {
test("creates a consent question with required fields", () => {
const question = buildConsentQuestion({
headline: "Consent Question",
subheader: "",
label: "I agree to terms",
t: mockT,
});
@@ -321,7 +320,6 @@ describe("Survey Builder", () => {
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent Question" },
subheader: { default: "" },
label: { default: "I agree to terms" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
@@ -369,7 +367,6 @@ describe("Survey Builder", () => {
test("creates a CTA question with required fields", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
subheader: "",
buttonExternal: false,
t: mockT,
});
@@ -377,7 +374,6 @@ describe("Survey Builder", () => {
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA Question" },
subheader: { default: "" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
@@ -402,7 +398,7 @@ describe("Survey Builder", () => {
const question = buildCTAQuestion({
id: "custom-id",
headline: "CTA Question",
subheader: "<p>Click the button</p>",
html: "<p>Click the button</p>",
buttonLabel: "Click me",
buttonExternal: true,
buttonUrl: "https://example.com",
@@ -414,7 +410,7 @@ describe("Survey Builder", () => {
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "<p>Click the button</p>" });
expect(question.html).toEqual({ default: "<p>Click the button</p>" });
expect(question.buttonLabel).toEqual({ default: "Click me" });
expect(question.buttonExternal).toBe(true);
expect(question.buttonUrl).toBe("https://example.com");
@@ -427,7 +423,6 @@ describe("Survey Builder", () => {
test("handles external button with URL", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
subheader: "",
buttonExternal: true,
buttonUrl: "https://formbricks.com",
t: mockT,
@@ -538,7 +533,7 @@ describe("Helper Functions", () => {
const card = getDefaultWelcomeCard(mockT);
expect(card.enabled).toBe(false);
expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" });
expect(card.subheader).toEqual({ default: "templates.default_welcome_card_html" });
expect(card.html).toEqual({ default: "templates.default_welcome_card_html" });
expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" });
// boolean flags
expect(card.timeToFinish).toBe(false);
+6 -6
View File
@@ -218,7 +218,7 @@ export const buildConsentQuestion = ({
}: {
id?: string;
headline: string;
subheader: string;
subheader?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
@@ -229,7 +229,7 @@ export const buildConsentQuestion = ({
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.Consent,
subheader: createI18nString(subheader, []),
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
@@ -242,7 +242,7 @@ export const buildConsentQuestion = ({
export const buildCTAQuestion = ({
id,
headline,
subheader,
html,
buttonLabel,
buttonExternal,
backButtonLabel,
@@ -255,7 +255,7 @@ export const buildCTAQuestion = ({
id?: string;
headline: string;
buttonExternal: boolean;
subheader: string;
html?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
@@ -267,7 +267,7 @@ export const buildCTAQuestion = ({
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.CTA,
subheader: createI18nString(subheader, []),
html: html ? createI18nString(html, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
@@ -364,7 +364,7 @@ export const getDefaultWelcomeCard = (t: TFnType): TSurveyWelcomeCard => {
return {
enabled: false,
headline: createI18nString(t("templates.default_welcome_card_headline"), []),
subheader: createI18nString(t("templates.default_welcome_card_html"), []),
html: createI18nString(t("templates.default_welcome_card_html"), []),
buttonLabel: createI18nString(t("templates.default_welcome_card_button_label"), []),
timeToFinish: false,
showResponseCount: false,
+1 -6
View File
@@ -6,7 +6,6 @@ import {
TSurveyMetaFieldFilter,
} from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TTag } from "@formbricks/types/tags";
import {
DateRange,
@@ -19,8 +18,6 @@ import {
QuestionOptions,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { recallToHeadline } from "@/lib/utils/recall";
const conditionOptions = {
openText: ["is"],
@@ -83,9 +80,7 @@ export const generateQuestionAndFilterOptions = (
survey.questions.forEach((q) => {
if (Object.keys(conditionOptions).includes(q.type)) {
questionsOptions.push({
label: getTextContent(
getLocalizedValue(recallToHeadline(q.headline, survey, false, "default"), "default")
),
label: q.headline,
questionType: q.type,
type: OptionsType.QUESTIONS,
id: q.id,
+14 -17
View File
@@ -32,7 +32,7 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
questions: [
buildCTAQuestion({
id: reusableQuestionIds[0],
subheader: t("templates.card_abandonment_survey_question_1_html"),
html: t("templates.card_abandonment_survey_question_1_html"),
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
headline: t("templates.card_abandonment_survey_question_1_headline"),
required: false,
@@ -92,7 +92,6 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
id: reusableQuestionIds[1],
logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")],
headline: t("templates.card_abandonment_survey_question_6_headline"),
subheader: "",
required: false,
label: t("templates.card_abandonment_survey_question_6_label"),
t,
@@ -134,7 +133,7 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
questions: [
buildCTAQuestion({
id: reusableQuestionIds[0],
subheader: t("templates.site_abandonment_survey_question_1_html"),
html: t("templates.site_abandonment_survey_question_1_html"),
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
headline: t("templates.site_abandonment_survey_question_2_headline"),
required: false,
@@ -193,7 +192,6 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
id: reusableQuestionIds[1],
logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")],
headline: t("templates.site_abandonment_survey_question_7_headline"),
subheader: "",
required: false,
label: t("templates.site_abandonment_survey_question_7_label"),
t,
@@ -233,7 +231,7 @@ const productMarketFitSuperhuman = (t: TFnType): TTemplate => {
questions: [
buildCTAQuestion({
id: reusableQuestionIds[0],
subheader: t("templates.product_market_fit_superhuman_question_1_html"),
html: t("templates.product_market_fit_superhuman_question_1_html"),
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
headline: t("templates.product_market_fit_superhuman_question_1_headline"),
required: false,
@@ -411,7 +409,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
}),
buildCTAQuestion({
id: reusableQuestionIds[2],
subheader: t("templates.churn_survey_question_3_html"),
html: t("templates.churn_survey_question_3_html"),
logic: [createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isClicked")],
headline: t("templates.churn_survey_question_3_headline"),
required: true,
@@ -431,7 +429,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
}),
buildCTAQuestion({
id: reusableQuestionIds[4],
subheader: t("templates.churn_survey_question_5_html"),
html: t("templates.churn_survey_question_5_html"),
logic: [createJumpLogic(reusableQuestionIds[4], localSurvey.endings[0].id, "isClicked")],
headline: t("templates.churn_survey_question_5_headline"),
required: true,
@@ -709,7 +707,7 @@ const improveTrialConversion = (t: TFnType): TTemplate => {
}),
buildCTAQuestion({
id: reusableQuestionIds[3],
subheader: t("templates.improve_trial_conversion_question_4_html"),
html: t("templates.improve_trial_conversion_question_4_html"),
logic: [createJumpLogic(reusableQuestionIds[3], localSurvey.endings[0].id, "isClicked")],
headline: t("templates.improve_trial_conversion_question_4_headline"),
required: true,
@@ -804,7 +802,7 @@ const reviewPrompt = (t: TFnType): TTemplate => {
}),
buildCTAQuestion({
id: reusableQuestionIds[1],
subheader: t("templates.review_prompt_question_2_html"),
html: t("templates.review_prompt_question_2_html"),
logic: [createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isClicked")],
headline: t("templates.review_prompt_question_2_headline"),
required: true,
@@ -842,7 +840,7 @@ const interviewPrompt = (t: TFnType): TTemplate => {
buildCTAQuestion({
id: createId(),
headline: t("templates.interview_prompt_question_1_headline"),
subheader: t("templates.interview_prompt_question_1_html"),
html: t("templates.interview_prompt_question_1_html"),
buttonLabel: t("templates.interview_prompt_question_1_button_label"),
buttonUrl: "https://cal.com/johannes",
buttonExternal: true,
@@ -1345,7 +1343,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
}),
buildCTAQuestion({
id: reusableQuestionIds[2],
subheader: t("templates.feedback_box_question_3_html"),
html: t("templates.feedback_box_question_3_html"),
logic: [
createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isClicked"),
createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isSkipped"),
@@ -2024,7 +2022,6 @@ const marketSiteClarity = (t: TFnType): TTemplate => {
}),
buildCTAQuestion({
headline: t("templates.market_site_clarity_question_3_headline"),
subheader: "",
required: false,
buttonLabel: t("templates.market_site_clarity_question_3_button_label"),
buttonUrl: "https://app.formbricks.com/auth/signup",
@@ -2671,7 +2668,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
questions: [
buildCTAQuestion({
id: reusableQuestionIds[0],
subheader: t("templates.identify_sign_up_barriers_question_1_html"),
html: t("templates.identify_sign_up_barriers_question_1_html"),
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
headline: t("templates.identify_sign_up_barriers_question_1_headline"),
required: false,
@@ -2796,7 +2793,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
}),
buildCTAQuestion({
id: reusableQuestionIds[8],
subheader: t("templates.identify_sign_up_barriers_question_9_html"),
html: t("templates.identify_sign_up_barriers_question_9_html"),
headline: t("templates.identify_sign_up_barriers_question_9_headline"),
required: false,
buttonUrl: "https://app.formbricks.com/auth/signup",
@@ -2968,7 +2965,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
}),
buildCTAQuestion({
id: reusableQuestionIds[2],
subheader: t("templates.improve_newsletter_content_question_3_html"),
html: t("templates.improve_newsletter_content_question_3_html"),
headline: t("templates.improve_newsletter_content_question_3_headline"),
required: false,
buttonUrl: "https://formbricks.com",
@@ -3004,7 +3001,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
questions: [
buildCTAQuestion({
id: reusableQuestionIds[0],
subheader: t("templates.evaluate_a_product_idea_question_1_html"),
html: t("templates.evaluate_a_product_idea_question_1_html"),
headline: t("templates.evaluate_a_product_idea_question_1_headline"),
required: true,
buttonLabel: t("templates.evaluate_a_product_idea_question_1_button_label"),
@@ -3037,7 +3034,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
}),
buildCTAQuestion({
id: reusableQuestionIds[3],
subheader: t("templates.evaluate_a_product_idea_question_4_html"),
html: t("templates.evaluate_a_product_idea_question_4_html"),
headline: t("templates.evaluate_a_product_idea_question_4_headline"),
required: true,
buttonLabel: t("templates.evaluate_a_product_idea_question_4_button_label"),
+3 -7
View File
@@ -16,9 +16,7 @@ import {
TSurveyQuestion,
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { processResponseData } from "../responses";
import { getTodaysDateTimeFormatted } from "../time";
import { getFormattedDateTimeString } from "../utils/datetime";
@@ -661,13 +659,11 @@ export const extracMetadataKeys = (obj: TResponse["meta"]) => {
export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) => {
const metaDataFields = responses.length > 0 ? extracMetadataKeys(responses[0].meta) : [];
const modifiedSurvey = replaceHeadlineRecall(survey, "default");
const questions = modifiedSurvey.questions.map((question, idx) => {
const headline = getTextContent(getLocalizedValue(question.headline, "default")) ?? question.id;
const questions = survey.questions.map((question, idx) => {
const headline = getLocalizedValue(question.headline, "default") ?? question.id;
if (question.type === "matrix") {
return question.rows.map((row) => {
return `${idx + 1}. ${headline} - ${getTextContent(getLocalizedValue(row.label, "default"))}`;
return `${idx + 1}. ${headline} - ${getLocalizedValue(row.label, "default")}`;
});
} else if (
question.type === "multipleChoiceMulti" ||
+3 -6
View File
@@ -1,6 +1,5 @@
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
@@ -46,11 +45,9 @@ export const getQuestionResponseMapping = (
const answer = response.data[question.id];
questionResponseMapping.push({
question: getTextContent(
parseRecallInfo(
getLocalizedValue(question.headline, responseLanguageCode ?? "default"),
response.data
)
question: parseRecallInfo(
getLocalizedValue(question.headline, responseLanguageCode ?? "default"),
response.data
),
response: convertResponseValue(answer, question),
type: question.type,
+31 -23
View File
@@ -1,6 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import {
checkForEmptyFallBackValue,
@@ -21,11 +22,9 @@ import {
// Mock dependencies
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (obj: any, lang: string) => {
if (typeof obj === "string") return obj;
if (!obj) return "";
return obj[lang] || obj["default"] || "";
},
getLocalizedValue: vi.fn().mockImplementation((obj, lang) => {
return typeof obj === "string" ? obj : obj[lang] || obj["default"] || "";
}),
}));
vi.mock("@/lib/pollyfills/structuredClone", () => ({
@@ -143,12 +142,12 @@ describe("recall utility functions", () => {
describe("recallToHeadline", () => {
test("converts recall pattern to headline format without slash", () => {
const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
const survey = {
const survey: TSurvey = {
id: "test-survey",
questions: [{ id: "product", headline: { en: "Product Question" } }],
questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
} as unknown as TSurvey;
const result = recallToHeadline(headline, survey, false, "en");
expect(result.en).toBe("How do you like @Product Question?");
@@ -156,12 +155,12 @@ describe("recall utility functions", () => {
test("converts recall pattern to headline format with slash", () => {
const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" };
const survey = {
const survey: TSurvey = {
id: "test-survey",
questions: [{ id: "product", headline: { en: "Product Question" } }],
questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
} as unknown as TSurvey;
const result = recallToHeadline(headline, survey, true, "en");
expect(result.en).toBe("Rate /Product Question\\");
@@ -205,12 +204,15 @@ describe("recall utility functions", () => {
const headline = {
en: "This is #recall:inner/fallback:fallback2#",
};
const survey = {
const survey: TSurvey = {
id: "test-survey",
questions: [{ id: "inner", headline: { en: "Inner with @outer" } }],
questions: [
{ id: "inner", headline: { en: "Inner with @outer" } },
{ id: "inner", headline: { en: "Inner value" } },
] as unknown as TSurveyQuestion[],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
} as unknown as TSurvey;
const result = recallToHeadline(headline, survey, false, "en");
expect(result.en).toBe("This is @Inner with @outer");
@@ -240,14 +242,16 @@ describe("recall utility functions", () => {
describe("checkForEmptyFallBackValue", () => {
test("identifies question with empty fallback value", () => {
const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
const survey = {
const survey: TSurvey = {
questions: [
{
id: "q1",
headline: questionHeadline,
},
],
} as any;
] as unknown as TSurveyQuestion[],
} as unknown as TSurvey;
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBe(survey.questions[0]);
@@ -255,15 +259,17 @@ describe("recall utility functions", () => {
test("identifies question with empty fallback in subheader", () => {
const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
const survey = {
const survey: TSurvey = {
questions: [
{
id: "q1",
headline: { en: "Normal question" },
subheader: questionSubheader,
},
],
} as any;
] as unknown as TSurveyQuestion[],
} as unknown as TSurvey;
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionSubheader.en);
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBe(survey.questions[0]);
@@ -271,14 +277,16 @@ describe("recall utility functions", () => {
test("returns null when no empty fallback values are found", () => {
const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
const survey = {
const survey: TSurvey = {
questions: [
{
id: "q1",
headline: questionHeadline,
},
],
} as any;
] as unknown as TSurveyQuestion[],
} as unknown as TSurvey;
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBeNull();
+3 -22
View File
@@ -1,6 +1,5 @@
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
@@ -60,11 +59,7 @@ const getRecallItemLabel = <T extends TSurvey>(
if (isHiddenField) return recallItemId;
const surveyQuestion = survey.questions.find((question) => question.id === recallItemId);
if (surveyQuestion) {
const headline = getLocalizedValue(surveyQuestion.headline, languageCode);
// Strip HTML tags to prevent raw HTML from showing in nested recalls
return headline ? getTextContent(headline) : headline;
}
if (surveyQuestion) return surveyQuestion.headline[languageCode];
const variable = survey.variables?.find((variable) => variable.id === recallItemId);
if (variable) return variable.name;
@@ -131,7 +126,8 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T
for (const question of survey.questions) {
if (
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language)))
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language))) ||
("html" in question && doesTextHaveRecall(getLocalizedValue(question.html, language)))
) {
return question;
}
@@ -271,18 +267,3 @@ export const parseRecallInfo = (
return modifiedText;
};
export const getTextContentWithRecallTruncated = (text: string, maxLength: number = 25): string => {
const cleanText = getTextContent(text).replaceAll(/\s+/g, " ").trim();
if (cleanText.length <= maxLength) {
return replaceRecallInfoWithUnderline(cleanText);
}
const recalledCleanText = replaceRecallInfoWithUnderline(cleanText);
const start = recalledCleanText.slice(0, 10);
const end = recalledCleanText.slice(-10);
return `${start}...${end}`;
};
@@ -4,7 +4,6 @@ import { useTranslate } from "@tolgee/react";
import { CheckCircle2Icon, ChevronsDownIcon, XCircleIcon } from "lucide-react";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { parseRecallInfo } from "@/lib/utils/recall";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
@@ -73,16 +72,12 @@ export const QuestionSkip = ({
{skippedQuestions?.map((questionId) => {
return (
<p className="my-2" key={questionId}>
{getTextContent(
parseRecallInfo(
getLocalizedValue(
questions.find((question) => question.id === questionId)?.headline ?? {
default: "",
},
"default"
),
responseData
)
{parseRecallInfo(
getLocalizedValue(
questions.find((question) => question.id === questionId)!.headline,
"default"
),
responseData
)}
</p>
);
@@ -112,16 +107,12 @@ export const QuestionSkip = ({
skippedQuestions.map((questionId) => {
return (
<p className="my-2" key={questionId}>
{getTextContent(
parseRecallInfo(
getLocalizedValue(
questions.find((question) => question.id === questionId)?.headline ?? {
default: "",
},
"default"
),
responseData
)
{parseRecallInfo(
getLocalizedValue(
questions.find((question) => question.id === questionId)!.headline,
"default"
),
responseData
)}
</p>
);
@@ -4,7 +4,6 @@ import { useTranslate } from "@tolgee/react";
import { CheckCircle2Icon } from "lucide-react";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { parseRecallInfo } from "@/lib/utils/recall";
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
@@ -78,15 +77,13 @@ export const SingleResponseCardBody = ({
<div key={`${question.id}`}>
{isValidValue(response.data[question.id]) ? (
<div>
<p className="mb-1 text-sm font-semibold text-slate-600">
<p className="mb-1 text-sm text-slate-500">
{formatTextWithSlashes(
getTextContent(
parseRecallInfo(
getLocalizedValue(question.headline, "default"),
response.data,
response.variables,
true
)
parseRecallInfo(
getLocalizedValue(question.headline, "default"),
response.data,
response.variables,
true
)
)}
</p>
@@ -54,7 +54,7 @@ describe("ResponseFeed", () => {
welcomeCard: {
enabled: false,
headline: "",
subheader: "",
html: "",
},
displayLimit: null,
autoComplete: null,
@@ -1,14 +1,14 @@
"use client";
import { useTranslate } from "@tolgee/react";
import DOMPurify from "dompurify";
import type { Dispatch, SetStateAction } from "react";
import { useMemo } from "react";
import type { TI18nString, TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { getTextContent, isValidHTML } from "@formbricks/types/surveys/validation";
import type { TI18nString, TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { extractLanguageCodes, isLabelValidForAllLanguages } from "@/lib/i18n/utils";
import { md } from "@/lib/markdownIt";
import { recallToHeadline } from "@/lib/utils/recall";
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
import { Editor } from "@/modules/ui/components/editor";
import { LanguageIndicator } from "./language-indicator";
@@ -25,21 +25,17 @@ interface LocalizedEditorProps {
setFirstRender?: Dispatch<SetStateAction<boolean>>;
locale: TUserLocale;
questionId: string;
isCard?: boolean; // Flag to indicate if this is a welcome/ending card
autoFocus?: boolean;
}
const checkIfValueIsIncomplete = (
id: string,
isInvalid: boolean,
surveyLanguageCodes: TSurveyLanguage[],
surveyLanguageCodes: string[],
value?: TI18nString
) => {
const labelIds = ["subheader", "headline", "html"];
const labelIds = ["subheader"];
if (value === undefined) return false;
const isDefaultIncomplete = labelIds.includes(id)
? getTextContent(value.default ?? "").trim() !== ""
: false;
const isDefaultIncomplete = labelIds.includes(id) ? value.default.trim() !== "" : false;
return isInvalid && !isLabelValidForAllLanguages(value, surveyLanguageCodes) && isDefaultIncomplete;
};
@@ -56,76 +52,38 @@ export function LocalizedEditor({
setFirstRender,
locale,
questionId,
isCard,
autoFocus,
}: Readonly<LocalizedEditorProps>) {
const { t } = useTranslate();
const surveyLanguageCodes = useMemo(
() => extractLanguageCodes(localSurvey.languages),
[localSurvey.languages]
);
const isInComplete = useMemo(
() => checkIfValueIsIncomplete(id, isInvalid, localSurvey.languages, value),
[id, isInvalid, localSurvey.languages, value]
() => checkIfValueIsIncomplete(id, isInvalid, surveyLanguageCodes, value),
[id, isInvalid, surveyLanguageCodes, value]
);
return (
<div className="relative w-full">
<Editor
id={id}
disableLists
excludedToolbarItems={["blockType"]}
firstRender={firstRender}
autoFocus={autoFocus}
getText={() => {
const text = value ? (value[selectedLanguageCode] ?? "") : "";
let html = md.render(text);
// For backwards compatibility: wrap plain text headlines in <strong> tags
// This ensures old surveys maintain semibold styling when converted to HTML
if (id === "headline" && text && !isValidHTML(text)) {
// Use [\s\S]*? to match any character including newlines
html = html.replaceAll(/<p>([\s\S]*?)<\/p>/g, "<p><strong>$1</strong></p>");
}
return html;
}}
key={`${questionId}-${id}-${selectedLanguageCode}`}
getText={() => md.render(value ? (value[selectedLanguageCode] ?? "") : "")}
key={`${questionIdx}-${selectedLanguageCode}`}
setFirstRender={setFirstRender}
setText={(v: string) => {
// Check if the question still exists before updating
const currentQuestion = localSurvey.questions[questionIdx];
// if this is a card, we wanna check if the card exists in the localSurvey
if (isCard) {
const isWelcomeCard = questionIdx === -1;
const isEndingCard = questionIdx >= localSurvey.questions.length;
// For ending cards, check if the field exists before updating
if (isEndingCard) {
const ending = localSurvey.endings.find((ending) => ending.id === questionId);
// If the field doesn't exist on the ending card, don't create it
if (!ending || ending[id] === undefined) {
return;
}
}
// For welcome cards, check if it exists
if (isWelcomeCard && !localSurvey.welcomeCard) {
if (localSurvey.questions[questionIdx] || questionIdx === -1) {
const translatedHtml = {
...value,
[selectedLanguageCode]: v,
};
if (questionIdx === -1) {
// welcome card
updateQuestion({ html: translatedHtml });
return;
}
const translatedContent = {
...(value ?? {}),
[selectedLanguageCode]: v,
};
updateQuestion({ [id]: translatedContent });
return;
}
if (currentQuestion && currentQuestion[id] !== undefined) {
const translatedContent = {
...(value ?? {}),
[selectedLanguageCode]: v,
};
updateQuestion(questionIdx, { [id]: translatedContent });
updateQuestion(questionIdx, { html: translatedHtml });
}
}}
localSurvey={localSurvey}
@@ -145,9 +103,14 @@ export function LocalizedEditor({
{value && selectedLanguageCode !== "default" && value.default ? (
<div className="mt-1 flex text-xs text-gray-500">
<strong>{t("environments.project.languages.translate")}:</strong>
<span className="ml-1">
{getTextContent(recallToHeadline(value, localSurvey, false, "default").default ?? "")}
</span>
<span
className="fb-htmlbody ml-1" // styles are in global.css
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
recallToHeadline(value, localSurvey, false, "default").default ?? ""
),
}}
/>
</div>
) : null}
</div>
@@ -1,4 +1,4 @@
import { Container } from "@react-email/components";
import { Text } from "@react-email/components";
import { cn } from "@/lib/cn";
interface QuestionHeaderProps {
@@ -10,13 +10,11 @@ interface QuestionHeaderProps {
export function QuestionHeader({ headline, subheader, className }: QuestionHeaderProps): React.JSX.Element {
return (
<>
<Container className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
<div dangerouslySetInnerHTML={{ __html: headline }} />
</Container>
<Text className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
{headline}
</Text>
{subheader && (
<Container className="text-question-color m-0 mt-2 block p-0 text-sm font-normal leading-6">
<div dangerouslySetInnerHTML={{ __html: subheader }} />
</Container>
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">{subheader}</Text>
)}
</>
);
@@ -94,7 +94,16 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.Consent:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
<Container className="text-question-color m-0 text-sm font-normal leading-6">
<div
className="m-0 p-0"
dangerouslySetInnerHTML={{
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
}}
/>
</Container>
<Container className="border-input-border-color bg-input-color rounded-custom m-0 mt-4 block w-full max-w-none border border-solid p-4 font-medium text-slate-800">
<Text className="text-question-color m-0 inline-block">
{getLocalizedValue(firstQuestion.label, defaultLanguageCode)}
@@ -172,7 +181,16 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.CTA:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
<Container className="text-question-color ml-0 mt-2 text-sm font-normal leading-6">
<div
className="m-0 p-0"
dangerouslySetInnerHTML={{
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
}}
/>
</Container>
<Container className="mx-0 mt-4 max-w-none">
{!firstQuestion.required && (
<EmailButton
@@ -2,7 +2,7 @@ import { useTranslate } from "@tolgee/react";
import { ReactNode } from "react";
import { toast } from "react-hot-toast";
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
@@ -53,7 +53,7 @@ export const FallbackInput = ({
return (
<div key={recallItem.id} className="flex flex-col gap-1">
<Label htmlFor={inputId} className="text-xs font-medium text-slate-700">
{getTextContentWithRecallTruncated(recallItem.label)}
{replaceRecallInfoWithUnderline(recallItem.label)}
</Label>
<Input
className="h-9 bg-white"
@@ -3,7 +3,6 @@
import { useTranslate } from "@tolgee/react";
import { ReactNode, useMemo } from "react";
import { TI18nString, TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getEnabledLanguages } from "@/lib/i18n/utils";
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
@@ -83,7 +82,7 @@ export const MultiLangWrapper = ({
{usedLanguageCode !== "default" && value && typeof value["default"] !== "undefined" && (
<div className="mt-1 text-xs text-slate-500">
<strong>{t("environments.project.languages.translate")}:</strong>{" "}
{getTextContent(recallToHeadline(value, localSurvey, false, "default")["default"] ?? "")}
{recallToHeadline(value, localSurvey, false, "default")["default"]}
</div>
)}
@@ -11,26 +11,7 @@ import {
import { RecallItemSelect } from "./recall-item-select";
vi.mock("@/lib/utils/recall", () => ({
getTextContentWithRecallTruncated: vi.fn((text: string, maxLength: number = 25) => {
// Remove all HTML tags by repeatedly applying the regex
let cleaned = text;
let prev;
do {
prev = cleaned;
cleaned = cleaned.replace(/<[^>]*>/g, "");
} while (cleaned !== prev);
cleaned = cleaned.replace(/\s+/g, " ").trim();
const withRecallReplaced = cleaned.replace(/#recall:[^#]+#/g, "___");
if (withRecallReplaced.length <= maxLength) {
return withRecallReplaced;
}
const start = withRecallReplaced.slice(0, 10);
const end = withRecallReplaced.slice(-10);
return `${start}...${end}`;
}),
replaceRecallInfoWithUnderline: vi.fn((text) => `_${text}_`),
}));
describe("RecallItemSelect", () => {
@@ -97,15 +78,15 @@ describe("RecallItemSelect", () => {
/>
);
expect(screen.getByText("Question 1")).toBeInTheDocument();
expect(screen.getByText("Question 2")).toBeInTheDocument();
expect(screen.getByText("hidden1")).toBeInTheDocument();
expect(screen.getByText("hidden2")).toBeInTheDocument();
expect(screen.getByText("Variable 1")).toBeInTheDocument();
expect(screen.getByText("Variable 2")).toBeInTheDocument();
expect(screen.getByText("_Question 1_")).toBeInTheDocument();
expect(screen.getByText("_Question 2_")).toBeInTheDocument();
expect(screen.getByText("_hidden1_")).toBeInTheDocument();
expect(screen.getByText("_hidden2_")).toBeInTheDocument();
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
expect(screen.queryByText("Current Question")).not.toBeInTheDocument();
expect(screen.queryByText("File Upload Question")).not.toBeInTheDocument();
expect(screen.queryByText("_Current Question_")).not.toBeInTheDocument();
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
});
test("do not render questions if questionId is 'start' (welcome card)", async () => {
@@ -121,16 +102,16 @@ describe("RecallItemSelect", () => {
/>
);
expect(screen.queryByText("Question 1")).not.toBeInTheDocument();
expect(screen.queryByText("Question 2")).not.toBeInTheDocument();
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
expect(screen.queryByText("_Question 2_")).not.toBeInTheDocument();
expect(screen.getByText("hidden1")).toBeInTheDocument();
expect(screen.getByText("hidden2")).toBeInTheDocument();
expect(screen.getByText("Variable 1")).toBeInTheDocument();
expect(screen.getByText("Variable 2")).toBeInTheDocument();
expect(screen.getByText("_hidden1_")).toBeInTheDocument();
expect(screen.getByText("_hidden2_")).toBeInTheDocument();
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
expect(screen.queryByText("Current Question")).not.toBeInTheDocument();
expect(screen.queryByText("File Upload Question")).not.toBeInTheDocument();
expect(screen.queryByText("_Current Question_")).not.toBeInTheDocument();
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
});
test("filters recall items based on search input", async () => {
@@ -150,9 +131,9 @@ describe("RecallItemSelect", () => {
const searchInput = screen.getByPlaceholderText("Search options");
await user.type(searchInput, "Variable");
expect(screen.getByText("Variable 1")).toBeInTheDocument();
expect(screen.getByText("Variable 2")).toBeInTheDocument();
expect(screen.queryByText("Question 1")).not.toBeInTheDocument();
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
});
test("calls addRecallItem and setShowRecallItemSelect when item is selected", async () => {
@@ -169,7 +150,7 @@ describe("RecallItemSelect", () => {
/>
);
const firstItem = screen.getByText("Question 1");
const firstItem = screen.getByText("_Question 1_");
await user.click(firstItem);
expect(mockAddRecallItem).toHaveBeenCalledWith({
@@ -195,8 +176,8 @@ describe("RecallItemSelect", () => {
/>
);
expect(screen.queryByText("Question 1")).not.toBeInTheDocument();
expect(screen.getByText("Question 2")).toBeInTheDocument();
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
expect(screen.getByText("_Question 2_")).toBeInTheDocument();
});
test("shows 'No recall items found' when search has no results", async () => {
@@ -22,7 +22,7 @@ import {
TSurveyQuestionId,
TSurveyRecallItem,
} from "@formbricks/types/surveys/types";
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
import {
DropdownMenu,
DropdownMenuContent,
@@ -130,7 +130,7 @@ export const RecallItemSelect = ({
});
return filteredQuestions;
}, [localSurvey.questions, questionId, recallItemIds, selectedLanguageCode]);
}, [localSurvey.questions, questionId, recallItemIds]);
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...variableRecallItems].filter(
@@ -143,6 +143,11 @@ export const RecallItemSelect = ({
);
}, [surveyQuestionRecallItems, hiddenFieldRecallItems, variableRecallItems, searchValue]);
// function to modify headline (recallInfo to corresponding headline)
const getRecallLabel = (label: string): string => {
return replaceRecallInfoWithUnderline(label);
};
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
switch (recallItem.type) {
case "question":
@@ -207,7 +212,7 @@ export const RecallItemSelect = ({
}}>
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{getTextContentWithRecallTruncated(recallItem.label)}
{getRecallLabel(recallItem.label)}
</p>
</DropdownMenuItem>
);
@@ -162,26 +162,6 @@ vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ children, tooltipContent }: any) => (
<span data-tooltip={tooltipContent}>{children}</span>
),
TooltipProvider: ({ children }: any) => <div>{children}</div>,
Tooltip: ({ children }: any) => <div>{children}</div>,
TooltipTrigger: ({ children, asChild }: any) => (asChild ? children : <div>{children}</div>),
TooltipContent: ({ children }: any) => <div>{children}</div>,
}));
// Mock LocalizedEditor to render as a simple input for testing
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
LocalizedEditor: ({ id, value, updateQuestion, questionIdx }: any) => (
<input
data-testid={id}
id={id}
defaultValue={value?.default || ""}
onChange={(e) => {
if (updateQuestion) {
updateQuestion(questionIdx, { [id]: { default: e.target.value } });
}
}}
/>
),
}));
// Mock component imports to avoid rendering real components that might access server-side resources
@@ -300,7 +280,7 @@ const mockSurvey = {
welcomeCard: {
enabled: true,
headline: createI18nString("Welcome", ["en", "fr"]),
subheader: createI18nString("<p>Welcome to our survey</p>", ["en", "fr"]),
html: createI18nString("<p>Welcome to our survey</p>", ["en", "fr"]),
buttonLabel: createI18nString("Start", ["en", "fr"]),
fileUrl: "",
videoUrl: "",
@@ -11,14 +11,12 @@ import {
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyQuestionTypeEnum,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
import { recallToHeadline } from "@/lib/utils/recall";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { MultiLangWrapper } from "@/modules/survey/components/question-form-input/components/multi-lang-wrapper";
import { RecallWrapper } from "@/modules/survey/components/question-form-input/components/recall-wrapper";
import { Button } from "@/modules/ui/components/button";
@@ -57,9 +55,6 @@ interface QuestionFormInputProps {
locale: TUserLocale;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
isStorageConfigured: boolean;
autoFocus?: boolean;
firstRender?: boolean;
setFirstRender?: (value: boolean) => void;
}
export const QuestionFormInput = ({
@@ -82,9 +77,6 @@ export const QuestionFormInput = ({
locale,
onKeyDown,
isStorageConfigured = true,
autoFocus,
firstRender: externalFirstRender,
setFirstRender: externalSetFirstRender,
}: QuestionFormInputProps) => {
const { t } = useTranslate();
const defaultLanguageCode =
@@ -282,132 +274,13 @@ export const QuestionFormInput = ({
const debouncedHandleUpdate = useMemo(() => debounce((value) => handleUpdate(value), 100), [handleUpdate]);
const [animationParent] = useAutoAnimate();
const [internalFirstRender, setInternalFirstRender] = useState(true);
// Use external firstRender state if provided, otherwise use internal state
const firstRender = externalFirstRender ?? internalFirstRender;
const setFirstRender = externalSetFirstRender ?? setInternalFirstRender;
const renderRemoveDescriptionButton = useMemo(() => {
if (id !== "subheader") return false;
return !!question?.subheader || (endingCard?.type === "endScreen" && !!endingCard?.subheader);
const renderRemoveDescriptionButton = () => {
if (
question &&
(question.type === TSurveyQuestionTypeEnum.CTA || question.type === TSurveyQuestionTypeEnum.Consent)
) {
return false;
}
if (id === "subheader") {
return !!question?.subheader || (endingCard?.type === "endScreen" && !!endingCard?.subheader);
}
return false;
};
const useRichTextEditor = id === "headline" || id === "subheader" || id === "html";
// For rich text editor fields, we need either updateQuestion or updateSurvey
if (useRichTextEditor && !updateQuestion && !updateSurvey) {
throw new Error("Either updateQuestion or updateSurvey must be provided");
}
if (useRichTextEditor) {
return (
<div className="w-full">
{label && (
<div className="mb-2 mt-3">
<Label htmlFor={id}>{label}</Label>
</div>
)}
<div className="flex flex-col gap-4" ref={animationParent}>
{showImageUploader && id === "headline" && (
<FileInput
id="question-image"
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={localSurvey.environmentId}
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
if (url) {
const update =
fileType === "video"
? { videoUrl: url[0], imageUrl: "" }
: { imageUrl: url[0], videoUrl: "" };
if ((isWelcomeCard || isEndingCard) && updateSurvey) {
updateSurvey(update);
} else if (updateQuestion) {
updateQuestion(questionIdx, update);
}
}
}}
fileUrl={getFileUrl()}
videoUrl={getVideoUrl()}
isVideoAllowed={true}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
)}
<div className="flex w-full items-start gap-2">
<div className="flex-1">
<LocalizedEditor
key={`${questionId}-${id}-${selectedLanguageCode}`}
id={id}
value={value}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={(isWelcomeCard || isEndingCard ? updateSurvey : updateQuestion)!}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
firstRender={firstRender}
setFirstRender={setFirstRender}
locale={locale}
questionId={questionId}
isCard={isWelcomeCard || isEndingCard}
autoFocus={autoFocus}
/>
</div>
{id === "headline" && !isWelcomeCard && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_photo_or_video")}>
<Button
variant="secondary"
size="icon"
aria-label="Toggle image uploader"
data-testid="toggle-image-uploader-button"
onClick={(e) => {
e.preventDefault();
setShowImageUploader((prev) => !prev);
}}>
<ImagePlusIcon />
</Button>
</TooltipRenderer>
)}
{id === "subheader" && renderRemoveDescriptionButton() && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
<Button
variant="secondary"
size="icon"
aria-label="Remove description"
onClick={(e) => {
e.preventDefault();
if (updateSurvey) {
updateSurvey({ subheader: undefined });
}
if (updateQuestion) {
updateQuestion(questionIdx, { subheader: undefined });
}
}}>
<TrashIcon />
</Button>
</TooltipRenderer>
)}
</div>
</div>
</div>
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [endingCard?.type, id, question?.subheader]);
return (
<div className="w-full">
@@ -547,7 +420,7 @@ export const QuestionFormInput = ({
</Button>
</TooltipRenderer>
)}
{renderRemoveDescriptionButton() ? (
{renderRemoveDescriptionButton ? (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
<Button
variant="secondary"
@@ -556,14 +429,12 @@ export const QuestionFormInput = ({
className="ml-2"
onClick={(e) => {
e.preventDefault();
if (updateSurvey) {
updateSurvey({ subheader: undefined });
}
if (updateQuestion) {
updateQuestion(questionIdx, { subheader: undefined });
}
if (updateSurvey) {
updateSurvey({ subheader: undefined });
}
}}>
<TrashIcon />
</Button>
@@ -93,7 +93,6 @@ export const AddressQuestionForm = ({
]);
const [parent] = useAutoAnimate();
return (
<form>
<QuestionFormInput
@@ -108,7 +107,6 @@ export const AddressQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div ref={parent}>
@@ -127,7 +125,6 @@ export const AddressQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -63,7 +63,6 @@ export const CalQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div>
{question.subheader !== undefined && (
@@ -81,7 +80,6 @@ export const CalQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -97,6 +95,7 @@ export const CalQuestionForm = ({
subheader: createI18nString("", surveyLanguageCodes),
});
}}>
{" "}
<PlusIcon className="mr-1 h-4 w-4" />
{t("environments.surveys.edit.add_description")}
</Button>
@@ -5,11 +5,15 @@ import { TUserLocale } from "@formbricks/types/user";
import { ConsentQuestionForm } from "./consent-question-form";
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: ({ label, id }: { label: string; id: string }) => (
<div data-testid="question-form-input" data-field-id={id}>
{label}
</div>
),
QuestionFormInput: ({ label }: { label: string }) => <div data-testid="question-form-input">{label}</div>,
}));
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
LocalizedEditor: ({ id }: { id: string }) => <div data-testid="localized-editor">{id}</div>,
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: string }) => <div data-testid="label">{children}</div>,
}));
describe("ConsentQuestionForm", () => {
@@ -57,18 +61,9 @@ describe("ConsentQuestionForm", () => {
);
const questionFormInputs = screen.getAllByTestId("question-form-input");
expect(questionFormInputs).toHaveLength(3);
// Check headline field
expect(questionFormInputs[0]).toHaveTextContent("environments.surveys.edit.question*");
expect(questionFormInputs[0]).toHaveAttribute("data-field-id", "headline");
// Check html (description) field
expect(questionFormInputs[1]).toHaveTextContent("common.description");
expect(questionFormInputs[1]).toHaveAttribute("data-field-id", "subheader");
// Check label (checkbox label) field
expect(questionFormInputs[2]).toHaveTextContent("environments.surveys.edit.checkbox_label*");
expect(questionFormInputs[2]).toHaveAttribute("data-field-id", "label");
expect(screen.getByTestId("label")).toHaveTextContent("common.description");
expect(screen.getByTestId("localized-editor")).toHaveTextContent("subheader");
expect(questionFormInputs[1]).toHaveTextContent("environments.surveys.edit.checkbox_label*");
});
});
@@ -1,10 +1,12 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { type JSX } from "react";
import { type JSX, useState } from "react";
import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Label } from "@/modules/ui/components/label";
interface ConsentQuestionFormProps {
localSurvey: TSurvey;
@@ -29,45 +31,57 @@ export const ConsentQuestionForm = ({
locale,
isStorageConfigured = true,
}: ConsentQuestionFormProps): JSX.Element => {
const [firstRender, setFirstRender] = useState(true);
const { t } = useTranslate();
// Common props shared across all QuestionFormInput components
const commonInputProps = {
localSurvey,
questionIdx,
isInvalid,
updateQuestion,
selectedLanguageCode,
setSelectedLanguageCode,
locale,
isStorageConfigured,
};
return (
<form>
<QuestionFormInput
{...commonInputProps}
id="headline"
value={question.headline}
label={t("environments.surveys.edit.question") + "*"}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
value={question.headline}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
<div className="mt-3">
<QuestionFormInput
{...commonInputProps}
id="subheader"
value={question.subheader}
label={t("common.description")}
/>
<Label htmlFor="subheader">{t("common.description")}</Label>
<div className="mt-2">
<LocalizedEditor
id="subheader"
value={question.html}
localSurvey={localSurvey}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
firstRender={firstRender}
setFirstRender={setFirstRender}
questionIdx={questionIdx}
locale={locale}
questionId={question.id}
/>
</div>
</div>
<QuestionFormInput
{...commonInputProps}
id="label"
label={t("environments.surveys.edit.checkbox_label") + "*"}
placeholder="I agree to the terms and conditions"
value={question.label}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
</form>
);
@@ -82,6 +82,7 @@ export const ContactInfoQuestionForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [question.firstName, question.lastName, question.email, question.phone, question.company]);
// Auto animate
const [parent] = useAutoAnimate();
return (
@@ -98,7 +99,6 @@ export const ContactInfoQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div ref={parent}>
@@ -117,7 +117,6 @@ export const ContactInfoQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -9,11 +9,11 @@ vi.mock("@formkit/auto-animate/react", () => ({
}));
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: ({ id }: { id: string }) => (
<div data-testid="question-form-input" data-field-id={id}>
QuestionFormInput-{id}
</div>
),
QuestionFormInput: () => <div data-testid="question-form-input">QuestionFormInput</div>,
}));
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
LocalizedEditor: () => <div data-testid="localized-editor">LocalizedEditor</div>,
}));
vi.mock("@/modules/ui/components/options-switch", () => ({
@@ -69,13 +69,8 @@ describe("CTAQuestionForm", () => {
);
const questionFormInputs = screen.getAllByTestId("question-form-input");
expect(questionFormInputs.length).toBe(3);
// Check that we have headline, html (description), and buttonLabel fields
expect(questionFormInputs[0]).toHaveAttribute("data-field-id", "headline");
expect(questionFormInputs[1]).toHaveAttribute("data-field-id", "subheader");
expect(questionFormInputs[2]).toHaveAttribute("data-field-id", "buttonLabel");
expect(questionFormInputs.length).toBe(2);
expect(screen.getByTestId("localized-editor")).toBeInTheDocument();
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
});
});
@@ -1,9 +1,11 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { type JSX } from "react";
import { type JSX, useState } from "react";
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
@@ -42,9 +44,10 @@ export const CTAQuestionForm = ({
},
{ value: "external", label: t("environments.surveys.edit.button_to_link_to_external_url") },
];
const [firstRender, setFirstRender] = useState(true);
const [parent] = useAutoAnimate();
return (
<form>
<form ref={parent}>
<QuestionFormInput
id="headline"
value={question.headline}
@@ -57,23 +60,26 @@ export const CTAQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div className="mt-3">
<QuestionFormInput
id="subheader"
value={question.subheader}
label={t("common.description")}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
<Label htmlFor="subheader">{t("common.description")}</Label>
<div className="mt-2">
<LocalizedEditor
id="subheader"
value={question.html}
localSurvey={localSurvey}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
firstRender={firstRender}
setFirstRender={setFirstRender}
questionIdx={questionIdx}
locale={locale}
questionId={question.id}
/>
</div>
</div>
<div className="mt-3">
<OptionsSwitch
@@ -3,7 +3,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { PlusIcon } from "lucide-react";
import { type JSX } from "react";
import type { JSX } from "react";
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
@@ -53,7 +53,6 @@ export const DateQuestionForm = ({
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const { t } = useTranslate();
const [parent] = useAutoAnimate();
return (
<form>
<QuestionFormInput
@@ -68,7 +67,6 @@ export const DateQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div ref={parent}>
{question.subheader !== undefined && (
@@ -77,7 +75,7 @@ export const DateQuestionForm = ({
<QuestionFormInput
id="subheader"
value={question.subheader}
label={t("common.description")}
label={t("environments.surveys.edit.description")}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
@@ -86,7 +84,6 @@ export const DateQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -6,7 +6,7 @@ import { createId } from "@paralleldrive/cuid2";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useTranslate } from "@tolgee/react";
import { GripIcon, Handshake, Undo2 } from "lucide-react";
import { useMemo, useState } from "react";
import { useState } from "react";
import toast from "react-hot-toast";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { TSurveyQuota } from "@formbricks/types/quota";
@@ -16,7 +16,6 @@ import {
TSurveyQuestionId,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -65,13 +64,8 @@ export const EditEndingCard = ({
isStorageConfigured,
quotas,
}: EditEndingCardProps) => {
const endingCard = localSurvey.endings[endingCardIndex];
const { t } = useTranslate();
const endingCard = useMemo(
() => localSurvey.endings[endingCardIndex],
[localSurvey.endings, endingCardIndex]
);
const isRedirectToUrlDisabled = isFormbricksCloud
? plan === "free" && endingCard.type !== "redirectToUrl"
: false;
@@ -101,30 +95,10 @@ export const EditEndingCard = ({
}
};
const updateSurvey = (
data: Partial<TSurveyEndScreenCard & { _forceUpdate?: boolean }> | Partial<TSurveyRedirectUrlCard>
) => {
const updateSurvey = (data: Partial<TSurveyEndScreenCard> | Partial<TSurveyRedirectUrlCard>) => {
setLocalSurvey((prevSurvey) => {
const currentEnding = prevSurvey.endings[endingCardIndex];
// If subheader was explicitly deleted (is undefined) in the current state,
// block ALL attempts to recreate it (from Editor cleanup/updates)
// UNLESS it's a forced update from the "Add Description" button
const filteredData = { ...data };
const isForceUpdate = "_forceUpdate" in filteredData;
if (isForceUpdate) {
delete (filteredData as any)._forceUpdate; // Remove the flag
}
if (!isForceUpdate && currentEnding?.type === "endScreen" && currentEnding.subheader === undefined) {
if ("subheader" in filteredData) {
// Block subheader updates when it's been deleted (Editor cleanup trying to recreate)
delete filteredData.subheader;
}
}
const updatedEndings = prevSurvey.endings.map((ending, idx) =>
idx === endingCardIndex ? { ...ending, ...filteredData } : ending
idx === endingCardIndex ? { ...ending, ...data } : ending
);
return { ...prevSurvey, endings: updatedEndings };
});
@@ -242,11 +216,9 @@ export const EditEndingCard = ({
selectedLanguageCode
]
? formatTextWithSlashes(
getTextContent(
recallToHeadline(endingCard.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
]
)
recallToHeadline(endingCard.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
]
)
: t("environments.surveys.edit.ending_card"))}
{endingCard.type === "redirectToUrl" &&
@@ -6,6 +6,12 @@ import { EditWelcomeCard } from "@/modules/survey/editor/components/edit-welcome
vi.mock("@/lib/cn");
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
LocalizedEditor: vi.fn(({ value, id }) => (
<textarea data-testid={`localized-editor-${id}`} defaultValue={value?.default}></textarea>
)),
}));
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: vi.fn(({ value, id }) => (
<input data-testid={`question-form-input-${id}`} defaultValue={value?.default}></input>
@@ -47,7 +53,7 @@ const mockSurvey = {
mockSurvey.welcomeCard = {
enabled: true,
headline: { default: "Welcome!" },
subheader: { default: "<p>Thank you for participating.</p>" },
html: { default: "<p>Thank you for participating.</p>" },
buttonLabel: { default: "Start Survey" },
timeToFinish: true,
showResponseCount: false,
@@ -103,9 +109,7 @@ describe("EditWelcomeCard", () => {
expect(screen.getByLabelText("common.on")).toBeInTheDocument();
expect(screen.getByTestId("file-input")).toBeInTheDocument();
expect(screen.getByTestId("question-form-input-headline")).toHaveValue("Welcome!");
expect(screen.getByTestId("question-form-input-subheader")).toHaveValue(
"<p>Thank you for participating.</p>"
);
expect(screen.getByTestId("localized-editor-html")).toHaveValue("<p>Thank you for participating.</p>");
expect(screen.getByTestId("question-form-input-buttonLabel")).toHaveValue("Start Survey");
expect(screen.getByLabelText("common.time_to_finish")).toBeInTheDocument();
const timeToFinishSwitch = screen.getAllByRole("switch")[1]; // Assuming the second switch is for timeToFinish
@@ -8,6 +8,7 @@ import { useState } from "react";
import { TSurvey, TSurveyQuestionId, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { FileInput } from "@/modules/ui/components/file-input";
import { Label } from "@/modules/ui/components/label";
@@ -37,8 +38,8 @@ export const EditWelcomeCard = ({
isStorageConfigured = true,
}: EditWelcomeCardProps) => {
const { t } = useTranslate();
const [firstRender, setFirstRender] = useState(true);
const [firstRender, setFirstRender] = useState(true);
const path = usePathname();
const environmentId = path?.split("/environments/")[1]?.split("/")[0];
@@ -47,6 +48,7 @@ export const EditWelcomeCard = ({
const setOpen = (e) => {
if (e) {
setActiveQuestionId("start");
setFirstRender(true);
} else {
setActiveQuestionId(null);
}
@@ -137,26 +139,26 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
<div className="mt-3">
<QuestionFormInput
id="subheader"
value={localSurvey.welcomeCard.subheader}
label={t("environments.surveys.edit.welcome_message")}
localSurvey={localSurvey}
questionIdx={-1}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
<Label htmlFor="subheader">{t("environments.surveys.edit.welcome_message")}</Label>
<div className="mt-2">
<LocalizedEditor
id="html"
value={localSurvey.welcomeCard.html}
localSurvey={localSurvey}
isInvalid={isInvalid}
updateQuestion={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
firstRender={firstRender}
setFirstRender={setFirstRender}
questionIdx={-1}
locale={locale}
questionId="start"
/>
</div>
</div>
<div className="mt-3 flex justify-between gap-8">
@@ -176,8 +178,6 @@ export const EditWelcomeCard = ({
label={t("environments.surveys.edit.next_button_label")}
locale={locale}
isStorageConfigured={isStorageConfigured}
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
</div>
@@ -140,13 +140,9 @@ describe("EndScreenForm", () => {
if (buttonElement) {
await userEvent.click(buttonElement);
// Check that the subheader was added (may be called multiple times due to autoFocus)
expect(mockUpdateSurvey).toHaveBeenCalledWith(
expect.objectContaining({
subheader: expect.any(Object),
_forceUpdate: true,
})
);
expect(mockUpdateSurvey).toHaveBeenCalledWith({
subheader: expect.any(Object),
});
}
});
@@ -2,7 +2,8 @@
import { useTranslate } from "@tolgee/react";
import { PlusIcon } from "lucide-react";
import { useRef, useState } from "react";
import { useState } from "react";
import { useRef } from "react";
import { TSurvey, TSurveyEndScreenCard } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@/lib/i18n/utils";
@@ -20,7 +21,7 @@ interface EndScreenFormProps {
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
updateSurvey: (input: Partial<TSurveyEndScreenCard & { _forceUpdate?: boolean }>) => void;
updateSurvey: (input: Partial<TSurveyEndScreenCard>) => void;
endingCard: TSurveyEndScreenCard;
locale: TUserLocale;
isStorageConfigured: boolean;
@@ -45,7 +46,6 @@ export const EndScreenForm = ({
endingCard.type === "endScreen" &&
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
);
return (
<form>
<QuestionFormInput
@@ -60,7 +60,6 @@ export const EndScreenForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
/>
<div>
{endingCard.subheader !== undefined && (
@@ -78,7 +77,6 @@ export const EndScreenForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -91,10 +89,8 @@ export const EndScreenForm = ({
variant="secondary"
type="button"
onClick={() => {
// Directly update the state, bypassing the guard in updateSurvey
updateSurvey({
subheader: createI18nString("", surveyLanguageCodes),
_forceUpdate: true,
});
}}>
<PlusIcon className="mr-1 h-4 w-4" />
@@ -145,7 +145,6 @@ export const FileUploadQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div ref={parent}>
{question.subheader !== undefined && (
@@ -163,7 +162,6 @@ export const FileUploadQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -184,6 +184,7 @@ export const MatrixQuestionForm = ({
show: true,
},
};
/// Auto animate
const [parent] = useAutoAnimate();
return (
@@ -200,7 +201,6 @@ export const MatrixQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div ref={parent}>
{question.subheader !== undefined && (
@@ -218,7 +218,6 @@ export const MatrixQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -195,7 +195,6 @@ export const MultipleChoiceQuestionForm = ({
// Auto animate
const [parent] = useAutoAnimate();
return (
<form>
<QuestionFormInput
@@ -210,7 +209,6 @@ export const MultipleChoiceQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div ref={parent}>
@@ -229,7 +227,6 @@ export const MultipleChoiceQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -3,7 +3,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { PlusIcon } from "lucide-react";
import { type JSX } from "react";
import type { JSX } from "react";
import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
@@ -38,6 +38,7 @@ export const NPSQuestionForm = ({
}: NPSQuestionFormProps): JSX.Element => {
const { t } = useTranslate();
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
// Auto animate
const [parent] = useAutoAnimate();
return (
@@ -54,7 +55,6 @@ export const NPSQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div ref={parent}>
@@ -73,7 +73,6 @@ export const NPSQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -89,6 +88,7 @@ export const NPSQuestionForm = ({
subheader: createI18nString("", surveyLanguageCodes),
});
}}>
{" "}
<PlusIcon className="mr-1 h-4 w-4" />
{t("environments.surveys.edit.add_description")}
</Button>
@@ -86,16 +86,15 @@ export const OpenQuestionForm = ({
<QuestionFormInput
id="headline"
value={question.headline}
label={t("environments.surveys.edit.question") + "*"}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
label={t("environments.surveys.edit.question") + "*"}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div ref={parent}>
@@ -105,16 +104,15 @@ export const OpenQuestionForm = ({
<QuestionFormInput
id="subheader"
value={question.subheader}
label={t("common.description")}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
label={t("common.description")}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -4,7 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import { PlusIcon } from "lucide-react";
import { type JSX } from "react";
import type { JSX } from "react";
import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
@@ -70,14 +70,14 @@ export const PictureSelectionForm = ({
choices: updatedChoices,
});
};
// Auto animate
const [parent] = useAutoAnimate();
return (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
label={t("environments.surveys.edit.question") + "*"}
value={question.headline}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
@@ -86,7 +86,6 @@ export const PictureSelectionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div ref={parent}>
{question.subheader !== undefined && (
@@ -104,7 +103,6 @@ export const PictureSelectionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -15,7 +15,6 @@ import {
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -224,17 +223,18 @@ export const QuestionCard = ({
aria-label="Toggle question details">
<div>
<div className="flex grow">
{/* <div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
{QUESTIONS_ICON_MAP[question.type]}
</div> */}
<div className="flex grow flex-col justify-center" dir="auto">
<h3 className="text-sm font-semibold">
{recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
]
? formatTextWithSlashes(
getTextContent(
recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
] ?? ""
)
recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
] ?? ""
)
: getTSurveyQuestionTypeEnumName(question.type, t)}
</h3>
@@ -131,7 +131,6 @@ export const RankingQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div ref={parent}>
@@ -150,7 +149,6 @@ export const RankingQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -39,7 +39,6 @@ export const RatingQuestionForm = ({
const { t } = useTranslate();
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const [parent] = useAutoAnimate();
return (
<form>
<QuestionFormInput
@@ -54,7 +53,6 @@ export const RatingQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
/>
<div ref={parent}>
@@ -73,7 +71,6 @@ export const RatingQuestionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
/>
</div>
</div>
@@ -1599,5 +1599,25 @@ describe("Survey Editor Utils", () => {
expect(result).toBe(10); // Index of question11
});
test("should find recall pattern in question html field", () => {
const surveyWithRecall = {
...createMockSurvey(),
questions: [
...createMockSurvey().questions,
{
id: "question11",
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Question" },
html: { default: "HTML #recall:question1/fallback:default" },
required: false,
},
],
} as TSurvey;
const result = isUsedInRecall(surveyWithRecall, "question1");
expect(result).toBe(10); // Index of question11
});
});
});
+16 -16
View File
@@ -20,7 +20,6 @@ import {
TSurveyVariable,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { isConditionGroup } from "@/lib/surveyLogic/utils";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -121,10 +120,9 @@ export const getConditionValueOptions = (
.forEach((question) => {
if (question.type === TSurveyQuestionTypeEnum.Matrix) {
// Rows submenu
const questionHeadline = getTextContent(getLocalizedValue(question.headline, "default"));
const rows = question.rows.map((row, rowIdx) => ({
icon: getQuestionIconMapping(t)[question.type],
label: `${getLocalizedValue(row.label, "default")} (${questionHeadline})`,
label: `${getLocalizedValue(row.label, "default")} (${getLocalizedValue(question.headline, "default")})`,
value: `${question.id}.${rowIdx}`,
meta: {
type: "question",
@@ -134,7 +132,7 @@ export const getConditionValueOptions = (
questionOptions.push({
icon: getQuestionIconMapping(t)[question.type],
label: questionHeadline,
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
@@ -157,7 +155,7 @@ export const getConditionValueOptions = (
} else {
questionOptions.push({
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(getLocalizedValue(question.headline, "default")),
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
@@ -379,7 +377,7 @@ export const getMatchValueProps = (
const questionOptions = allowedQuestions.map((question) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(getLocalizedValue(question.headline, "default")),
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
@@ -929,7 +927,7 @@ export const getActionTargetOptions = (
const questionOptions = questions.map((question) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(getLocalizedValue(question.headline, "default")),
label: getLocalizedValue(question.headline, "default"),
value: question.id,
};
});
@@ -940,8 +938,7 @@ export const getActionTargetOptions = (
return {
label:
ending.type === "endScreen"
? getTextContent(getLocalizedValue(ending.headline, "default")) ||
t("environments.surveys.edit.end_screen_card")
? getLocalizedValue(ending.headline, "default") || t("environments.surveys.edit.end_screen_card")
: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
value: ending.id,
};
@@ -1048,7 +1045,7 @@ export const getActionValueOptions = (
const questionOptions = allowedQuestions.map((question) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(getLocalizedValue(question.headline, "default")),
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
@@ -1106,7 +1103,7 @@ export const getActionValueOptions = (
const questionOptions = allowedQuestions.map((question) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(getLocalizedValue(question.headline, "default")),
label: getLocalizedValue(question.headline, "default"),
value: question.id,
meta: {
type: "question",
@@ -1280,7 +1277,7 @@ const checkWelcomeCardForRecall = (welcomeCard: TSurveyWelcomeCard, recallPatter
return (
checkTextForRecallPattern(welcomeCard.headline, recallPattern) ||
checkTextForRecallPattern(welcomeCard.subheader, recallPattern)
checkTextForRecallPattern(welcomeCard.html, recallPattern)
);
};
@@ -1295,6 +1292,11 @@ const checkQuestionForRecall = (question: TSurveyQuestion, recallPattern: string
return true;
}
// Check html field (for consent and CTA questions)
if ("html" in question && checkTextForRecallPattern(question.html, recallPattern)) {
return true;
}
return false;
};
@@ -1427,10 +1429,8 @@ export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: strin
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
};
export const getSurveyFollowUpActionDefaultBody = (t: TFnType): string => {
return t("templates.follow_ups_modal_action_body")
.replaceAll(/[\u200B-\u200D\uFEFF]/g, "")
.trim();
export const getSurveyFollowUpActionDefaultBody = (t: TFnType) => {
return t("templates.follow_ups_modal_action_body") as string;
};
export const findEndingCardUsedInLogic = (survey: TSurvey, endingCardId: string): number => {
@@ -183,7 +183,7 @@ describe("validation.isWelcomeCardValid", () => {
const baseWelcomeCard: TSurveyWelcomeCard = {
enabled: true,
headline: { default: "Welcome", en: "Welcome", de: "Willkommen" },
subheader: { default: "<p>Info</p>", en: "<p>Info</p>", de: "<p>Infos</p>" },
html: { default: "<p>Info</p>", en: "<p>Info</p>", de: "<p>Infos</p>" },
timeToFinish: false,
showResponseCount: false,
};
@@ -197,13 +197,13 @@ describe("validation.isWelcomeCardValid", () => {
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(false);
});
test("should return false if subheader is invalid (when subheader is provided)", () => {
const card = { ...baseWelcomeCard, subheader: { default: "<p>Info</p>", en: "<p>Info</p>", de: " " } };
test("should return false if html is invalid (when html is provided)", () => {
const card = { ...baseWelcomeCard, html: { default: "<p>Info</p>", en: "<p>Info</p>", de: " " } };
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(false);
});
test("should return true if subheader is undefined", () => {
const card = { ...baseWelcomeCard, subheader: undefined };
test("should return true if html is undefined", () => {
const card = { ...baseWelcomeCard, html: undefined };
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(true);
});
});
@@ -372,7 +372,7 @@ describe("validation.validateQuestion", () => {
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent", en: "Consent", de: "Zustimmung" },
label: { default: "I agree", en: "I agree", de: "Ich stimme zu" },
subheader: { default: "Details...", en: "Details...", de: "Details..." },
html: { default: "Details...", en: "Details...", de: "Details..." },
};
test("should return true for a valid Consent question", () => {
@@ -21,7 +21,7 @@ import {
TSurveyRedirectUrlCard,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { findLanguageCodesForDuplicateLabels, getTextContent } from "@formbricks/types/surveys/validation";
import { findLanguageCodesForDuplicateLabels } from "@formbricks/types/surveys/validation";
import { extractLanguageCodes, getLocalizedValue } from "@/lib/i18n/utils";
import { checkForEmptyFallBackValue } from "@/lib/utils/recall";
@@ -35,7 +35,7 @@ export const isLabelValidForAllLanguages = (
});
const languageCodes = extractLanguageCodes(filteredLanguages);
const languages = languageCodes.length === 0 ? ["default"] : languageCodes;
return languages.every((language) => label?.[language] && getTextContent(label[language]).length > 0);
return languages.every((language) => label && label[language] && label[language].trim() !== "");
};
// Validation logic for multiple choice questions
@@ -145,7 +145,7 @@ export const validationRules = {
let isValid = isHeadlineValid && isSubheaderValid;
const defaultLanguageCode = "default";
//question specific fields
let fieldsToValidate = ["buttonLabel", "upperLabel", "backButtonLabel", "lowerLabel"];
let fieldsToValidate = ["html", "buttonLabel", "upperLabel", "backButtonLabel", "lowerLabel"];
// Remove backButtonLabel from validation if it is the first question
if (isFirstQuestion) {
@@ -210,7 +210,7 @@ const isContentValid = (content: Record<string, string> | undefined, surveyLangu
};
export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => {
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.subheader, surveyLanguages);
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.html, surveyLanguages);
};
export const isEndingCardValid = (
@@ -142,7 +142,7 @@ describe("FollowUpItem", () => {
},
endings: [],
welcomeCard: {
subheader: {
html: {
default: "Thanks for providing your feedback - let's go!",
},
enabled: false,
+2 -2
View File
@@ -201,7 +201,7 @@ export const getQuestionTypes = (t: TFnType): TQuestion[] => [
icon: MousePointerClickIcon,
preset: {
headline: createI18nString("", []),
subheader: createI18nString("", []),
html: createI18nString("", []),
buttonLabel: createI18nString(t("templates.book_interview"), []),
buttonExternal: false,
dismissButtonLabel: createI18nString(t("templates.skip"), []),
@@ -215,7 +215,7 @@ export const getQuestionTypes = (t: TFnType): TQuestion[] => [
icon: CheckIcon,
preset: {
headline: createI18nString("", []),
subheader: createI18nString("", []),
html: createI18nString("", []),
label: createI18nString("", []),
buttonLabel: createI18nString(t("templates.next"), []),
backButtonLabel: createI18nString(t("templates.back"), []),
@@ -9,7 +9,6 @@ import { Toaster, toast } from "react-hot-toast";
import { z } from "zod";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
@@ -175,7 +174,7 @@ export const VerifyEmail = ({
{localSurvey.questions.map((question, index) => (
<p
key={index}
className="my-1 text-sm">{`${(index + 1).toString()}. ${getTextContent(getLocalizedValue(question.headline, languageCode))}`}</p>
className="my-1 text-sm">{`${(index + 1).toString()}. ${getLocalizedValue(question.headline, languageCode)}`}</p>
))}
</div>
<Button variant="ghost" className="mt-6" onClick={handlePreviewClick}>
@@ -79,7 +79,7 @@ describe("data", () => {
timeToFinish: false,
showResponseCount: false,
headline: { default: "Welcome" },
subheader: { default: "" },
html: { default: "" },
buttonLabel: { default: "Start" },
},
questions: [],
@@ -1,5 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getPrefillValue } from "./utils";
@@ -85,6 +85,7 @@ describe("survey link utils", () => {
required: false,
logic: [],
buttonLabel: { default: "Click me" },
html: { default: "" },
subheader: { default: "" },
},
{
@@ -136,7 +137,7 @@ describe("survey link utils", () => {
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
subheader: { default: "" },
html: { default: "" },
buttonLabel: { default: "Start" },
},
hiddenFields: {},
@@ -2,7 +2,6 @@ import { CodeHighlightNode, CodeNode } from "@lexical/code";
import { AutoLinkNode, LinkNode } from "@lexical/link";
import { ListItemNode, ListNode } from "@lexical/list";
import { TRANSFORMERS } from "@lexical/markdown";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
@@ -55,8 +54,6 @@ export type TextEditorProps = {
selectedLanguageCode?: string;
fallbacks?: { [id: string]: string };
addFallback?: () => void;
autoFocus?: boolean;
id?: string;
};
const editorConfig = {
@@ -121,15 +118,10 @@ export const Editor = (props: TextEditorProps) => {
style={{ height: props.height }}>
<RichTextPlugin
contentEditable={
<ContentEditable
style={{ height: props.height }}
className="editor-input"
aria-labelledby={props.id}
dir="auto"
/>
<ContentEditable style={{ height: props.height }} className="editor-input" />
}
placeholder={
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400" dir="auto">
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400">
{props.placeholder ?? ""}
</div>
}
@@ -138,7 +130,6 @@ export const Editor = (props: TextEditorProps) => {
<ListPlugin />
<LinkPlugin />
<AutoLinkPlugin />
{props.autoFocus && <AutoFocusPlugin />}
{props.localSurvey && props.questionId && props.selectedLanguageCode && (
<RecallPlugin
localSurvey={props.localSurvey}
@@ -3,7 +3,7 @@ import { cleanup, render } from "@testing-library/react";
import { $applyNodeReplacement } from "lexical";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
import { $createRecallNode, RecallNode, RecallPayload, SerializedRecallNode } from "./recall-node";
vi.mock("lexical", () => ({
@@ -23,19 +23,6 @@ vi.mock("@/lib/utils/recall", () => ({
replaceRecallInfoWithUnderline: vi.fn((label: string) => {
return label.replace(/#recall:[^#]+#/g, "___");
}),
getTextContentWithRecallTruncated: vi.fn((text: string, maxLength: number = 25) => {
// Mock: strip HTML tags, clean whitespace, truncate, replace recall patterns
const cleanText = text.replace(/<|>/g, "").replace(/\s+/g, " ").trim();
const withRecallReplaced = cleanText.replace(/#recall:[^#]+#/g, "___");
if (withRecallReplaced.length <= maxLength) {
return withRecallReplaced;
}
const start = withRecallReplaced.slice(0, 10);
const end = withRecallReplaced.slice(-10);
return `${start}...${end}`;
}),
}));
describe("RecallNode", () => {
@@ -366,15 +353,15 @@ describe("RecallNode", () => {
expect(span?.textContent).toContain("@");
});
test("calls getTextContentWithRecallTruncated with label", () => {
test("calls replaceRecallInfoWithUnderline with label", () => {
const node = new RecallNode(mockPayload);
node.decorate();
expect(vi.mocked(getTextContentWithRecallTruncated)).toHaveBeenCalledWith("What is your name?");
expect(vi.mocked(replaceRecallInfoWithUnderline)).toHaveBeenCalledWith("What is your name?");
});
test("handles label with nested recall patterns", () => {
vi.mocked(getTextContentWithRecallTruncated).mockReturnValueOnce("Processed Label");
vi.mocked(replaceRecallInfoWithUnderline).mockReturnValueOnce("Processed Label");
const payloadWithNestedRecall: RecallPayload = {
recallItem: {
@@ -389,7 +376,7 @@ describe("RecallNode", () => {
const decorated = node.decorate();
const { container } = render(<>{decorated}</>);
expect(vi.mocked(getTextContentWithRecallTruncated)).toHaveBeenCalledWith(
expect(vi.mocked(replaceRecallInfoWithUnderline)).toHaveBeenCalledWith(
"What is your #recall:name/fallback:name# answer?"
);
expect(container.textContent).toContain("@Processed Label");
@@ -4,7 +4,7 @@ import type { DOMConversionMap, DOMConversionOutput, DOMExportOutput, NodeKey, S
import { $applyNodeReplacement, DecoratorNode } from "lexical";
import { ReactNode } from "react";
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
export interface RecallPayload {
recallItem: TSurveyRecallItem;
@@ -134,13 +134,12 @@ export class RecallNode extends DecoratorNode<ReactNode> {
}
decorate(): ReactNode {
const displayLabel = getTextContentWithRecallTruncated(this.__recallItem.label);
const displayLabel = replaceRecallInfoWithUnderline(this.__recallItem.label);
return (
<span
className="recall-node z-30 inline-flex h-fit justify-center whitespace-nowrap rounded-md bg-slate-100 text-sm text-slate-700"
aria-label={`Recall: ${displayLabel}`}
title={displayLabel}>
className="recall-node z-30 inline-flex h-fit justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-slate-700"
aria-label={`Recall: ${displayLabel}`}>
@{displayLabel}
</span>
);
@@ -223,15 +223,7 @@ export const RecallPlugin = ({
}
});
},
[
findAllRecallNodes,
localSurvey,
selectedLanguageCode,
setRecallItems,
setFallbacks,
editor,
convertTextToRecallNodes,
]
[localSurvey, selectedLanguageCode, editor, convertTextToRecallNodes, findAllRecallNodes]
);
// Handle @ key press for recall trigger
@@ -268,7 +260,7 @@ export const RecallPlugin = ({
}
return false;
},
[editor, setShowRecallItemSelect]
[editor]
);
// Close dropdown when clicking outside
@@ -285,7 +277,7 @@ export const RecallPlugin = ({
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [setShowRecallItemSelect, showRecallItemSelect]);
}, [showRecallItemSelect]);
// Clean up when dropdown closes
useEffect(() => {
@@ -393,13 +385,11 @@ export const RecallPlugin = ({
},
[
editor,
setShowRecallItemSelect,
recallItems,
setRecallItems,
atSymbolPosition,
replaceAtSymbolWithStoredPosition,
replaceAtSymbolWithCurrentSelection,
onShowFallbackInput,
recallItems,
]
);
@@ -20,7 +20,9 @@ import {
$getRoot,
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_CRITICAL,
FORMAT_TEXT_COMMAND,
PASTE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from "lexical";
import { AtSign, Bold, ChevronDownIcon, Italic, Link, PencilIcon, Underline } from "lucide-react";
@@ -310,8 +312,25 @@ export const ToolbarPlugin = (
}
}, [editor, isLink, props]);
// Removed custom PASTE_COMMAND handler to allow Lexical's default paste handler
// to properly preserve rich text formatting (bold, italic, links, etc.)
useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(e: ClipboardEvent) => {
const text = e.clipboardData?.getData("text/plain");
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertRawText(text ?? "");
}
});
e.preventDefault();
return true; // Prevent the default paste handler
},
COMMAND_PRIORITY_CRITICAL
);
}, [editor]);
if (!props.editable) return <></>;
@@ -404,20 +423,18 @@ export const ToolbarPlugin = (
</DropdownMenu>
)}
<div className="flex items-center gap-1">
{items.map(({ key, icon, onClick, active, tooltipText, disabled }) =>
!props.excludedToolbarItems?.includes(key) ? (
<ToolbarButton
key={key}
icon={icon}
active={active}
disabled={disabled}
onClick={onClick}
tooltipText={tooltipText}
/>
) : null
)}
</div>
{items.map(({ key, icon, onClick, active, tooltipText, disabled }) =>
!props.excludedToolbarItems?.includes(key) ? (
<ToolbarButton
key={key}
icon={icon}
active={active}
disabled={disabled}
onClick={onClick}
tooltipText={tooltipText}
/>
) : null
)}
</div>
);
};
@@ -21,6 +21,7 @@
position: relative;
line-height: 24px;
font-weight: 400;
text-align: left;
border-color: #cbd5e1;
border-width: 1px;
padding: 1px;
@@ -35,11 +36,11 @@
position: relative;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
overflow-y: auto;
overflow: auto;
resize: vertical;
height: auto;
min-height: var(--editor-min-height, 48px);
max-height: 200px;
min-height: var(--editor-min-height, 40px);
max-height: 150px;
}
.editor-input {
@@ -48,7 +49,7 @@
position: relative;
tab-size: 1;
outline: 0;
padding: 5px 10px 10px 10px;
padding: 10px 10px;
outline: none;
}
@@ -348,4 +349,4 @@ i.link {
.inactive-button {
color: #777;
}
}
+79 -45
View File
@@ -1,19 +1,15 @@
import { expect } from "@playwright/test";
import { surveys } from "@/playwright/utils/mock";
import { test } from "./lib/fixtures";
import * as helper from "./utils/helper";
import { createSurvey, createSurveyWithLogic, uploadFileForFileUploadQuestion } from "./utils/helper";
test.use({
launchOptions: {
slowMo: 150,
slowMo: 110,
},
});
test.describe("Survey Create & Submit Response without logic", async () => {
// 5 minutes
test.setTimeout(1000 * 60 * 5);
let url: string | null;
test("Create survey and submit response", async ({ page, users }) => {
@@ -202,9 +198,18 @@ test.describe("Survey Create & Submit Response without logic", async () => {
).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("cell", { name: "Roses 0" }).locator("div").click();
await page.getByRole("cell", { name: "Trees 0" }).locator("div").click();
await page.getByRole("cell", { name: "Ocean 0" }).locator("div").click();
await page
.getByRole("cell", { name: "How much do you love these flowers?: Roses 0" })
.locator("div")
.click();
await page
.getByRole("cell", { name: "How much do you love these flowers?: Trees 0" })
.locator("div")
.click();
await page
.getByRole("cell", { name: "How much do you love these flowers?: Ocean 0" })
.locator("div")
.click();
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
// Address Question
@@ -238,8 +243,8 @@ test.describe("Survey Create & Submit Response without logic", async () => {
});
test.describe("Multi Language Survey Create", async () => {
// 5 minutes
test.setTimeout(1000 * 60 * 5);
// 4 minutes
test.setTimeout(1000 * 60 * 4);
test("Create Survey", async ({ page, users }) => {
const user = await users.create();
@@ -280,7 +285,7 @@ test.describe("Multi Language Survey Create", async () => {
// Add questions in default language
await page.getByText("Add question").click();
await page.getByRole("button", { name: "Single-Select" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.singleSelectQuestion.question);
await page.getByLabel("Question*").fill(surveys.createAndSubmit.singleSelectQuestion.question);
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.singleSelectQuestion.options[1]);
@@ -290,7 +295,7 @@ test.describe("Multi Language Survey Create", async () => {
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.multiSelectQuestion.question);
await page.getByLabel("Question*").fill(surveys.createAndSubmit.multiSelectQuestion.question);
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.multiSelectQuestion.options[1]);
await page.getByPlaceholder("Option 3").fill(surveys.createAndSubmit.multiSelectQuestion.options[2]);
@@ -300,11 +305,7 @@ test.describe("Multi Language Survey Create", async () => {
.nth(1)
.click();
await page.getByRole("button", { name: "Picture Selection" }).click();
await helper.fillRichTextEditor(
page,
"Question*",
surveys.createAndSubmit.pictureSelectQuestion.question
);
await page.getByLabel("Question*").fill(surveys.createAndSubmit.pictureSelectQuestion.question);
// Handle file uploads
await uploadFileForFileUploadQuestion(page);
@@ -315,7 +316,7 @@ test.describe("Multi Language Survey Create", async () => {
.nth(1)
.click();
await page.getByRole("button", { name: "Rating" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.ratingQuestion.question);
await page.getByLabel("Question*").fill(surveys.createAndSubmit.ratingQuestion.question);
await page.getByPlaceholder("Not good").fill(surveys.createAndSubmit.ratingQuestion.lowLabel);
await page.getByPlaceholder("Very satisfied").fill(surveys.createAndSubmit.ratingQuestion.highLabel);
@@ -325,7 +326,7 @@ test.describe("Multi Language Survey Create", async () => {
.nth(1)
.click();
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.npsQuestion.question);
await page.getByLabel("Question*").fill(surveys.createAndSubmit.npsQuestion.question);
await page.getByLabel("Lower label").fill(surveys.createAndSubmit.npsQuestion.lowLabel);
await page.getByLabel("Upper label").fill(surveys.createAndSubmit.npsQuestion.highLabel);
@@ -335,7 +336,7 @@ test.describe("Multi Language Survey Create", async () => {
.nth(1)
.click();
await page.getByRole("button", { name: "Date" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.dateQuestion.question);
await page.getByLabel("Question*").fill(surveys.createAndSubmit.dateQuestion.question);
await page
.locator("div")
@@ -343,7 +344,7 @@ test.describe("Multi Language Survey Create", async () => {
.nth(1)
.click();
await page.getByRole("button", { name: "File Upload" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.fileUploadQuestion.question);
await page.getByLabel("Question*").fill(surveys.createAndSubmit.fileUploadQuestion.question);
await page
.locator("div")
@@ -353,7 +354,7 @@ test.describe("Multi Language Survey Create", async () => {
await page.getByRole("button", { name: "Matrix" }).scrollIntoViewIfNeeded();
await page.getByRole("button", { name: "Matrix" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.matrix.question);
await page.getByLabel("Question*").fill(surveys.createAndSubmit.matrix.question);
await page.locator("#row-0").click();
await page.locator("#row-0").fill(surveys.createAndSubmit.matrix.rows[0]);
await page.locator("#row-1").click();
@@ -378,7 +379,7 @@ test.describe("Multi Language Survey Create", async () => {
.nth(1)
.click();
await page.getByRole("button", { name: "Address" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.address.question);
await page.getByLabel("Question*").fill(surveys.createAndSubmit.address.question);
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
@@ -391,7 +392,7 @@ test.describe("Multi Language Survey Create", async () => {
.nth(1)
.click();
await page.getByRole("button", { name: "Ranking" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.ranking.question);
await page.getByLabel("Question*").fill(surveys.createAndSubmit.ranking.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.ranking.choices[0]);
await page.getByPlaceholder("Option 2").click();
@@ -411,15 +412,20 @@ test.describe("Multi Language Survey Create", async () => {
await page.getByRole("button", { name: "English" }).nth(1).click();
await page.getByRole("button", { name: "German" }).click();
// Fill welcome card in german using rich text editor helper
await helper.fillRichTextEditor(page, "Note*", surveys.germanCreate.welcomeCard.headline);
await helper.fillRichTextEditor(page, "Welcome message", surveys.germanCreate.welcomeCard.description);
// Fill welcome card in german
await page.locator(".editor-input").click();
await page.locator(".editor-input").fill(surveys.germanCreate.welcomeCard.description);
await page.getByLabel("Note*").click();
await page.getByLabel("Note*").fill(surveys.germanCreate.welcomeCard.headline);
await page.getByPlaceholder("Next").click();
await page.getByPlaceholder("Next").fill(surveys.germanCreate.welcomeCard.buttonLabel);
// Fill Open text question in german
await page.getByRole("main").getByText("Free text").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.openTextQuestion.question);
await page.getByPlaceholder("Your question here. Recall").click();
await page
.getByPlaceholder("Your question here. Recall")
.fill(surveys.germanCreate.openTextQuestion.question);
await page.getByLabel("Placeholder").click();
await page.getByLabel("Placeholder").fill(surveys.germanCreate.openTextQuestion.placeholder);
await page.getByText("Show Advanced settings").first().click();
@@ -428,7 +434,10 @@ test.describe("Multi Language Survey Create", async () => {
// Fill Single select question in german
await page.getByRole("main").getByText("Single-Select").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.singleSelectQuestion.question);
await page.getByPlaceholder("Your question here. Recall").click();
await page
.getByPlaceholder("Your question here. Recall")
.fill(surveys.germanCreate.singleSelectQuestion.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").click();
@@ -442,7 +451,10 @@ test.describe("Multi Language Survey Create", async () => {
// Fill Multi select question in german
await page.getByRole("main").getByRole("heading", { name: "Multi-Select" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.multiSelectQuestion.question);
await page.getByPlaceholder("Your question here. Recall").click();
await page
.getByPlaceholder("Your question here. Recall")
.fill(surveys.germanCreate.multiSelectQuestion.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").click();
@@ -457,7 +469,10 @@ test.describe("Multi Language Survey Create", async () => {
// Fill Picture select question in german
await page.getByRole("main").getByText("Picture Selection").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.pictureSelectQuestion.question);
await page.getByPlaceholder("Your question here. Recall").click();
await page
.getByPlaceholder("Your question here. Recall")
.fill(surveys.germanCreate.pictureSelectQuestion.question);
await page.getByText("Show Advanced settings").first().click();
await page.getByPlaceholder("Next").click();
await page.getByPlaceholder("Next").fill(surveys.germanCreate.next);
@@ -466,7 +481,10 @@ test.describe("Multi Language Survey Create", async () => {
// Fill Rating question in german
await page.getByRole("main").getByText("Rating").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.ratingQuestion.question);
await page.getByPlaceholder("Your question here. Recall").click();
await page
.getByPlaceholder("Your question here. Recall")
.fill(surveys.germanCreate.ratingQuestion.question);
await page.getByPlaceholder("Not good").click();
await page.getByPlaceholder("Not good").fill(surveys.germanCreate.ratingQuestion.lowLabel);
await page.getByPlaceholder("Very satisfied").click();
@@ -477,7 +495,8 @@ test.describe("Multi Language Survey Create", async () => {
// Fill NPS question in german
await page.getByRole("main").getByText("Net Promoter Score (NPS)").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.npsQuestion.question);
await page.getByPlaceholder("Your question here. Recall").click();
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.npsQuestion.question);
await page.getByLabel("Lower Label").click();
await page.getByLabel("Lower Label").fill(surveys.germanCreate.npsQuestion.lowLabel);
await page.getByLabel("Upper Label").click();
@@ -488,7 +507,10 @@ test.describe("Multi Language Survey Create", async () => {
// Fill Date question in german
await page.getByRole("main").getByText("Date").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.dateQuestion.question);
await page.getByPlaceholder("Your question here. Recall").click();
await page
.getByPlaceholder("Your question here. Recall")
.fill(surveys.germanCreate.dateQuestion.question);
await page.getByText("Show Advanced settings").first().click();
await page.getByPlaceholder("Next").click();
await page.getByPlaceholder("Next").fill(surveys.germanCreate.next);
@@ -497,7 +519,10 @@ test.describe("Multi Language Survey Create", async () => {
// Fill File upload question in german
await page.getByRole("main").getByText("File Upload").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.fileUploadQuestion.question);
await page.getByPlaceholder("Your question here. Recall").click();
await page
.getByPlaceholder("Your question here. Recall")
.fill(surveys.germanCreate.fileUploadQuestion.question);
await page.getByText("Show Advanced settings").first().click();
await page.getByPlaceholder("Next").click();
await page.getByPlaceholder("Next").fill(surveys.germanCreate.next);
@@ -506,7 +531,8 @@ test.describe("Multi Language Survey Create", async () => {
// Fill Matrix question in german
await page.getByRole("main").getByText("Matrix").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.matrix.question);
await page.getByPlaceholder("Your question here. Recall").click();
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.matrix.question);
await page.locator("#row-0").click();
await page.locator("#row-0").fill(surveys.germanCreate.matrix.rows[0]);
await page.locator("#row-1").click();
@@ -529,7 +555,10 @@ test.describe("Multi Language Survey Create", async () => {
// Fill Address question in german
await page.getByRole("main").getByText("Address").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.addressQuestion.question);
await page.getByPlaceholder("Your question here. Recall").click();
await page
.getByPlaceholder("Your question here. Recall")
.fill(surveys.germanCreate.addressQuestion.question);
await page.locator('[id="addressLine1\\.placeholder"]').click();
await page
.locator('[id="addressLine1\\.placeholder"]')
@@ -560,7 +589,8 @@ test.describe("Multi Language Survey Create", async () => {
// Fill Ranking question in german
await page.getByRole("main").getByText("Ranking").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.ranking.question);
await page.getByPlaceholder("Your question here. Recall").click();
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.ranking.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.ranking.choices[0]);
await page.getByPlaceholder("Option 2").click();
@@ -579,8 +609,12 @@ test.describe("Multi Language Survey Create", async () => {
// Fill Thank you card in german
await page.getByText("Ending card").first().click();
await helper.fillRichTextEditor(page, "Note*", surveys.germanCreate.endingCard.headline);
await helper.fillRichTextEditor(page, "Description", surveys.germanCreate.endingCard.description);
await page.getByPlaceholder("Your question here. Recall").click();
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.endingCard.headline);
await page.getByPlaceholder("Your description here. Recall").click();
await page
.getByPlaceholder("Your description here. Recall")
.fill(surveys.germanCreate.endingCard.description);
await page.locator("#showButton").check();
@@ -610,8 +644,8 @@ test.describe("Multi Language Survey Create", async () => {
});
test.describe("Testing Survey with advanced logic", async () => {
// 8 minutes
test.setTimeout(1000 * 60 * 8);
// 6 minutes
test.setTimeout(1000 * 60 * 6);
let url: string | null;
test("Create survey and submit response", async ({ page, users }) => {
@@ -785,9 +819,9 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("cell", { name: "Roses 0" }).locator("div").click();
await page.getByRole("cell", { name: "Trees 0" }).locator("div").click();
await page.getByRole("cell", { name: "Ocean 0" }).locator("div").click();
await page.getByRole("cell", { name: "This is my Matrix Question: Roses 0" }).locator("div").click();
await page.getByRole("cell", { name: "This is my Matrix Question: Trees 0" }).locator("div").click();
await page.getByRole("cell", { name: "This is my Matrix Question: Ocean 0" }).locator("div").click();
await page.locator("#questionCard-7").getByRole("button", { name: "Next" }).click();
// CTA Question
+43 -71
View File
@@ -157,28 +157,6 @@ export const signupUsingInviteToken = async (page: Page, name: string, email: st
await page.getByRole("button", { name: "Login with Email" }).click();
};
/**
* Helper function to fill content into a rich text editor (contenteditable div).
* The rich text editor uses a contenteditable div with class "editor-input" instead of a regular input.
*
* @param page - Playwright Page object
* @param labelText - The label text to find the editor (e.g., "Note*", "Description")
* @param content - The text content to fill into the editor
*/
export const fillRichTextEditor = async (page: Page, labelText: string, content: string): Promise<void> => {
// Find the editor by locating the label and then finding the .editor-input within the same form group
const label = page.locator(`label:has-text("${labelText}")`);
const editorContainer = label.locator("..").locator("..");
const editor = editorContainer.locator(".editor-input").first();
await editor.click();
// Clear existing content by selecting all and deleting
await editor.press("Meta+a"); // Cmd+A on Mac, Ctrl+A is handled automatically by Playwright
await editor.press("Backspace");
// Type the new content
await editor.pressSequentially(content, { delay: 50 });
};
export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
const addQuestion = "Add questionAdd a new question to your survey";
@@ -191,19 +169,16 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
await expect(page.locator("#welcome-toggle")).toBeVisible();
await page.getByText("Welcome Card").click();
await page.locator("#welcome-toggle").check();
// Use the helper function for rich text editors
await fillRichTextEditor(page, "Note*", params.welcomeCard.headline);
await fillRichTextEditor(page, "Welcome message", params.welcomeCard.description);
await page.getByLabel("Note*").fill(params.welcomeCard.headline);
await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description);
await page.getByText("Welcome CardOn").click();
// Open Text Question
await page.getByRole("main").getByText("What would you like to know?").click();
await fillRichTextEditor(page, "Question*", params.openTextQuestion.question);
await page.getByLabel("Question*").fill(params.openTextQuestion.question);
await page.getByRole("button", { name: "Add description" }).click();
await fillRichTextEditor(page, "Description", params.openTextQuestion.description);
await page.locator('input[name="subheader"]').fill(params.openTextQuestion.description);
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
await page.locator("h3").filter({ hasText: params.openTextQuestion.question }).click();
@@ -215,9 +190,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "Single-Select" }).click();
await fillRichTextEditor(page, "Question*", params.singleSelectQuestion.question);
await page.getByLabel("Question*").fill(params.singleSelectQuestion.question);
await page.getByRole("button", { name: "Add description" }).click();
await fillRichTextEditor(page, "Description", params.singleSelectQuestion.description);
await page.locator('input[name="subheader"]').fill(params.singleSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
@@ -229,9 +204,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
await fillRichTextEditor(page, "Question*", params.multiSelectQuestion.question);
await page.getByLabel("Question*").fill(params.multiSelectQuestion.question);
await page.getByRole("button", { name: "Add description", exact: true }).click();
await fillRichTextEditor(page, "Description", params.multiSelectQuestion.description);
await page.locator('input[name="subheader"]').fill(params.multiSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(params.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
@@ -243,9 +218,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "Rating" }).click();
await fillRichTextEditor(page, "Question*", params.ratingQuestion.question);
await page.getByLabel("Question*").fill(params.ratingQuestion.question);
await page.getByRole("button", { name: "Add description", exact: true }).click();
await fillRichTextEditor(page, "Description", params.ratingQuestion.description);
await page.locator('input[name="subheader"]').fill(params.ratingQuestion.description);
await page.getByPlaceholder("Not good").fill(params.ratingQuestion.lowLabel);
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
@@ -256,7 +231,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
await fillRichTextEditor(page, "Question*", params.npsQuestion.question);
await page.getByLabel("Question*").fill(params.npsQuestion.question);
await page.getByLabel("Lower label").fill(params.npsQuestion.lowLabel);
await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel);
@@ -267,7 +242,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "Statement (Call to Action)" }).click();
await fillRichTextEditor(page, "Question*", params.ctaQuestion.question);
await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question);
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
// Consent Question
@@ -277,7 +252,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "Consent" }).click();
await fillRichTextEditor(page, "Question*", params.consentQuestion.question);
await page.getByLabel("Question*").fill(params.consentQuestion.question);
await page.getByPlaceholder("I agree to the terms and").fill(params.consentQuestion.checkboxLabel);
// Picture Select Question
@@ -287,9 +262,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "Picture Selection" }).click();
await fillRichTextEditor(page, "Question*", params.pictureSelectQuestion.question);
await page.getByLabel("Question*").fill(params.pictureSelectQuestion.question);
await page.getByRole("button", { name: "Add description" }).click();
await fillRichTextEditor(page, "Description", params.pictureSelectQuestion.description);
await page.locator('input[name="subheader"]').fill(params.pictureSelectQuestion.description);
// Handle file uploads
await uploadFileForFileUploadQuestion(page);
@@ -301,7 +276,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "File Upload" }).click();
await fillRichTextEditor(page, "Question*", params.fileUploadQuestion.question);
await page.getByLabel("Question*").fill(params.fileUploadQuestion.question);
// Matrix Upload Question
await page
@@ -310,9 +285,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "Matrix" }).click();
await fillRichTextEditor(page, "Question*", params.matrix.question);
await page.getByLabel("Question*").fill(params.matrix.question);
await page.getByRole("button", { name: "Add description", exact: true }).click();
await fillRichTextEditor(page, "Description", params.matrix.description);
await page.locator('input[name="subheader"]').fill(params.matrix.description);
await page.locator("#row-0").click();
await page.locator("#row-0").fill(params.matrix.rows[0]);
await page.locator("#row-1").click();
@@ -338,7 +313,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "Address" }).click();
await fillRichTextEditor(page, "Question*", params.address.question);
await page.getByLabel("Question*").fill(params.address.question);
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
@@ -352,7 +327,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "Contact Info" }).click();
await fillRichTextEditor(page, "Question*", params.contactInfo.question);
await page.getByLabel("Question*").fill(params.contactInfo.question);
await page.getByRole("row", { name: "Last Name" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "Email" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "Phone" }).getByRole("switch").nth(1).click();
@@ -365,7 +340,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.nth(1)
.click();
await page.getByRole("button", { name: "Ranking" }).click();
await fillRichTextEditor(page, "Question*", params.ranking.question);
await page.getByLabel("Question*").fill(params.ranking.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(params.ranking.choices[0]);
await page.getByPlaceholder("Option 2").click();
@@ -407,19 +382,16 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await expect(page.locator("#welcome-toggle")).toBeVisible();
await page.getByText("Welcome Card").click();
await page.locator("#welcome-toggle").check();
// Use the helper function for rich text editors
await fillRichTextEditor(page, "Note*", params.welcomeCard.headline);
await fillRichTextEditor(page, "Welcome message", params.welcomeCard.description);
await page.getByLabel("Note*").fill(params.welcomeCard.headline);
await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description);
await page.getByText("Welcome CardOn").click();
// Open Text Question
await page.getByRole("main").getByText("What would you like to know?").click();
await fillRichTextEditor(page, "Question*", params.openTextQuestion.question);
await page.getByLabel("Question*").fill(params.openTextQuestion.question);
await page.getByRole("button", { name: "Add description" }).click();
await fillRichTextEditor(page, "Description", params.openTextQuestion.description);
await page.locator('input[name="subheader"]').fill(params.openTextQuestion.description);
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
await page.locator("h3").filter({ hasText: params.openTextQuestion.question }).click();
@@ -431,9 +403,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Single-Select" }).click();
await fillRichTextEditor(page, "Question*", params.singleSelectQuestion.question);
await page.getByLabel("Question*").fill(params.singleSelectQuestion.question);
await page.getByRole("button", { name: "Add description" }).click();
await fillRichTextEditor(page, "Description", params.singleSelectQuestion.description);
await page.locator('input[name="subheader"]').fill(params.singleSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
@@ -445,9 +417,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
await fillRichTextEditor(page, "Question*", params.multiSelectQuestion.question);
await page.getByLabel("Question*").fill(params.multiSelectQuestion.question);
await page.getByRole("button", { name: "Add description" }).click();
await fillRichTextEditor(page, "Description", params.multiSelectQuestion.description);
await page.locator('input[name="subheader"]').fill(params.multiSelectQuestion.description);
await page.getByPlaceholder("Option 1").fill(params.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
@@ -459,9 +431,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Picture Selection" }).click();
await fillRichTextEditor(page, "Question*", params.pictureSelectQuestion.question);
await page.getByLabel("Question*").fill(params.pictureSelectQuestion.question);
await page.getByRole("button", { name: "Add description" }).click();
await fillRichTextEditor(page, "Description", params.pictureSelectQuestion.description);
await page.locator('input[name="subheader"]').fill(params.pictureSelectQuestion.description);
const fileInput = page.locator('input[type="file"]');
const response1 = await fetch("https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg");
const response2 = await fetch("https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg");
@@ -488,9 +460,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Rating" }).click();
await fillRichTextEditor(page, "Question*", params.ratingQuestion.question);
await page.getByLabel("Question*").fill(params.ratingQuestion.question);
await page.getByRole("button", { name: "Add description" }).click();
await fillRichTextEditor(page, "Description", params.ratingQuestion.description);
await page.locator('input[name="subheader"]').fill(params.ratingQuestion.description);
await page.getByPlaceholder("Not good").fill(params.ratingQuestion.lowLabel);
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
@@ -501,7 +473,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
await fillRichTextEditor(page, "Question*", params.npsQuestion.question);
await page.getByLabel("Question*").fill(params.npsQuestion.question);
await page.getByLabel("Lower label").fill(params.npsQuestion.lowLabel);
await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel);
@@ -512,7 +484,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Ranking" }).click();
await fillRichTextEditor(page, "Question*", params.ranking.question);
await page.getByLabel("Question*").fill(params.ranking.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(params.ranking.choices[0]);
await page.getByPlaceholder("Option 2").click();
@@ -534,9 +506,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Matrix" }).click();
await fillRichTextEditor(page, "Question*", params.matrix.question);
await page.getByLabel("Question*").fill(params.matrix.question);
await page.getByRole("button", { name: "Add description" }).click();
await fillRichTextEditor(page, "Description", params.matrix.description);
await page.locator('input[name="subheader"]').fill(params.matrix.description);
await page.locator("#row-0").click();
await page.locator("#row-0").fill(params.matrix.rows[0]);
await page.locator("#row-1").click();
@@ -562,7 +534,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Statement (Call to Action)" }).click();
await fillRichTextEditor(page, "Question*", params.ctaQuestion.question);
await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question);
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
// Consent Question
@@ -572,7 +544,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Consent" }).click();
await fillRichTextEditor(page, "Question*", params.consentQuestion.question);
await page.getByLabel("Question*").fill(params.consentQuestion.question);
await page.getByPlaceholder("I agree to the terms and").fill(params.consentQuestion.checkboxLabel);
// File Upload Question
@@ -582,7 +554,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "File Upload" }).click();
await fillRichTextEditor(page, "Question*", params.fileUploadQuestion.question);
await page.getByLabel("Question*").fill(params.fileUploadQuestion.question);
// Date Question
await page
@@ -591,7 +563,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Date" }).click();
await fillRichTextEditor(page, "Question*", params.date.question);
await page.getByLabel("Question*").fill(params.date.question);
// Cal Question
await page
@@ -600,7 +572,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Schedule a meeting" }).click();
await fillRichTextEditor(page, "Question*", params.cal.question);
await page.getByLabel("Question*").fill(params.cal.question);
// Fill Address Question
await page
@@ -609,7 +581,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.nth(1)
.click();
await page.getByRole("button", { name: "Address" }).click();
await fillRichTextEditor(page, "Question*", params.address.question);
await page.getByLabel("Question*").fill(params.address.question);
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
-1
View File
@@ -192,7 +192,6 @@
"icon": "lightbulb",
"pages": [
"xm-and-surveys/xm/best-practices/contact-form",
"xm-and-surveys/xm/best-practices/headless-surveys",
"xm-and-surveys/xm/best-practices/docs-feedback",
"xm-and-surveys/xm/best-practices/feature-chaser",
"xm-and-surveys/xm/best-practices/feedback-box",
@@ -1,220 +0,0 @@
---
title: "Headless Surveys"
icon: "pen"
description: "Using Formbricks as a Headless Survey Platform"
---
This document shows how you can use Formbricks to manage survey definitions and response collection via APIs, while rendering the surveys in your own frontend and forwarding the response data to your own analytics pipelines.
## In a nutshell
### What Formbricks handles:
1. **Survey Management:** Create, update, and host survey definitions through the Formbricks Management API or dashboard.
2. **Response Handling:** Receive and securely store responses via the Client or Management API.
3. **Webhooks Delivery:** Send real-time response data to your configured endpoints when responses are created, updated, or completed.
### What you handle:
1. **Custom Survey Wrapper / UI:** Build your own front-end package that fetches the survey (via API or local cache), renders it, and captures user responses.
2. **Analysis & Reporting:** Process incoming webhook data or fetched responses in your own analytics, data warehouse, or visualization tools. You can still make use of Formbricks to view Survey stats and data, but any type of custom dashboards is currently not supported.
## Why choose this approach?
1. **Your UI, your brand:** You take full control of survey look, feel, transitions, validations, and logic in your application stack.
2. **Separation of concerns:** Formbricks functions like a specialized “Backend-as-a-Service” for survey schemas and response handling; you control the frontend and analytics.
3. **OSS, self-hostable**: With Formbricks being open source, you can self-host without vendor lock-in.
## Core components
1. **Formbricks Backend:** Use the Formbricks app or Management API to create surveys (questions, flows, locales, validations).
2. **Your UI Survey Package:** Renders your custom UI, collects the data and sends to Formbricks backend using Formbricks API. For inspiration, you can start looking [here](https://github.com/formbricks/formbricks/tree/main/packages/surveys). With an active Enterprise license you can even fork our surveys package, make changes and keep them private to your organization (freed from AGPL obligation to also release your changes under AGPL)
3. **Webhook Integration:** Using in-built Webhook integration forward the data to your Analysis tool or Data warehouse.
4. **Your Analysis Tool / Data Warehouse:** Receive all the data from Formbricks integration and process it for analysis.
## Data Flow
### **Create Survey with Formbricks:**
Create a survey in Formbricks (UI) or programmatically via the Management API. Read more about the API endpoint [here](https://formbricks.com/docs/api-reference/management-api--survey/create-survey).
Returns: Full survey object with id, schema, and configuration.
⚠️ Backend only: Requires API key \- call from your server, not client-side.
```javascript
POST /api/v1/management/surveys
Headers:
x-api-key: <your-api-key>
Content-Type: application/json
Body:
{
"environmentId": "your-environment-id",
"type": "link",
"name": "Customer Feedback Survey",
"questions": [
...
]
}
```
### **Fetch Survey Schema:**
Get the survey schema using the Formbricks API. Read more about the API endpoint [here](https://formbricks.com/docs/api-reference/management-api--survey/get-survey-by-id).
GET /api/v1/management/surveys/{surveyId}
Headers: x-api-key: \<your-api-key\>
####
**Returns**: Complete survey JSON schema including:
- Questions array with types, logic, and validation
- Display settings and styling
- Languages and translations
- Branching/skip logic
- Thank you pages and redirects
#### **Implementation Options:**
**Option A (Live)**: Your backend fetches at runtime and serves to your UI
1. Fresh data on every request
2. Requires backend proxy endpoint
⚠️ Backend only: API key required, cannot be called from browser.
**Optional:**: Store survey JSON in your CDN/storage
1. Faster client load times
2. Periodically refresh from Management API
3. Best for high-traffic scenarios
⚠️ Backend only: API key required, cannot be called from browser.
**Option B (Client Environment API)**: You can fetch all the survey schema and surveys from the Client side using the Client Environment API. However, this only works for Website & App surveys since they are the only ones that are made public on the Client API for our SDK to pull into an app. Make sure that:
1. Survey type: Website & App
2. Recontact Options: Overwrite Global Waiting Time & Always show
3. Targeting: None
These are **necessary requirements** for the survey to show up in the endpoint.
More about the Endpoint [here](https://formbricks.com/docs/api-reference/client-api--environment/get-environment-state).
```javascript
GET /api/v1/client/{environmentId}/environment
Headers:
Content-Type: application/json
Body:
{
"data": {
"actionClasses": [
{ ... },
{ ... }
],
"project": {
"id": "<project_id>",
...
},
"surveys": [
{
"id": "<survey_id>",
"name": "Start from scratch",
"status": "inProgress",
"question": "What would you like to know?",
"trigger": "code action",
"ending": "Thank you! We appreciate your feedback."
}
]
}
}
```
### **Render Survey with Your Custom UI:**
Your frontend receives the survey JSON and renders it using your own UI components.
For inspiration, you can start looking [here](https://github.com/formbricks/formbricks/tree/main/packages/surveys). With an active Enterprise license you can even fork our surveys package, make changes and keep them private to your organization (freed from AGPL obligation to also release your changes under AGPL)
* Question rendering based on type (openText, multipleChoiceSingle, rating, etc.)
* Skip logic and conditional branching
* Input validation
* Progress tracking
* Custom styling and branding
### **Submit Responses to Formbricks:**
#### **Client-side Submission (Recommended):**
Post responses directly from the browser to Formbricks Client API. Read more about it [here](https://formbricks.com/docs/api-reference/client-api--response/create-response).
✅ No authentication required \- Safe for browser/mobile apps.
```javascript
POST /api/v1/client/{environmentId}/responses
Headers:
Content-Type: application/json
Body:
{
"surveyId": "survey-xyz",
"data": {
"question-id-1": "Customer's answer",
"question-id-2": 5,
"question-id-3": ["option1", "option2"]
},
"finished": true
}
```
#### **Server-side Submission (Alternative):**
Proxy responses through your backend.
Use when: You need server-side validation, PII handling, or response enrichment before storage.
### **Consume Analytics & Response Data:**
#### **Option A: Real-time Webhooks (Recommended):**
Configure webhooks in Formbricks to push response data to your system. Read more about Webhooks [here](https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhooks).
1. Go to Formbricks Settings → Webhooks
2. Add your endpoint URL: https://your-domain.com/webhooks/formbricks
3. Select triggers:
* responseCreated \- New response started
* responseUpdated \- Response in progress
* responseFinished \- Response completed
**Webhook payload example:**
```javascript
{
"event": "responseFinished",
"data": {
"id": "response-123",
"surveyId": "survey-xyz",
"data": {
"question-id-1": "answer"
...
},
"createdAt": "2025-01-15T10:30:00Z",
"finished": true
}
}
```
Forward to your analytics tool, data warehouse, or CRM in real-time.
#### **Option B: Pull from API on Demand:**
Fetch responses periodically from your backend, read more about the Endpoint [here](https://formbricks.com/docs/api-reference/management-api--response/get-survey-responses).
```javascript
GET /api/v1/management/responses?surveyId={surveyId}
Headers:
x-api-key: <your-api-key>
```
@@ -1,160 +0,0 @@
import { Prisma } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { MigrationScript } from "../../src/scripts/migration-runner";
type I18nString = Record<string, string>;
interface SurveyQuestion {
type: string;
html?: I18nString;
subheader?: I18nString;
[key: string]: unknown;
}
interface WelcomeCard {
html?: I18nString;
subheader?: I18nString;
[key: string]: unknown;
}
interface SurveyRecord {
id: string;
questions: SurveyQuestion[];
welcomeCard?: WelcomeCard;
}
const processCtaOrConsentQuestion = (question: SurveyQuestion): boolean => {
// Only process CTA and Consent questions
if (question.type !== "cta" && question.type !== "consent") {
return false;
}
// If html field exists, move it to subheader
if (question.html) {
question.subheader = question.html;
delete question.html; // Remove the old html field
return true;
}
return false;
};
const processWelcomeCard = (welcomeCard: WelcomeCard): boolean => {
// If html field exists, move it to subheader for consistency with ending cards
if (welcomeCard.html) {
welcomeCard.subheader = welcomeCard.html;
delete welcomeCard.html; // Remove the old html field
return true;
}
return false;
};
export const moveHtmlToSubheaderForCtaAndConsent: MigrationScript = {
type: "data",
id: "htm2sub4ctacnsnt1014",
name: "20251014110903_move_html_to_subheader_for_cta_consent_and_welcome_card",
run: async ({ tx }) => {
// Select only surveys that actually have html fields to migrate
// Check for CTA/Consent questions with html field, or welcomeCard with html field
const surveys = await tx.$queryRaw<SurveyRecord[]>`
SELECT id, questions, "welcomeCard"
FROM "Survey"
WHERE (
-- Check if any question has type cta/consent AND has html field
EXISTS (
SELECT 1
FROM jsonb_array_elements(questions) AS q
WHERE (q->>'type' = 'cta' OR q->>'type' = 'consent')
AND q->'html' IS NOT NULL
AND jsonb_typeof(q->'html') = 'object'
)
-- Or welcomeCard has html field
OR (
"welcomeCard" IS NOT NULL
AND "welcomeCard"->'html' IS NOT NULL
AND jsonb_typeof("welcomeCard"->'html') = 'object'
)
)
`;
if (surveys.length === 0) {
logger.info("No surveys found that need migration");
return;
}
logger.info(`Found ${surveys.length.toString()} surveys to process`);
// Process surveys and collect updates
const updates: { id: string; questions?: SurveyQuestion[]; welcomeCard?: WelcomeCard }[] = [];
for (const survey of surveys) {
let questionsUpdated = false;
let welcomeCardUpdated = false;
// Process questions
for (const question of survey.questions) {
const wasUpdated = processCtaOrConsentQuestion(question);
if (wasUpdated) questionsUpdated = true;
}
// Process welcome card
if (survey.welcomeCard && typeof survey.welcomeCard === "object") {
welcomeCardUpdated = processWelcomeCard(survey.welcomeCard);
}
if (questionsUpdated || welcomeCardUpdated) {
const update: { id: string; questions?: SurveyQuestion[]; welcomeCard?: WelcomeCard } = {
id: survey.id,
};
if (questionsUpdated) update.questions = survey.questions;
if (welcomeCardUpdated) update.welcomeCard = survey.welcomeCard;
updates.push(update);
}
}
if (updates.length === 0) {
logger.info("No surveys needed updating");
return;
}
logger.info(`Updating ${updates.length.toString()} surveys`);
// Execute updates in batches using bulk UPDATE with VALUES
// This is much faster than individual updates (1 query per batch instead of N queries)
const BATCH_SIZE = 10000;
let updatedCount = 0;
for (let i = 0; i < updates.length; i += BATCH_SIZE) {
const batch = updates.slice(i, i + BATCH_SIZE);
// Build parameterized VALUES list using Prisma.sql
const valuesTuples = batch.map((update) => {
const questionsJson = update.questions ? JSON.stringify(update.questions) : null;
const welcomeCardJson = update.welcomeCard ? JSON.stringify(update.welcomeCard) : null;
return Prisma.sql`(${update.id}, ${questionsJson}::jsonb, ${welcomeCardJson}::jsonb)`;
});
// Join all tuples into a single VALUES clause
const valuesClause = Prisma.join(valuesTuples, ", ");
// Execute single bulk UPDATE query
await tx.$executeRaw`
UPDATE "Survey" AS s
SET
questions = COALESCE(v.questions, s.questions),
"welcomeCard" = COALESCE(v."welcomeCard", s."welcomeCard")
FROM (VALUES ${valuesClause}) AS v(id, questions, "welcomeCard")
WHERE s.id = v.id::text
`;
updatedCount += batch.length;
logger.info(`Progress: ${updatedCount.toString()}/${updates.length.toString()} surveys updated`);
}
logger.info(
`Successfully updated ${updatedCount.toString()} surveys - moved html to subheader for CTA/Consent questions and welcome cards`
);
},
};
@@ -17,7 +17,7 @@ const migrationsDir = path.resolve(__dirname, "../../migration");
async function createMigration(): Promise<void> {
// Log the full path to verify directory location
logger.info({ migrationsDir }, "Migrations Directory Full Path");
logger.info(migrationsDir, "Migrations Directory Full Path");
// Check if migrations directory exists, create if not
const hasAccess = await fs
@@ -27,7 +27,7 @@ async function createMigration(): Promise<void> {
if (!hasAccess) {
await fs.mkdir(migrationsDir, { recursive: true });
logger.info({ migrationsDir }, `Created migrations directory`);
logger.info(`Created migrations directory: ${migrationsDir}`);
}
const migrationNameSpaced = await promptForMigrationName();
@@ -60,11 +60,11 @@ async function createMigration(): Promise<void> {
// Create the migration directory
await fs.mkdir(fullMigrationPath, { recursive: true });
logger.info({ fullMigrationPath }, "Created migration directory");
logger.info(fullMigrationPath, "Created migration directory");
// Create the migration file
await fs.writeFile(filePath, getTemplateContent(migrationFunctionName, migrationNameTimestamped));
logger.info({ filePath }, "New migration created");
logger.info(filePath, "New migration created");
}
function promptForMigrationName(): Promise<string> {
+1 -1
View File
@@ -73,7 +73,7 @@ const ZSurveyBase = z.object({
timeToFinish: z.boolean(),
showResponseCount: z.boolean(),
headline: z.record(z.string()).optional(),
subheader: z.record(z.string()).optional(),
html: z.record(z.string()).optional(),
fileUrl: z.string().optional(),
buttonLabel: z.record(z.string()).optional(),
videoUrl: z.string().optional(),
@@ -1,7 +1,5 @@
import DOMPurify from "isomorphic-dompurify";
import { useTranslation } from "react-i18next";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { isValidHTML } from "@/lib/html-utils";
interface HeadlineProps {
headline: string;
@@ -9,12 +7,8 @@ interface HeadlineProps {
required?: boolean;
alignTextCenter?: boolean;
}
export function Headline({ headline, questionId, required = true, alignTextCenter = false }: HeadlineProps) {
const { t } = useTranslation();
const isHeadlineHtml = isValidHTML(headline);
const safeHtml = isHeadlineHtml && headline ? DOMPurify.sanitize(headline, { ADD_ATTR: ["target"] }) : "";
return (
<label htmlFor={questionId} className="fb-text-heading fb-mb-[3px] fb-flex fb-flex-col">
{!required && (
@@ -28,17 +22,9 @@ export function Headline({ headline, questionId, required = true, alignTextCente
<div
className={`fb-flex fb-items-center ${alignTextCenter ? "fb-justify-center" : "fb-justify-between"}`}
dir="auto">
{isHeadlineHtml ? (
<div
data-testid="fb__surveys__headline-text-test"
className="fb-htmlbody fb-text-base"
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
) : (
<p data-testid="fb__surveys__headline-text-test" className="fb-text-base fb-font-semibold">
{headline}
</p>
)}
<p data-testid="fb__surveys__headline-text-test" className="fb-text-base fb-font-semibold">
{headline}
</p>
</div>
</label>
);
@@ -0,0 +1,94 @@
import "@testing-library/jest-dom/vitest";
import { render, waitFor } from "@testing-library/preact";
import DOMPurify from "isomorphic-dompurify";
import { describe, expect, test, vi } from "vitest";
import { HtmlBody } from "./html-body";
// Mock DOMPurify to test sanitization
vi.mock("isomorphic-dompurify", () => ({
default: {
sanitize: vi.fn((html) => html), // Pass through the HTML for testing
},
}));
describe("HtmlBody", () => {
const defaultProps = {};
test("renders sanitized HTML content", async () => {
const htmlString = "<p>Test content</p>";
const { container } = render(<HtmlBody {...defaultProps} htmlString={htmlString} />);
await waitFor(() => {
const span = container.querySelector("span");
expect(span).toBeInTheDocument();
expect(span).toHaveClass("fb-htmlbody", "fb-break-words");
expect(span).toHaveAttribute("dir", "auto");
expect(span?.innerHTML).toBe(htmlString);
});
expect(DOMPurify.sanitize).toHaveBeenCalledWith(htmlString, { ADD_ATTR: ["target"] });
});
test("returns null when htmlString is not provided", () => {
const { container } = render(<HtmlBody {...defaultProps} />);
expect(container.firstChild).toBeNull();
});
test("returns null for empty editor paragraph", async () => {
const emptyEditorHtml = '<p class="fb-editor-paragraph"><br></p>';
const { container } = render(<HtmlBody {...defaultProps} htmlString={emptyEditorHtml} />);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
});
test("handles complex HTML with attributes", async () => {
const complexHtml = `
<div>
<h1>Title</h1>
<p><strong>Bold text</strong></p>
<a href="https://example.com" target="_blank">Link</a>
</div>
`;
const { container } = render(<HtmlBody {...defaultProps} htmlString={complexHtml} />);
await waitFor(() => {
const span = container.querySelector("span");
expect(span).toBeInTheDocument();
expect(span?.innerHTML).toBe(complexHtml);
});
expect(DOMPurify.sanitize).toHaveBeenCalledWith(complexHtml, { ADD_ATTR: ["target"] });
});
test("updates content when htmlString prop changes", async () => {
const initialHtml = "<p>Initial content</p>";
const { container, rerender } = render(<HtmlBody {...defaultProps} htmlString={initialHtml} />);
await waitFor(() => {
const span = container.querySelector("span");
expect(span?.innerHTML).toBe(initialHtml);
});
const updatedHtml = "<p>Updated content</p>";
rerender(<HtmlBody {...defaultProps} htmlString={updatedHtml} />);
await waitFor(() => {
const span = container.querySelector("span");
expect(span?.innerHTML).toBe(updatedHtml);
});
expect(DOMPurify.sanitize).toHaveBeenCalledWith(updatedHtml, { ADD_ATTR: ["target"] });
});
test("applies className using cn utility", async () => {
const htmlString = "<p>Test content</p>";
const { container } = render(<HtmlBody {...defaultProps} htmlString={htmlString} />);
await waitFor(() => {
const span = container.querySelector("span");
expect(span).toHaveClass("fb-htmlbody", "fb-break-words");
});
});
});
@@ -0,0 +1,28 @@
import DOMPurify from "isomorphic-dompurify";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface HtmlBodyProps {
readonly htmlString?: string;
}
export function HtmlBody({ htmlString }: HtmlBodyProps) {
const [safeHtml, setSafeHtml] = useState("");
useEffect(() => {
if (htmlString) {
setSafeHtml(DOMPurify.sanitize(htmlString, { ADD_ATTR: ["target"] }));
}
}, [htmlString]);
if (!htmlString) return null;
if (safeHtml === `<p class="fb-editor-paragraph"><br></p>`) return null;
return (
<span
className={cn("fb-htmlbody fb-break-words")} // styles are in global.css
dangerouslySetInnerHTML={{ __html: safeHtml }}
dir="auto"
/>
);
}
@@ -177,6 +177,7 @@ describe("QuestionConditional", () => {
required: true,
subheader: { default: "" },
format: "M-d-y" as const,
html: { default: "" },
};
render(<QuestionConditional {...baseProps} question={question} value="" />);
@@ -10,23 +10,30 @@ describe("Subheader", () => {
test("renders subheader text when provided", () => {
const subheaderText = "Test subheader text";
const questionId = "q1";
const { container } = render(<Subheader subheader={subheaderText} questionId={questionId} />);
render(<Subheader subheader={subheaderText} questionId={questionId} />);
const labelElement = container.querySelector(`label[for="${questionId}"]`);
expect(labelElement).toBeInTheDocument();
expect(labelElement?.textContent).toBe(subheaderText);
const textSpan = screen.getByText(subheaderText);
expect(textSpan).toBeInTheDocument();
expect(textSpan.tagName).toBe("SPAN");
const subheaderElement = screen.getByText(subheaderText);
expect(subheaderElement).toBeInTheDocument();
expect(subheaderElement.tagName).toBe("LABEL");
expect(subheaderElement).toHaveAttribute("for", questionId);
});
test("returns null when no subheader text provided", () => {
test("renders empty label when no subheader text provided", () => {
const questionId = "q1";
const { container } = render(<Subheader questionId={questionId} />);
const subheaderElement = container.querySelector(`label[for="${questionId}"]`);
expect(subheaderElement).not.toBeInTheDocument();
expect(subheaderElement).toBeInTheDocument();
expect(subheaderElement).toHaveClass(
"fb-text-subheading",
"fb-block",
"fb-break-words",
"fb-text-sm",
"fb-font-normal",
"fb-leading-6"
);
expect(subheaderElement).toHaveAttribute("dir", "auto");
expect(subheaderElement?.textContent).toBe("");
});
test("applies correct styling classes", () => {
@@ -49,33 +56,4 @@ describe("Subheader", () => {
const subheaderElement = container.querySelector('label[for="q1"]');
expect(subheaderElement).toHaveAttribute("dir", "auto");
});
test("renders HTML content safely when provided", () => {
const htmlSubheader = "<p><strong>Bold text</strong></p>";
const questionId = "q1";
const { container } = render(<Subheader subheader={htmlSubheader} questionId={questionId} />);
const labelElement = container.querySelector(`label[for="${questionId}"]`);
expect(labelElement).toBeInTheDocument();
const htmlSpan = container.querySelector(".fb-htmlbody");
expect(htmlSpan).toBeInTheDocument();
expect(htmlSpan?.innerHTML).toContain("<strong>Bold text</strong>");
});
test("renders plain text in span when no HTML detected", () => {
const plainText = "Plain text without HTML";
const questionId = "q1";
const { container } = render(<Subheader subheader={plainText} questionId={questionId} />);
const labelElement = container.querySelector(`label[for="${questionId}"]`);
expect(labelElement).toBeInTheDocument();
const htmlSpan = container.querySelector(".fb-htmlbody");
expect(htmlSpan).not.toBeInTheDocument();
const textSpan = screen.getByText(plainText);
expect(textSpan.tagName).toBe("SPAN");
expect(textSpan).not.toHaveClass("fb-htmlbody");
});
});
@@ -1,6 +1,4 @@
import DOMPurify from "isomorphic-dompurify";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { isValidHTML } from "@/lib/html-utils";
interface SubheaderProps {
subheader?: string;
@@ -8,22 +6,12 @@ interface SubheaderProps {
}
export function Subheader({ subheader, questionId }: SubheaderProps) {
const isHtml = subheader ? isValidHTML(subheader) : false;
const safeHtml = isHtml && subheader ? DOMPurify.sanitize(subheader, { ADD_ATTR: ["target"] }) : "";
if (!subheader) return null;
return (
<label
htmlFor={questionId}
className="fb-text-subheading fb-block fb-break-words fb-text-sm fb-font-normal fb-leading-6"
data-testid="subheader"
dir="auto">
{isHtml ? (
<span className="fb-htmlbody" dangerouslySetInnerHTML={{ __html: safeHtml }} />
) : (
<span>{subheader}</span>
)}
{subheader}
</label>
);
}
@@ -681,7 +681,7 @@ export function Survey({
<WelcomeCard
key="start"
headline={localSurvey.welcomeCard.headline}
subheader={localSurvey.welcomeCard.subheader}
html={localSurvey.welcomeCard.html}
fileUrl={localSurvey.welcomeCard.fileUrl}
buttonLabel={localSurvey.welcomeCard.buttonLabel}
onSubmit={onSubmit}
@@ -26,7 +26,7 @@ describe("WelcomeCard", () => {
const defaultProps = {
headline: { default: "Welcome to our survey" },
subheader: { default: "This is a test survey" },
html: { default: "This is a test survey" },
buttonLabel: { default: "Start" },
onSubmit: vi.fn(),
survey: mockSurvey,
@@ -38,28 +38,17 @@ describe("WelcomeCard", () => {
};
test("renders welcome card with basic content", () => {
const { container, getByTestId } = render(<WelcomeCard {...defaultProps} />);
const { container } = render(<WelcomeCard {...defaultProps} />);
expect(container.querySelector(".fb-text-heading")).toHaveTextContent("Welcome to our survey");
expect(getByTestId("subheader")).toHaveTextContent("This is a test survey");
expect(container.querySelector(".fb-htmlbody")).toHaveTextContent("This is a test survey");
expect(container.querySelector("button")).toHaveTextContent("Start");
});
test("shows time to complete when timeToFinish is true", () => {
const propsWithTimeOnly = {
...defaultProps,
survey: {
...mockSurvey,
welcomeCard: {
...mockSurvey.welcomeCard,
timeToFinish: true,
showResponseCount: false,
},
},
};
const { getByTestId } = render(<WelcomeCard {...propsWithTimeOnly} />);
const { container } = render(<WelcomeCard {...defaultProps} />);
const timeDisplay = getByTestId("fb__surveys__welcome-card__time-display");
const timeDisplay = container.querySelector(".fb-text-subheading");
expect(timeDisplay).toBeInTheDocument();
expect(timeDisplay).toHaveTextContent(/common.takes/);
});
@@ -111,48 +100,26 @@ describe("WelcomeCard", () => {
test("calculates time to complete correctly for different survey lengths", () => {
// Test short survey (2 questions)
const shortSurvey = {
...mockSurvey,
welcomeCard: {
...mockSurvey.welcomeCard,
timeToFinish: true,
showResponseCount: false,
},
};
const { getByTestId, unmount } = render(<WelcomeCard {...defaultProps} survey={shortSurvey} />);
const timeDisplay = getByTestId("fb__surveys__welcome-card__time-display");
const { container } = render(<WelcomeCard {...defaultProps} />);
const timeDisplay = container.querySelector(".fb-text-subheading");
expect(timeDisplay).toHaveTextContent(/common.takes common.less_than_x_minutes/);
unmount();
// Test medium survey (12 questions)
const mediumSurvey = {
...mockSurvey,
questions: Array(12).fill({ id: "q", logic: [] }),
welcomeCard: {
...mockSurvey.welcomeCard,
timeToFinish: true,
showResponseCount: false,
},
};
const { getByTestId: getByTestIdMedium, unmount: unmountMedium } = render(
<WelcomeCard {...defaultProps} survey={mediumSurvey} />
);
const mediumTimeDisplay = getByTestIdMedium("fb__surveys__welcome-card__time-display");
const { container: mediumContainer } = render(<WelcomeCard {...defaultProps} survey={mediumSurvey} />);
const mediumTimeDisplay = mediumContainer.querySelector(".fb-text-subheading");
expect(mediumTimeDisplay).toHaveTextContent(/common.takes common.x_minutes/);
unmountMedium();
// Test long survey (25 questions)
const longSurvey = {
...mockSurvey,
questions: Array(25).fill({ id: "q", logic: [] }),
welcomeCard: {
...mockSurvey.welcomeCard,
timeToFinish: true,
showResponseCount: false,
},
};
const { getByTestId: getByTestIdLong } = render(<WelcomeCard {...defaultProps} survey={longSurvey} />);
const longTimeDisplay = getByTestIdLong("fb__surveys__welcome-card__time-display");
const { container: longContainer } = render(<WelcomeCard {...defaultProps} survey={longSurvey} />);
const longTimeDisplay = longContainer.querySelector(".fb-text-subheading");
expect(longTimeDisplay).toHaveTextContent(/common.takes common.x_plus_minutes/);
});
@@ -180,7 +147,7 @@ describe("WelcomeCard", () => {
const minimalProps = {
...defaultProps,
headline: undefined,
subheader: undefined,
html: undefined,
buttonLabel: undefined,
fileUrl: undefined,
responseCount: undefined,
@@ -258,34 +225,23 @@ describe("WelcomeCard", () => {
...mockSurvey,
questions: [{ id: "dummy", logic: [] }], // Add dummy question to avoid logic error
endings: [],
welcomeCard: {
...mockSurvey.welcomeCard,
timeToFinish: true,
showResponseCount: false,
},
};
const { getByTestId: getByTestIdEmpty, unmount: unmountEmpty } = render(
const { container: emptyContainer } = render(
<WelcomeCard {...defaultProps} survey={emptyQuestionsSurvey} />
);
expect(getByTestIdEmpty("fb__surveys__welcome-card__time-display")).toHaveTextContent(
expect(emptyContainer.querySelector(".fb-text-subheading")).toHaveTextContent(
/common.takes common.less_than_x_minutes/
);
unmountEmpty();
// Test with exactly 24 questions (6 minutes boundary)
const boundaryQuestionsSurvey = {
...mockSurvey,
questions: Array(24).fill({ id: "q", logic: [] }),
welcomeCard: {
...mockSurvey.welcomeCard,
timeToFinish: true,
showResponseCount: false,
},
};
const { getByTestId: getByTestIdBoundary } = render(
const { container: boundaryContainer } = render(
<WelcomeCard {...defaultProps} survey={boundaryQuestionsSurvey} />
);
expect(getByTestIdBoundary("fb__surveys__welcome-card__time-display")).toHaveTextContent(
expect(boundaryContainer.querySelector(".fb-text-subheading")).toHaveTextContent(
/common.takes common.x_minutes/
);
});
@@ -294,15 +250,15 @@ describe("WelcomeCard", () => {
const localizedProps = {
...defaultProps,
headline: { default: "Welcome", es: "Bienvenido" },
subheader: { default: "Test", es: "Prueba" },
html: { default: "Test", es: "Prueba" },
buttonLabel: { default: "Start", es: "Comenzar" },
languageCode: "es",
};
const { container, getByTestId } = render(<WelcomeCard {...localizedProps} />);
const { container } = render(<WelcomeCard {...localizedProps} />);
expect(container.querySelector(".fb-text-heading")).toHaveTextContent("Bienvenido");
expect(getByTestId("subheader")).toHaveTextContent("Prueba");
expect(container.querySelector(".fb-htmlbody")).toHaveTextContent("Prueba");
expect(container.querySelector("button")).toHaveTextContent("Comenzar");
});
@@ -9,11 +9,11 @@ import { getLocalizedValue } from "@/lib/i18n";
import { replaceRecallInfo } from "@/lib/recall";
import { calculateElementIdx } from "@/lib/utils";
import { Headline } from "./headline";
import { Subheader } from "./subheader";
import { HtmlBody } from "./html-body";
interface WelcomeCardProps {
headline?: TI18nString;
subheader?: TI18nString;
html?: TI18nString;
fileUrl?: string;
buttonLabel?: TI18nString;
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
@@ -65,7 +65,7 @@ function UsersIcon() {
export function WelcomeCard({
headline,
subheader,
html,
fileUrl,
buttonLabel,
onSubmit,
@@ -150,13 +150,8 @@ export function WelcomeCard({
headline={replaceRecallInfo(getLocalizedValue(headline, languageCode), responseData, variablesData)}
questionId="welcomeCard"
/>
<Subheader
subheader={replaceRecallInfo(
getLocalizedValue(subheader, languageCode),
responseData,
variablesData
)}
questionId="welcomeCard"
<HtmlBody
htmlString={replaceRecallInfo(getLocalizedValue(html, languageCode), responseData, variablesData)}
/>
<div className="fb-mt-4 fb-flex fb-gap-4 fb-pt-4">
<SubmitButton
@@ -174,9 +169,7 @@ export function WelcomeCard({
/>
</div>
{timeToFinish && !showResponseCount ? (
<div
className="fb-items-center fb-text-subheading fb-my-4 fb-flex"
data-testid="fb__surveys__welcome-card__time-display">
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs">
<span>
@@ -24,7 +24,7 @@ describe("ConsentQuestion", () => {
id: "consent-q",
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent Headline" },
subheader: { default: "Consent Subheader" },
html: { default: "This is the consent text" },
label: { default: "I agree to the terms" },
buttonLabel: { default: "Submit" },
backButtonLabel: { default: "Back" },
@@ -4,8 +4,8 @@ import type { TSurveyConsentQuestion, TSurveyQuestionId } from "@formbricks/type
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { HtmlBody } from "@/components/general/html-body";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
@@ -75,10 +75,7 @@ export function ConsentQuestion({
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<HtmlBody htmlString={getLocalizedValue(question.html, languageCode) || ""} />
<label
ref={consentRef}
tabIndex={isCurrent ? 0 : -1}
@@ -27,8 +27,8 @@ vi.mock("@/components/general/headline", () => ({
Headline: vi.fn(({ headline }) => <div data-testid="headline">{headline}</div>),
}));
vi.mock("@/components/general/subheader", () => ({
Subheader: vi.fn(({ subheader }) => <div data-testid="subheader">{subheader}</div>),
vi.mock("@/components/general/html-body", () => ({
HtmlBody: vi.fn(({ htmlString }) => <div data-testid="html-body">{htmlString}</div>),
}));
vi.mock("@/components/general/question-media", () => ({
@@ -58,7 +58,7 @@ describe("CTAQuestion", () => {
id: "q1",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "Test Headline" },
subheader: { default: "Test Subheader" },
html: { default: "Test HTML content" },
buttonLabel: { default: "Click Me" },
dismissButtonLabel: { default: "Skip This" },
backButtonLabel: { default: "Go Back" },
@@ -86,7 +86,7 @@ describe("CTAQuestion", () => {
test("renders correctly without media", () => {
render(<CTAQuestion {...mockProps} />);
expect(screen.getByTestId("headline")).toBeInTheDocument();
expect(screen.getByTestId("subheader")).toBeInTheDocument();
expect(screen.getByTestId("html-body")).toBeInTheDocument();
expect(screen.getByTestId("back-button")).toBeInTheDocument();
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
expect(screen.queryByTestId("question-media")).not.toBeInTheDocument();
@@ -1,11 +1,11 @@
import { useState } from "preact/hooks";
import { useState } from "react";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyCTAQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { HtmlBody } from "@/components/general/html-body";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
@@ -59,10 +59,7 @@ export function CTAQuestion({
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<HtmlBody htmlString={getLocalizedValue(question.html, languageCode)} />
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-start">
<SubmitButton
@@ -185,7 +185,10 @@ export function MatrixQuestion({
getLocalizedValue(column.label, languageCode)
: false
}
aria-label={`${getLocalizedValue(row.label, languageCode)} ${getLocalizedValue(
aria-label={`${getLocalizedValue(
question.headline,
languageCode
)}: ${getLocalizedValue(row.label, languageCode)} ${getLocalizedValue(
column.label,
languageCode
)}`}
@@ -103,7 +103,7 @@ const mockSurvey: TJsEnvironmentStateSurvey = {
welcomeCard: {
enabled: true,
headline: { default: "Welcome!" },
subheader: { default: "Welcome text" },
html: { default: "Welcome text" },
timeToFinish: false,
showResponseCount: false,
buttonLabel: { default: "Start" },
@@ -1,26 +0,0 @@
import { describe, expect, test } from "vitest";
import { isValidHTML } from "./html-utils";
describe("html-utils", () => {
describe("isValidHTML", () => {
test("should return false for empty string", () => {
expect(isValidHTML("")).toBe(false);
});
test("should return false for plain text", () => {
expect(isValidHTML("Hello World")).toBe(false);
});
test("should return true for HTML with tags", () => {
expect(isValidHTML("<p>Hello</p>")).toBe(true);
});
test("should return true for HTML with formatting", () => {
expect(isValidHTML("<p><strong>Bold text</strong></p>")).toBe(true);
});
test("should return true for complex HTML", () => {
expect(isValidHTML('<div class="test"><p>Test</p></div>')).toBe(true);
});
});
});
-22
View File
@@ -1,22 +0,0 @@
/**
* Lightweight HTML detection for browser environments
* Uses native DOMParser (built-in, 0 KB bundle size)
* @param str - The input string to test
* @returns true if the string contains valid HTML elements, false otherwise
* @note Returns false in non-browser environments (SSR, Node.js) where window is undefined
*/
export const isValidHTML = (str: string): boolean => {
// This should ideally never happen because the surveys package should be used in an environment where DOM is available
if (typeof globalThis?.window === "undefined") return false;
if (!str) return false;
try {
const doc = new DOMParser().parseFromString(str, "text/html");
const errorNode = doc.querySelector("parsererror");
if (errorNode) return false;
return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);
} catch {
return false;
}
};
+5 -5
View File
@@ -95,12 +95,12 @@ export const parseRecallInformation = (
}
if (
(question.type === TSurveyQuestionTypeEnum.CTA || question.type === TSurveyQuestionTypeEnum.Consent) &&
question.subheader &&
question.subheader[languageCode].includes("recall:") &&
modifiedQuestion.subheader
question.html &&
question.html[languageCode].includes("recall:") &&
modifiedQuestion.html
) {
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.subheader, languageCode),
modifiedQuestion.html[languageCode] = replaceRecallInfo(
getLocalizedValue(modifiedQuestion.html, languageCode),
responseData,
variables
);
+1 -1
View File
@@ -122,4 +122,4 @@ p.fb-editor-paragraph {
.grecaptcha-badge {
visibility: hidden;
}
}
-3
View File
@@ -37,9 +37,6 @@ const config = ({ mode }) => {
emptyOutDir: false,
minify: "terser",
rollupOptions: {
// Externalize node-html-parser to keep bundle size small (~53KB)
// It's pulled in via @formbricks/types but not used in browser runtime
external: ["node-html-parser"],
output: {
inlineDynamicImports: true,
},
+1 -2
View File
@@ -10,8 +10,7 @@
},
"dependencies": {
"@prisma/client": "6.14.0",
"zod": "3.24.4",
"node-html-parser": "7.0.1"
"zod": "3.24.4"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
+14 -5
View File
@@ -101,7 +101,7 @@ export const ZSurveyWelcomeCard = z
.object({
enabled: z.boolean(),
headline: ZI18nString.optional(),
subheader: ZI18nString.optional(),
html: ZI18nString.optional(),
fileUrl: z.string().optional(),
buttonLabel: ZI18nString.optional(),
timeToFinish: z.boolean().default(true),
@@ -554,6 +554,7 @@ export type TSurveyOpenTextQuestion = z.infer<typeof ZSurveyOpenTextQuestion>;
export const ZSurveyConsentQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionTypeEnum.Consent),
html: ZI18nString.optional(),
label: ZI18nString,
});
@@ -588,6 +589,7 @@ export type TSurveyNPSQuestion = z.infer<typeof ZSurveyNPSQuestion>;
export const ZSurveyCTAQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionTypeEnum.CTA),
html: ZI18nString.optional(),
buttonUrl: z.string().optional(),
buttonExternal: z.boolean(),
dismissButtonLabel: ZI18nString.optional(),
@@ -888,10 +890,10 @@ export const ZSurvey = z
}
}
if (welcomeCard.subheader && welcomeCard.subheader.default.trim() !== "") {
if (welcomeCard.html && welcomeCard.html.default.trim() !== "") {
multiLangIssue = validateCardFieldsForAllLanguages(
"welcomeCardSubheader",
welcomeCard.subheader,
"welcomeCardHtml",
welcomeCard.html,
languages,
"welcome"
);
@@ -928,7 +930,14 @@ export const ZSurvey = z
}
const defaultLanguageCode = "default";
const initialFieldsToValidate = ["buttonLabel", "upperLabel", "lowerLabel", "label", "placeholder"];
const initialFieldsToValidate = [
"html",
"buttonLabel",
"upperLabel",
"lowerLabel",
"label",
"placeholder",
];
let fieldsToValidate =
questionIndex === 0 || isBackButtonHidden
+3 -51
View File
@@ -1,4 +1,3 @@
import { parse } from "node-html-parser";
import { z } from "zod";
import type {
TActionJumpToQuestion,
@@ -11,47 +10,6 @@ import type {
TSurveyQuestionId,
} from "./types";
/**
* Checks if a string contains valid HTML markup
* @param str - The input string to test
* @returns true if the string contains valid HTML elements, false otherwise
*/
export const isValidHTML = (str: string): boolean => {
if (!str) return false;
try {
const root = parse(str);
// Check if there are any element nodes (not just text nodes)
// nodeType 1 = ELEMENT_NODE
return root.childNodes.some((node) => Number(node.nodeType) === 1);
} catch {
return false;
}
};
/**
* Extracts text content from an HTML string
* Works in both browser and Node.js using node-html-parser
* @param str - The input string (can be HTML or plain text)
* @returns The extracted text content without HTML tags
*/
export const getTextContent = (str: string): string => {
if (!str || str.trim() === "") return "";
if (isValidHTML(str)) {
try {
const root = parse(str);
const textContent = root.textContent;
return textContent.trim();
} catch {
// If parsing fails, treat as plain text
return str.trim();
}
}
return str.trim();
};
export const FORBIDDEN_IDS = [
"userId",
"source",
@@ -94,15 +52,9 @@ const validateLabelForAllLanguages = (label: TI18nString, surveyLanguages: TSurv
const languageCodes = extractLanguageCodes(enabledLanguages);
const languages = !languageCodes.length ? ["default"] : languageCodes;
const invalidLanguageCodes = languages.filter((language) => {
// Check if label exists and is not undefined
if (!label[language]) return true;
// Use getTextContent to extract text from HTML or plain text
// This ensures empty HTML like <p><br></p> is properly detected as empty
const textContent = getTextContent(label[language]);
return textContent.length === 0;
});
const invalidLanguageCodes = languages.filter(
(language) => !label[language] || label[language].trim() === ""
);
return invalidLanguageCodes.map((invalidLanguageCode) => {
if (invalidLanguageCode === "default") {
-11
View File
@@ -871,9 +871,6 @@ importers:
'@prisma/client':
specifier: 6.14.0
version: 6.14.0(prisma@6.14.0(magicast@0.3.5)(typescript@5.8.3))(typescript@5.8.3)
node-html-parser:
specifier: 7.0.1
version: 7.0.1
zod:
specifier: 3.24.4
version: 3.24.4
@@ -8123,9 +8120,6 @@ packages:
node-html-parser@6.1.13:
resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
node-html-parser@7.0.1:
resolution: {integrity: sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==}
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
@@ -19496,11 +19490,6 @@ snapshots:
css-select: 5.2.2
he: 1.2.0
node-html-parser@7.0.1:
dependencies:
css-select: 5.2.2
he: 1.2.0
node-releases@2.0.19: {}
nodemailer@7.0.9: {}