mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-21 19:39:28 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe07a0689c |
+2
-2
@@ -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(),
|
||||
|
||||
+1
-4
@@ -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>
|
||||
)}
|
||||
|
||||
+1
-2
@@ -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>
|
||||
|
||||
+1
-4
@@ -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>
|
||||
))}
|
||||
|
||||
+5
-10
@@ -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>
|
||||
|
||||
+1
-4
@@ -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"]
|
||||
)}
|
||||
|
||||
+1
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
+12
-21
@@ -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>
|
||||
);
|
||||
|
||||
+6
-9
@@ -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
-2
@@ -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"
|
||||
|
||||
+1
-2
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+23
-42
@@ -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 () => {
|
||||
|
||||
+8
-3
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
-160
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -122,4 +122,4 @@ p.fb-editor-paragraph {
|
||||
|
||||
.grecaptcha-badge {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Generated
-11
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user