mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-23 02:45:21 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe07a0689c |
+2
-2
@@ -105,7 +105,7 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[1],
|
id: reusableQuestionIds[1],
|
||||||
subheader: t("templates.star_rating_survey_question_2_html"),
|
html: t("templates.star_rating_survey_question_2_html"),
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -322,7 +322,7 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[1],
|
id: reusableQuestionIds[1],
|
||||||
subheader: t("templates.smileys_survey_question_2_html"),
|
html: t("templates.smileys_survey_question_2_html"),
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
|
|||||||
+1
-4
@@ -14,7 +14,6 @@ import {
|
|||||||
TIntegrationAirtableTables,
|
TIntegrationAirtableTables,
|
||||||
} from "@formbricks/types/integration/airtable";
|
} from "@formbricks/types/integration/airtable";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
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 { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
||||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
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));
|
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="ml-2">
|
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
|
||||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+1
-2
@@ -11,7 +11,6 @@ import {
|
|||||||
TIntegrationGoogleSheetsInput,
|
TIntegrationGoogleSheetsInput,
|
||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
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 { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
|
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
|
||||||
import {
|
import {
|
||||||
@@ -277,7 +276,7 @@ export const AddIntegrationModal = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 w-[30rem] truncate">
|
<span className="ml-2 w-[30rem] truncate">
|
||||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
{getLocalizedValue(question.headline, "default")}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-4
@@ -14,7 +14,6 @@ import {
|
|||||||
TIntegrationSlackInput,
|
TIntegrationSlackInput,
|
||||||
} from "@formbricks/types/integration/slack";
|
} from "@formbricks/types/integration/slack";
|
||||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
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 { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
import SlackLogo from "@/images/slacklogo.png";
|
import SlackLogo from "@/images/slacklogo.png";
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
@@ -282,9 +281,7 @@ export const AddChannelMappingModal = ({
|
|||||||
handleCheckboxChange(question.id);
|
handleCheckboxChange(question.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="ml-2">
|
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
|
||||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+5
-10
@@ -6,7 +6,6 @@ import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TResponseTableData } from "@formbricks/types/responses";
|
import { TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
@@ -55,9 +54,7 @@ const getQuestionColumnsData = (
|
|||||||
|
|
||||||
// Helper function to get localized question headline
|
// Helper function to get localized question headline
|
||||||
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
|
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
|
||||||
return getTextContent(
|
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
|
||||||
getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default")
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to render choice ID badges
|
// Helper function to render choice ID badges
|
||||||
@@ -86,7 +83,7 @@ const getQuestionColumnsData = (
|
|||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{getTextContent(getLocalizedValue(question.headline, "default")) +
|
{getLocalizedValue(question.headline, "default") +
|
||||||
" - " +
|
" - " +
|
||||||
getLocalizedValue(matrixRow.label, "default")}
|
getLocalizedValue(matrixRow.label, "default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -202,11 +199,9 @@ const getQuestionColumnsData = (
|
|||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[question.type]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[question.type]}</span>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{getTextContent(
|
{getLocalizedValue(
|
||||||
getLocalizedValue(
|
recallToHeadline(question.headline, survey, false, "default"),
|
||||||
recallToHeadline(question.headline, survey, false, "default"),
|
"default"
|
||||||
"default"
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-4
@@ -4,7 +4,6 @@ import { useTranslate } from "@tolgee/react";
|
|||||||
import { InboxIcon } from "lucide-react";
|
import { InboxIcon } from "lucide-react";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||||
@@ -31,9 +30,7 @@ export const QuestionSummaryHeader = ({
|
|||||||
<div className={"align-center flex justify-between gap-4"}>
|
<div className={"align-center flex justify-between gap-4"}>
|
||||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
||||||
{formatTextWithSlashes(
|
{formatTextWithSlashes(
|
||||||
getTextContent(
|
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"],
|
||||||
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
|
|
||||||
),
|
|
||||||
"@",
|
"@",
|
||||||
["text-lg"]
|
["text-lg"]
|
||||||
)}
|
)}
|
||||||
|
|||||||
+1
-2
@@ -33,7 +33,6 @@ import {
|
|||||||
TSurveyQuestionTypeEnum,
|
TSurveyQuestionTypeEnum,
|
||||||
TSurveySummary,
|
TSurveySummary,
|
||||||
} from "@formbricks/types/surveys/types";
|
} 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 { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
|
||||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||||
@@ -260,7 +259,7 @@ export const getSurveySummaryDropOff = (
|
|||||||
return {
|
return {
|
||||||
questionId: question.id,
|
questionId: question.id,
|
||||||
questionType: question.type,
|
questionType: question.type,
|
||||||
headline: getTextContent(getLocalizedValue(question.headline, "default")),
|
headline: getLocalizedValue(question.headline, "default"),
|
||||||
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
|
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
|
||||||
impressions: impressionsArr[index] || 0,
|
impressions: impressionsArr[index] || 0,
|
||||||
dropOffCount: dropOffArr[index] || 0,
|
dropOffCount: dropOffArr[index] || 0,
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ const mockSurvey: TSurvey = {
|
|||||||
welcomeCard: {
|
welcomeCard: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
headline: { default: "Welcome" },
|
headline: { default: "Welcome" },
|
||||||
subheader: { default: "" },
|
html: { default: "" },
|
||||||
timeToFinish: false,
|
timeToFinish: false,
|
||||||
showResponseCount: false,
|
showResponseCount: false,
|
||||||
buttonLabel: { default: "Start" },
|
buttonLabel: { default: "Start" },
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const mockSurvey: TSurvey = {
|
|||||||
createdBy: "cm98dg3xm000019hpubj39vfi",
|
createdBy: "cm98dg3xm000019hpubj39vfi",
|
||||||
status: "inProgress",
|
status: "inProgress",
|
||||||
welcomeCard: {
|
welcomeCard: {
|
||||||
subheader: {
|
html: {
|
||||||
default: "Thanks for providing your feedback - let's go!",
|
default: "Thanks for providing your feedback - let's go!",
|
||||||
},
|
},
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -168,7 +168,6 @@ export const mockSurvey: TSurvey = {
|
|||||||
triggers: [],
|
triggers: [],
|
||||||
segment: null,
|
segment: null,
|
||||||
followUps: mockFollowUps,
|
followUps: mockFollowUps,
|
||||||
metadata: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mockContactQuestion: TSurveyContactInfoQuestion = {
|
export const mockContactQuestion: TSurveyContactInfoQuestion = {
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ const baseSurvey: TSurvey = {
|
|||||||
autoComplete: null,
|
autoComplete: null,
|
||||||
segment: null,
|
segment: null,
|
||||||
pin: null,
|
pin: null,
|
||||||
metadata: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const attributes: TAttributes = {
|
const attributes: TAttributes = {
|
||||||
@@ -103,7 +102,7 @@ describe("replaceAttributeRecall", () => {
|
|||||||
welcomeCard: {
|
welcomeCard: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headline: { default: "Welcome, recall:name!" },
|
headline: { default: "Welcome, recall:name!" },
|
||||||
subheader: { default: "<p>Some content</p>" },
|
html: { default: "<p>Some content</p>" },
|
||||||
buttonLabel: { default: "Start" },
|
buttonLabel: { default: "Start" },
|
||||||
timeToFinish: false,
|
timeToFinish: false,
|
||||||
showResponseCount: false,
|
showResponseCount: false,
|
||||||
@@ -207,7 +206,7 @@ describe("replaceAttributeRecall", () => {
|
|||||||
welcomeCard: {
|
welcomeCard: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headline: { default: "Welcome!" },
|
headline: { default: "Welcome!" },
|
||||||
subheader: { default: "<p>Some content</p>" },
|
html: { default: "<p>Some content</p>" },
|
||||||
buttonLabel: { default: "Start" },
|
buttonLabel: { default: "Start" },
|
||||||
timeToFinish: false,
|
timeToFinish: false,
|
||||||
showResponseCount: false,
|
showResponseCount: false,
|
||||||
|
|||||||
@@ -313,7 +313,6 @@ describe("Survey Builder", () => {
|
|||||||
test("creates a consent question with required fields", () => {
|
test("creates a consent question with required fields", () => {
|
||||||
const question = buildConsentQuestion({
|
const question = buildConsentQuestion({
|
||||||
headline: "Consent Question",
|
headline: "Consent Question",
|
||||||
subheader: "",
|
|
||||||
label: "I agree to terms",
|
label: "I agree to terms",
|
||||||
t: mockT,
|
t: mockT,
|
||||||
});
|
});
|
||||||
@@ -321,7 +320,6 @@ describe("Survey Builder", () => {
|
|||||||
expect(question).toMatchObject({
|
expect(question).toMatchObject({
|
||||||
type: TSurveyQuestionTypeEnum.Consent,
|
type: TSurveyQuestionTypeEnum.Consent,
|
||||||
headline: { default: "Consent Question" },
|
headline: { default: "Consent Question" },
|
||||||
subheader: { default: "" },
|
|
||||||
label: { default: "I agree to terms" },
|
label: { default: "I agree to terms" },
|
||||||
buttonLabel: { default: "common.next" },
|
buttonLabel: { default: "common.next" },
|
||||||
backButtonLabel: { default: "common.back" },
|
backButtonLabel: { default: "common.back" },
|
||||||
@@ -369,7 +367,6 @@ describe("Survey Builder", () => {
|
|||||||
test("creates a CTA question with required fields", () => {
|
test("creates a CTA question with required fields", () => {
|
||||||
const question = buildCTAQuestion({
|
const question = buildCTAQuestion({
|
||||||
headline: "CTA Question",
|
headline: "CTA Question",
|
||||||
subheader: "",
|
|
||||||
buttonExternal: false,
|
buttonExternal: false,
|
||||||
t: mockT,
|
t: mockT,
|
||||||
});
|
});
|
||||||
@@ -377,7 +374,6 @@ describe("Survey Builder", () => {
|
|||||||
expect(question).toMatchObject({
|
expect(question).toMatchObject({
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
type: TSurveyQuestionTypeEnum.CTA,
|
||||||
headline: { default: "CTA Question" },
|
headline: { default: "CTA Question" },
|
||||||
subheader: { default: "" },
|
|
||||||
buttonLabel: { default: "common.next" },
|
buttonLabel: { default: "common.next" },
|
||||||
backButtonLabel: { default: "common.back" },
|
backButtonLabel: { default: "common.back" },
|
||||||
required: false,
|
required: false,
|
||||||
@@ -402,7 +398,7 @@ describe("Survey Builder", () => {
|
|||||||
const question = buildCTAQuestion({
|
const question = buildCTAQuestion({
|
||||||
id: "custom-id",
|
id: "custom-id",
|
||||||
headline: "CTA Question",
|
headline: "CTA Question",
|
||||||
subheader: "<p>Click the button</p>",
|
html: "<p>Click the button</p>",
|
||||||
buttonLabel: "Click me",
|
buttonLabel: "Click me",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
buttonUrl: "https://example.com",
|
buttonUrl: "https://example.com",
|
||||||
@@ -414,7 +410,7 @@ describe("Survey Builder", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(question.id).toBe("custom-id");
|
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.buttonLabel).toEqual({ default: "Click me" });
|
||||||
expect(question.buttonExternal).toBe(true);
|
expect(question.buttonExternal).toBe(true);
|
||||||
expect(question.buttonUrl).toBe("https://example.com");
|
expect(question.buttonUrl).toBe("https://example.com");
|
||||||
@@ -427,7 +423,6 @@ describe("Survey Builder", () => {
|
|||||||
test("handles external button with URL", () => {
|
test("handles external button with URL", () => {
|
||||||
const question = buildCTAQuestion({
|
const question = buildCTAQuestion({
|
||||||
headline: "CTA Question",
|
headline: "CTA Question",
|
||||||
subheader: "",
|
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
buttonUrl: "https://formbricks.com",
|
buttonUrl: "https://formbricks.com",
|
||||||
t: mockT,
|
t: mockT,
|
||||||
@@ -538,7 +533,7 @@ describe("Helper Functions", () => {
|
|||||||
const card = getDefaultWelcomeCard(mockT);
|
const card = getDefaultWelcomeCard(mockT);
|
||||||
expect(card.enabled).toBe(false);
|
expect(card.enabled).toBe(false);
|
||||||
expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" });
|
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" });
|
expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" });
|
||||||
// boolean flags
|
// boolean flags
|
||||||
expect(card.timeToFinish).toBe(false);
|
expect(card.timeToFinish).toBe(false);
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export const buildConsentQuestion = ({
|
|||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
headline: string;
|
headline: string;
|
||||||
subheader: string;
|
subheader?: string;
|
||||||
buttonLabel?: string;
|
buttonLabel?: string;
|
||||||
backButtonLabel?: string;
|
backButtonLabel?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
@@ -229,7 +229,7 @@ export const buildConsentQuestion = ({
|
|||||||
return {
|
return {
|
||||||
id: id ?? createId(),
|
id: id ?? createId(),
|
||||||
type: TSurveyQuestionTypeEnum.Consent,
|
type: TSurveyQuestionTypeEnum.Consent,
|
||||||
subheader: createI18nString(subheader, []),
|
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||||
headline: createI18nString(headline, []),
|
headline: createI18nString(headline, []),
|
||||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||||
@@ -242,7 +242,7 @@ export const buildConsentQuestion = ({
|
|||||||
export const buildCTAQuestion = ({
|
export const buildCTAQuestion = ({
|
||||||
id,
|
id,
|
||||||
headline,
|
headline,
|
||||||
subheader,
|
html,
|
||||||
buttonLabel,
|
buttonLabel,
|
||||||
buttonExternal,
|
buttonExternal,
|
||||||
backButtonLabel,
|
backButtonLabel,
|
||||||
@@ -255,7 +255,7 @@ export const buildCTAQuestion = ({
|
|||||||
id?: string;
|
id?: string;
|
||||||
headline: string;
|
headline: string;
|
||||||
buttonExternal: boolean;
|
buttonExternal: boolean;
|
||||||
subheader: string;
|
html?: string;
|
||||||
buttonLabel?: string;
|
buttonLabel?: string;
|
||||||
backButtonLabel?: string;
|
backButtonLabel?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
@@ -267,7 +267,7 @@ export const buildCTAQuestion = ({
|
|||||||
return {
|
return {
|
||||||
id: id ?? createId(),
|
id: id ?? createId(),
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
type: TSurveyQuestionTypeEnum.CTA,
|
||||||
subheader: createI18nString(subheader, []),
|
html: html ? createI18nString(html, []) : undefined,
|
||||||
headline: createI18nString(headline, []),
|
headline: createI18nString(headline, []),
|
||||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||||
@@ -364,7 +364,7 @@ export const getDefaultWelcomeCard = (t: TFnType): TSurveyWelcomeCard => {
|
|||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
headline: createI18nString(t("templates.default_welcome_card_headline"), []),
|
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"), []),
|
buttonLabel: createI18nString(t("templates.default_welcome_card_button_label"), []),
|
||||||
timeToFinish: false,
|
timeToFinish: false,
|
||||||
showResponseCount: false,
|
showResponseCount: false,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
TSurveyMetaFieldFilter,
|
TSurveyMetaFieldFilter,
|
||||||
} from "@formbricks/types/responses";
|
} from "@formbricks/types/responses";
|
||||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import {
|
import {
|
||||||
DateRange,
|
DateRange,
|
||||||
@@ -19,8 +18,6 @@ import {
|
|||||||
QuestionOptions,
|
QuestionOptions,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
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 = {
|
const conditionOptions = {
|
||||||
openText: ["is"],
|
openText: ["is"],
|
||||||
@@ -83,9 +80,7 @@ export const generateQuestionAndFilterOptions = (
|
|||||||
survey.questions.forEach((q) => {
|
survey.questions.forEach((q) => {
|
||||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||||
questionsOptions.push({
|
questionsOptions.push({
|
||||||
label: getTextContent(
|
label: q.headline,
|
||||||
getLocalizedValue(recallToHeadline(q.headline, survey, false, "default"), "default")
|
|
||||||
),
|
|
||||||
questionType: q.type,
|
questionType: q.type,
|
||||||
type: OptionsType.QUESTIONS,
|
type: OptionsType.QUESTIONS,
|
||||||
id: q.id,
|
id: q.id,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
|
|||||||
questions: [
|
questions: [
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[0],
|
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")],
|
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||||
headline: t("templates.card_abandonment_survey_question_1_headline"),
|
headline: t("templates.card_abandonment_survey_question_1_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
@@ -92,7 +92,6 @@ const cartAbandonmentSurvey = (t: TFnType): TTemplate => {
|
|||||||
id: reusableQuestionIds[1],
|
id: reusableQuestionIds[1],
|
||||||
logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")],
|
logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")],
|
||||||
headline: t("templates.card_abandonment_survey_question_6_headline"),
|
headline: t("templates.card_abandonment_survey_question_6_headline"),
|
||||||
subheader: "",
|
|
||||||
required: false,
|
required: false,
|
||||||
label: t("templates.card_abandonment_survey_question_6_label"),
|
label: t("templates.card_abandonment_survey_question_6_label"),
|
||||||
t,
|
t,
|
||||||
@@ -134,7 +133,7 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
|
|||||||
questions: [
|
questions: [
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[0],
|
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")],
|
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||||
headline: t("templates.site_abandonment_survey_question_2_headline"),
|
headline: t("templates.site_abandonment_survey_question_2_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
@@ -193,7 +192,6 @@ const siteAbandonmentSurvey = (t: TFnType): TTemplate => {
|
|||||||
id: reusableQuestionIds[1],
|
id: reusableQuestionIds[1],
|
||||||
logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")],
|
logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")],
|
||||||
headline: t("templates.site_abandonment_survey_question_7_headline"),
|
headline: t("templates.site_abandonment_survey_question_7_headline"),
|
||||||
subheader: "",
|
|
||||||
required: false,
|
required: false,
|
||||||
label: t("templates.site_abandonment_survey_question_7_label"),
|
label: t("templates.site_abandonment_survey_question_7_label"),
|
||||||
t,
|
t,
|
||||||
@@ -233,7 +231,7 @@ const productMarketFitSuperhuman = (t: TFnType): TTemplate => {
|
|||||||
questions: [
|
questions: [
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[0],
|
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")],
|
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||||
headline: t("templates.product_market_fit_superhuman_question_1_headline"),
|
headline: t("templates.product_market_fit_superhuman_question_1_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
@@ -411,7 +409,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[2],
|
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")],
|
logic: [createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isClicked")],
|
||||||
headline: t("templates.churn_survey_question_3_headline"),
|
headline: t("templates.churn_survey_question_3_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
@@ -431,7 +429,7 @@ const churnSurvey = (t: TFnType): TTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[4],
|
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")],
|
logic: [createJumpLogic(reusableQuestionIds[4], localSurvey.endings[0].id, "isClicked")],
|
||||||
headline: t("templates.churn_survey_question_5_headline"),
|
headline: t("templates.churn_survey_question_5_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
@@ -709,7 +707,7 @@ const improveTrialConversion = (t: TFnType): TTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[3],
|
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")],
|
logic: [createJumpLogic(reusableQuestionIds[3], localSurvey.endings[0].id, "isClicked")],
|
||||||
headline: t("templates.improve_trial_conversion_question_4_headline"),
|
headline: t("templates.improve_trial_conversion_question_4_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
@@ -804,7 +802,7 @@ const reviewPrompt = (t: TFnType): TTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[1],
|
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")],
|
logic: [createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isClicked")],
|
||||||
headline: t("templates.review_prompt_question_2_headline"),
|
headline: t("templates.review_prompt_question_2_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
@@ -842,7 +840,7 @@ const interviewPrompt = (t: TFnType): TTemplate => {
|
|||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
headline: t("templates.interview_prompt_question_1_headline"),
|
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"),
|
buttonLabel: t("templates.interview_prompt_question_1_button_label"),
|
||||||
buttonUrl: "https://cal.com/johannes",
|
buttonUrl: "https://cal.com/johannes",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
@@ -1345,7 +1343,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[2],
|
id: reusableQuestionIds[2],
|
||||||
subheader: t("templates.feedback_box_question_3_html"),
|
html: t("templates.feedback_box_question_3_html"),
|
||||||
logic: [
|
logic: [
|
||||||
createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isClicked"),
|
createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isClicked"),
|
||||||
createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isSkipped"),
|
createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isSkipped"),
|
||||||
@@ -2024,7 +2022,6 @@ const marketSiteClarity = (t: TFnType): TTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
headline: t("templates.market_site_clarity_question_3_headline"),
|
headline: t("templates.market_site_clarity_question_3_headline"),
|
||||||
subheader: "",
|
|
||||||
required: false,
|
required: false,
|
||||||
buttonLabel: t("templates.market_site_clarity_question_3_button_label"),
|
buttonLabel: t("templates.market_site_clarity_question_3_button_label"),
|
||||||
buttonUrl: "https://app.formbricks.com/auth/signup",
|
buttonUrl: "https://app.formbricks.com/auth/signup",
|
||||||
@@ -2671,7 +2668,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
|
|||||||
questions: [
|
questions: [
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[0],
|
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")],
|
logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||||
headline: t("templates.identify_sign_up_barriers_question_1_headline"),
|
headline: t("templates.identify_sign_up_barriers_question_1_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
@@ -2796,7 +2793,7 @@ const identifySignUpBarriers = (t: TFnType): TTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[8],
|
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"),
|
headline: t("templates.identify_sign_up_barriers_question_9_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
buttonUrl: "https://app.formbricks.com/auth/signup",
|
buttonUrl: "https://app.formbricks.com/auth/signup",
|
||||||
@@ -2968,7 +2965,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[2],
|
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"),
|
headline: t("templates.improve_newsletter_content_question_3_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
buttonUrl: "https://formbricks.com",
|
buttonUrl: "https://formbricks.com",
|
||||||
@@ -3004,7 +3001,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
|
|||||||
questions: [
|
questions: [
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[0],
|
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"),
|
headline: t("templates.evaluate_a_product_idea_question_1_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
buttonLabel: t("templates.evaluate_a_product_idea_question_1_button_label"),
|
buttonLabel: t("templates.evaluate_a_product_idea_question_1_button_label"),
|
||||||
@@ -3037,7 +3034,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[3],
|
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"),
|
headline: t("templates.evaluate_a_product_idea_question_4_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
buttonLabel: t("templates.evaluate_a_product_idea_question_4_button_label"),
|
buttonLabel: t("templates.evaluate_a_product_idea_question_4_button_label"),
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ import {
|
|||||||
TSurveyQuestion,
|
TSurveyQuestion,
|
||||||
TSurveyRankingQuestion,
|
TSurveyRankingQuestion,
|
||||||
} from "@formbricks/types/surveys/types";
|
} from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
|
||||||
import { processResponseData } from "../responses";
|
import { processResponseData } from "../responses";
|
||||||
import { getTodaysDateTimeFormatted } from "../time";
|
import { getTodaysDateTimeFormatted } from "../time";
|
||||||
import { getFormattedDateTimeString } from "../utils/datetime";
|
import { getFormattedDateTimeString } from "../utils/datetime";
|
||||||
@@ -661,13 +659,11 @@ export const extracMetadataKeys = (obj: TResponse["meta"]) => {
|
|||||||
|
|
||||||
export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) => {
|
export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) => {
|
||||||
const metaDataFields = responses.length > 0 ? extracMetadataKeys(responses[0].meta) : [];
|
const metaDataFields = responses.length > 0 ? extracMetadataKeys(responses[0].meta) : [];
|
||||||
const modifiedSurvey = replaceHeadlineRecall(survey, "default");
|
const questions = survey.questions.map((question, idx) => {
|
||||||
|
const headline = getLocalizedValue(question.headline, "default") ?? question.id;
|
||||||
const questions = modifiedSurvey.questions.map((question, idx) => {
|
|
||||||
const headline = getTextContent(getLocalizedValue(question.headline, "default")) ?? question.id;
|
|
||||||
if (question.type === "matrix") {
|
if (question.type === "matrix") {
|
||||||
return question.rows.map((row) => {
|
return question.rows.map((row) => {
|
||||||
return `${idx + 1}. ${headline} - ${getTextContent(getLocalizedValue(row.label, "default"))}`;
|
return `${idx + 1}. ${headline} - ${getLocalizedValue(row.label, "default")}`;
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
question.type === "multipleChoiceMulti" ||
|
question.type === "multipleChoiceMulti" ||
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
|
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
|
||||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||||
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
|
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
|
||||||
|
|
||||||
@@ -46,11 +45,9 @@ export const getQuestionResponseMapping = (
|
|||||||
const answer = response.data[question.id];
|
const answer = response.data[question.id];
|
||||||
|
|
||||||
questionResponseMapping.push({
|
questionResponseMapping.push({
|
||||||
question: getTextContent(
|
question: parseRecallInfo(
|
||||||
parseRecallInfo(
|
getLocalizedValue(question.headline, responseLanguageCode ?? "default"),
|
||||||
getLocalizedValue(question.headline, responseLanguageCode ?? "default"),
|
response.data
|
||||||
response.data
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
response: convertResponseValue(answer, question),
|
response: convertResponseValue(answer, question),
|
||||||
type: question.type,
|
type: question.type,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
|
||||||
import { TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||||
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||||
import {
|
import {
|
||||||
checkForEmptyFallBackValue,
|
checkForEmptyFallBackValue,
|
||||||
@@ -21,11 +22,9 @@ import {
|
|||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("@/lib/i18n/utils", () => ({
|
vi.mock("@/lib/i18n/utils", () => ({
|
||||||
getLocalizedValue: (obj: any, lang: string) => {
|
getLocalizedValue: vi.fn().mockImplementation((obj, lang) => {
|
||||||
if (typeof obj === "string") return obj;
|
return typeof obj === "string" ? obj : obj[lang] || obj["default"] || "";
|
||||||
if (!obj) return "";
|
}),
|
||||||
return obj[lang] || obj["default"] || "";
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
||||||
@@ -143,12 +142,12 @@ describe("recall utility functions", () => {
|
|||||||
describe("recallToHeadline", () => {
|
describe("recallToHeadline", () => {
|
||||||
test("converts recall pattern to headline format without slash", () => {
|
test("converts recall pattern to headline format without slash", () => {
|
||||||
const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
|
const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
|
||||||
const survey = {
|
const survey: TSurvey = {
|
||||||
id: "test-survey",
|
id: "test-survey",
|
||||||
questions: [{ id: "product", headline: { en: "Product Question" } }],
|
questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
|
||||||
hiddenFields: { fieldIds: [] },
|
hiddenFields: { fieldIds: [] },
|
||||||
variables: [],
|
variables: [],
|
||||||
} as any;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
const result = recallToHeadline(headline, survey, false, "en");
|
const result = recallToHeadline(headline, survey, false, "en");
|
||||||
expect(result.en).toBe("How do you like @Product Question?");
|
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", () => {
|
test("converts recall pattern to headline format with slash", () => {
|
||||||
const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" };
|
const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" };
|
||||||
const survey = {
|
const survey: TSurvey = {
|
||||||
id: "test-survey",
|
id: "test-survey",
|
||||||
questions: [{ id: "product", headline: { en: "Product Question" } }],
|
questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
|
||||||
hiddenFields: { fieldIds: [] },
|
hiddenFields: { fieldIds: [] },
|
||||||
variables: [],
|
variables: [],
|
||||||
} as any;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
const result = recallToHeadline(headline, survey, true, "en");
|
const result = recallToHeadline(headline, survey, true, "en");
|
||||||
expect(result.en).toBe("Rate /Product Question\\");
|
expect(result.en).toBe("Rate /Product Question\\");
|
||||||
@@ -205,12 +204,15 @@ describe("recall utility functions", () => {
|
|||||||
const headline = {
|
const headline = {
|
||||||
en: "This is #recall:inner/fallback:fallback2#",
|
en: "This is #recall:inner/fallback:fallback2#",
|
||||||
};
|
};
|
||||||
const survey = {
|
const survey: TSurvey = {
|
||||||
id: "test-survey",
|
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: [] },
|
hiddenFields: { fieldIds: [] },
|
||||||
variables: [],
|
variables: [],
|
||||||
} as any;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
const result = recallToHeadline(headline, survey, false, "en");
|
const result = recallToHeadline(headline, survey, false, "en");
|
||||||
expect(result.en).toBe("This is @Inner with @outer");
|
expect(result.en).toBe("This is @Inner with @outer");
|
||||||
@@ -240,14 +242,16 @@ describe("recall utility functions", () => {
|
|||||||
describe("checkForEmptyFallBackValue", () => {
|
describe("checkForEmptyFallBackValue", () => {
|
||||||
test("identifies question with empty fallback value", () => {
|
test("identifies question with empty fallback value", () => {
|
||||||
const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
|
const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
|
||||||
const survey = {
|
const survey: TSurvey = {
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
headline: questionHeadline,
|
headline: questionHeadline,
|
||||||
},
|
},
|
||||||
],
|
] as unknown as TSurveyQuestion[],
|
||||||
} as any;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
|
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
|
||||||
|
|
||||||
const result = checkForEmptyFallBackValue(survey, "en");
|
const result = checkForEmptyFallBackValue(survey, "en");
|
||||||
expect(result).toBe(survey.questions[0]);
|
expect(result).toBe(survey.questions[0]);
|
||||||
@@ -255,15 +259,17 @@ describe("recall utility functions", () => {
|
|||||||
|
|
||||||
test("identifies question with empty fallback in subheader", () => {
|
test("identifies question with empty fallback in subheader", () => {
|
||||||
const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
|
const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
|
||||||
const survey = {
|
const survey: TSurvey = {
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
headline: { en: "Normal question" },
|
headline: { en: "Normal question" },
|
||||||
subheader: questionSubheader,
|
subheader: questionSubheader,
|
||||||
},
|
},
|
||||||
],
|
] as unknown as TSurveyQuestion[],
|
||||||
} as any;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
|
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionSubheader.en);
|
||||||
|
|
||||||
const result = checkForEmptyFallBackValue(survey, "en");
|
const result = checkForEmptyFallBackValue(survey, "en");
|
||||||
expect(result).toBe(survey.questions[0]);
|
expect(result).toBe(survey.questions[0]);
|
||||||
@@ -271,14 +277,16 @@ describe("recall utility functions", () => {
|
|||||||
|
|
||||||
test("returns null when no empty fallback values are found", () => {
|
test("returns null when no empty fallback values are found", () => {
|
||||||
const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
|
const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
|
||||||
const survey = {
|
const survey: TSurvey = {
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
headline: questionHeadline,
|
headline: questionHeadline,
|
||||||
},
|
},
|
||||||
],
|
] as unknown as TSurveyQuestion[],
|
||||||
} as any;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
|
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
|
||||||
|
|
||||||
const result = checkForEmptyFallBackValue(survey, "en");
|
const result = checkForEmptyFallBackValue(survey, "en");
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
|
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
|
||||||
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||||
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
|
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
|
||||||
@@ -60,11 +59,7 @@ const getRecallItemLabel = <T extends TSurvey>(
|
|||||||
if (isHiddenField) return recallItemId;
|
if (isHiddenField) return recallItemId;
|
||||||
|
|
||||||
const surveyQuestion = survey.questions.find((question) => question.id === recallItemId);
|
const surveyQuestion = survey.questions.find((question) => question.id === recallItemId);
|
||||||
if (surveyQuestion) {
|
if (surveyQuestion) return surveyQuestion.headline[languageCode];
|
||||||
const headline = getLocalizedValue(surveyQuestion.headline, languageCode);
|
|
||||||
// Strip HTML tags to prevent raw HTML from showing in nested recalls
|
|
||||||
return headline ? getTextContent(headline) : headline;
|
|
||||||
}
|
|
||||||
|
|
||||||
const variable = survey.variables?.find((variable) => variable.id === recallItemId);
|
const variable = survey.variables?.find((variable) => variable.id === recallItemId);
|
||||||
if (variable) return variable.name;
|
if (variable) return variable.name;
|
||||||
@@ -131,7 +126,8 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T
|
|||||||
for (const question of survey.questions) {
|
for (const question of survey.questions) {
|
||||||
if (
|
if (
|
||||||
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
|
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;
|
return question;
|
||||||
}
|
}
|
||||||
@@ -271,18 +267,3 @@ export const parseRecallInfo = (
|
|||||||
|
|
||||||
return modifiedText;
|
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 { CheckCircle2Icon, ChevronsDownIcon, XCircleIcon } from "lucide-react";
|
||||||
import { TResponseData } from "@formbricks/types/responses";
|
import { TResponseData } from "@formbricks/types/responses";
|
||||||
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
@@ -73,16 +72,12 @@ export const QuestionSkip = ({
|
|||||||
{skippedQuestions?.map((questionId) => {
|
{skippedQuestions?.map((questionId) => {
|
||||||
return (
|
return (
|
||||||
<p className="my-2" key={questionId}>
|
<p className="my-2" key={questionId}>
|
||||||
{getTextContent(
|
{parseRecallInfo(
|
||||||
parseRecallInfo(
|
getLocalizedValue(
|
||||||
getLocalizedValue(
|
questions.find((question) => question.id === questionId)!.headline,
|
||||||
questions.find((question) => question.id === questionId)?.headline ?? {
|
"default"
|
||||||
default: "",
|
),
|
||||||
},
|
responseData
|
||||||
"default"
|
|
||||||
),
|
|
||||||
responseData
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -112,16 +107,12 @@ export const QuestionSkip = ({
|
|||||||
skippedQuestions.map((questionId) => {
|
skippedQuestions.map((questionId) => {
|
||||||
return (
|
return (
|
||||||
<p className="my-2" key={questionId}>
|
<p className="my-2" key={questionId}>
|
||||||
{getTextContent(
|
{parseRecallInfo(
|
||||||
parseRecallInfo(
|
getLocalizedValue(
|
||||||
getLocalizedValue(
|
questions.find((question) => question.id === questionId)!.headline,
|
||||||
questions.find((question) => question.id === questionId)?.headline ?? {
|
"default"
|
||||||
default: "",
|
),
|
||||||
},
|
responseData
|
||||||
"default"
|
|
||||||
),
|
|
||||||
responseData
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
+6
-9
@@ -4,7 +4,6 @@ import { useTranslate } from "@tolgee/react";
|
|||||||
import { CheckCircle2Icon } from "lucide-react";
|
import { CheckCircle2Icon } from "lucide-react";
|
||||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||||
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
|
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
|
||||||
@@ -78,15 +77,13 @@ export const SingleResponseCardBody = ({
|
|||||||
<div key={`${question.id}`}>
|
<div key={`${question.id}`}>
|
||||||
{isValidValue(response.data[question.id]) ? (
|
{isValidValue(response.data[question.id]) ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-semibold text-slate-600">
|
<p className="mb-1 text-sm text-slate-500">
|
||||||
{formatTextWithSlashes(
|
{formatTextWithSlashes(
|
||||||
getTextContent(
|
parseRecallInfo(
|
||||||
parseRecallInfo(
|
getLocalizedValue(question.headline, "default"),
|
||||||
getLocalizedValue(question.headline, "default"),
|
response.data,
|
||||||
response.data,
|
response.variables,
|
||||||
response.variables,
|
true
|
||||||
true
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ describe("ResponseFeed", () => {
|
|||||||
welcomeCard: {
|
welcomeCard: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
headline: "",
|
headline: "",
|
||||||
subheader: "",
|
html: "",
|
||||||
},
|
},
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
autoComplete: null,
|
autoComplete: null,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { TI18nString, TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
import type { TI18nString, TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent, isValidHTML } from "@formbricks/types/surveys/validation";
|
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { extractLanguageCodes, isLabelValidForAllLanguages } from "@/lib/i18n/utils";
|
||||||
import { md } from "@/lib/markdownIt";
|
import { md } from "@/lib/markdownIt";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
|
|
||||||
import { Editor } from "@/modules/ui/components/editor";
|
import { Editor } from "@/modules/ui/components/editor";
|
||||||
import { LanguageIndicator } from "./language-indicator";
|
import { LanguageIndicator } from "./language-indicator";
|
||||||
|
|
||||||
@@ -25,21 +25,17 @@ interface LocalizedEditorProps {
|
|||||||
setFirstRender?: Dispatch<SetStateAction<boolean>>;
|
setFirstRender?: Dispatch<SetStateAction<boolean>>;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
questionId: string;
|
questionId: string;
|
||||||
isCard?: boolean; // Flag to indicate if this is a welcome/ending card
|
|
||||||
autoFocus?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkIfValueIsIncomplete = (
|
const checkIfValueIsIncomplete = (
|
||||||
id: string,
|
id: string,
|
||||||
isInvalid: boolean,
|
isInvalid: boolean,
|
||||||
surveyLanguageCodes: TSurveyLanguage[],
|
surveyLanguageCodes: string[],
|
||||||
value?: TI18nString
|
value?: TI18nString
|
||||||
) => {
|
) => {
|
||||||
const labelIds = ["subheader", "headline", "html"];
|
const labelIds = ["subheader"];
|
||||||
if (value === undefined) return false;
|
if (value === undefined) return false;
|
||||||
const isDefaultIncomplete = labelIds.includes(id)
|
const isDefaultIncomplete = labelIds.includes(id) ? value.default.trim() !== "" : false;
|
||||||
? getTextContent(value.default ?? "").trim() !== ""
|
|
||||||
: false;
|
|
||||||
return isInvalid && !isLabelValidForAllLanguages(value, surveyLanguageCodes) && isDefaultIncomplete;
|
return isInvalid && !isLabelValidForAllLanguages(value, surveyLanguageCodes) && isDefaultIncomplete;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,76 +52,38 @@ export function LocalizedEditor({
|
|||||||
setFirstRender,
|
setFirstRender,
|
||||||
locale,
|
locale,
|
||||||
questionId,
|
questionId,
|
||||||
isCard,
|
|
||||||
autoFocus,
|
|
||||||
}: Readonly<LocalizedEditorProps>) {
|
}: Readonly<LocalizedEditorProps>) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const surveyLanguageCodes = useMemo(
|
||||||
|
() => extractLanguageCodes(localSurvey.languages),
|
||||||
|
[localSurvey.languages]
|
||||||
|
);
|
||||||
const isInComplete = useMemo(
|
const isInComplete = useMemo(
|
||||||
() => checkIfValueIsIncomplete(id, isInvalid, localSurvey.languages, value),
|
() => checkIfValueIsIncomplete(id, isInvalid, surveyLanguageCodes, value),
|
||||||
[id, isInvalid, localSurvey.languages, value]
|
[id, isInvalid, surveyLanguageCodes, value]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Editor
|
<Editor
|
||||||
id={id}
|
|
||||||
disableLists
|
disableLists
|
||||||
excludedToolbarItems={["blockType"]}
|
excludedToolbarItems={["blockType"]}
|
||||||
firstRender={firstRender}
|
firstRender={firstRender}
|
||||||
autoFocus={autoFocus}
|
getText={() => md.render(value ? (value[selectedLanguageCode] ?? "") : "")}
|
||||||
getText={() => {
|
key={`${questionIdx}-${selectedLanguageCode}`}
|
||||||
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}`}
|
|
||||||
setFirstRender={setFirstRender}
|
setFirstRender={setFirstRender}
|
||||||
setText={(v: string) => {
|
setText={(v: string) => {
|
||||||
// Check if the question still exists before updating
|
if (localSurvey.questions[questionIdx] || questionIdx === -1) {
|
||||||
const currentQuestion = localSurvey.questions[questionIdx];
|
const translatedHtml = {
|
||||||
|
...value,
|
||||||
// if this is a card, we wanna check if the card exists in the localSurvey
|
[selectedLanguageCode]: v,
|
||||||
if (isCard) {
|
};
|
||||||
const isWelcomeCard = questionIdx === -1;
|
if (questionIdx === -1) {
|
||||||
const isEndingCard = questionIdx >= localSurvey.questions.length;
|
// welcome card
|
||||||
|
updateQuestion({ html: translatedHtml });
|
||||||
// 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) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
updateQuestion(questionIdx, { html: translatedHtml });
|
||||||
const translatedContent = {
|
|
||||||
...(value ?? {}),
|
|
||||||
[selectedLanguageCode]: v,
|
|
||||||
};
|
|
||||||
updateQuestion({ [id]: translatedContent });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentQuestion && currentQuestion[id] !== undefined) {
|
|
||||||
const translatedContent = {
|
|
||||||
...(value ?? {}),
|
|
||||||
[selectedLanguageCode]: v,
|
|
||||||
};
|
|
||||||
updateQuestion(questionIdx, { [id]: translatedContent });
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
localSurvey={localSurvey}
|
localSurvey={localSurvey}
|
||||||
@@ -145,9 +103,14 @@ export function LocalizedEditor({
|
|||||||
{value && selectedLanguageCode !== "default" && value.default ? (
|
{value && selectedLanguageCode !== "default" && value.default ? (
|
||||||
<div className="mt-1 flex text-xs text-gray-500">
|
<div className="mt-1 flex text-xs text-gray-500">
|
||||||
<strong>{t("environments.project.languages.translate")}:</strong>
|
<strong>{t("environments.project.languages.translate")}:</strong>
|
||||||
<span className="ml-1">
|
<span
|
||||||
{getTextContent(recallToHeadline(value, localSurvey, false, "default").default ?? "")}
|
className="fb-htmlbody ml-1" // styles are in global.css
|
||||||
</span>
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(
|
||||||
|
recallToHeadline(value, localSurvey, false, "default").default ?? ""
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Container } from "@react-email/components";
|
import { Text } from "@react-email/components";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
interface QuestionHeaderProps {
|
interface QuestionHeaderProps {
|
||||||
@@ -10,13 +10,11 @@ interface QuestionHeaderProps {
|
|||||||
export function QuestionHeader({ headline, subheader, className }: QuestionHeaderProps): React.JSX.Element {
|
export function QuestionHeader({ headline, subheader, className }: QuestionHeaderProps): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Container className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
|
<Text className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
|
||||||
<div dangerouslySetInnerHTML={{ __html: headline }} />
|
{headline}
|
||||||
</Container>
|
</Text>
|
||||||
{subheader && (
|
{subheader && (
|
||||||
<Container className="text-question-color m-0 mt-2 block p-0 text-sm font-normal leading-6">
|
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">{subheader}</Text>
|
||||||
<div dangerouslySetInnerHTML={{ __html: subheader }} />
|
|
||||||
</Container>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,7 +94,16 @@ export async function PreviewEmailTemplate({
|
|||||||
case TSurveyQuestionTypeEnum.Consent:
|
case TSurveyQuestionTypeEnum.Consent:
|
||||||
return (
|
return (
|
||||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
<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">
|
<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">
|
<Text className="text-question-color m-0 inline-block">
|
||||||
{getLocalizedValue(firstQuestion.label, defaultLanguageCode)}
|
{getLocalizedValue(firstQuestion.label, defaultLanguageCode)}
|
||||||
@@ -172,7 +181,16 @@ export async function PreviewEmailTemplate({
|
|||||||
case TSurveyQuestionTypeEnum.CTA:
|
case TSurveyQuestionTypeEnum.CTA:
|
||||||
return (
|
return (
|
||||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
<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">
|
<Container className="mx-0 mt-4 max-w-none">
|
||||||
{!firstQuestion.required && (
|
{!firstQuestion.required && (
|
||||||
<EmailButton
|
<EmailButton
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ import { useTranslate } from "@tolgee/react";
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
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 { Button } from "@/modules/ui/components/button";
|
||||||
import { Input } from "@/modules/ui/components/input";
|
import { Input } from "@/modules/ui/components/input";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
import { Label } from "@/modules/ui/components/label";
|
||||||
@@ -53,7 +53,7 @@ export const FallbackInput = ({
|
|||||||
return (
|
return (
|
||||||
<div key={recallItem.id} className="flex flex-col gap-1">
|
<div key={recallItem.id} className="flex flex-col gap-1">
|
||||||
<Label htmlFor={inputId} className="text-xs font-medium text-slate-700">
|
<Label htmlFor={inputId} className="text-xs font-medium text-slate-700">
|
||||||
{getTextContentWithRecallTruncated(recallItem.label)}
|
{replaceRecallInfoWithUnderline(recallItem.label)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
className="h-9 bg-white"
|
className="h-9 bg-white"
|
||||||
|
|||||||
+1
-2
@@ -3,7 +3,6 @@
|
|||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { ReactNode, useMemo } from "react";
|
import { ReactNode, useMemo } from "react";
|
||||||
import { TI18nString, TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
import { TI18nString, TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||||
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
|
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
|
||||||
@@ -83,7 +82,7 @@ export const MultiLangWrapper = ({
|
|||||||
{usedLanguageCode !== "default" && value && typeof value["default"] !== "undefined" && (
|
{usedLanguageCode !== "default" && value && typeof value["default"] !== "undefined" && (
|
||||||
<div className="mt-1 text-xs text-slate-500">
|
<div className="mt-1 text-xs text-slate-500">
|
||||||
<strong>{t("environments.project.languages.translate")}:</strong>{" "}
|
<strong>{t("environments.project.languages.translate")}:</strong>{" "}
|
||||||
{getTextContent(recallToHeadline(value, localSurvey, false, "default")["default"] ?? "")}
|
{recallToHeadline(value, localSurvey, false, "default")["default"]}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+23
-42
@@ -11,26 +11,7 @@ import {
|
|||||||
import { RecallItemSelect } from "./recall-item-select";
|
import { RecallItemSelect } from "./recall-item-select";
|
||||||
|
|
||||||
vi.mock("@/lib/utils/recall", () => ({
|
vi.mock("@/lib/utils/recall", () => ({
|
||||||
getTextContentWithRecallTruncated: vi.fn((text: string, maxLength: number = 25) => {
|
replaceRecallInfoWithUnderline: vi.fn((text) => `_${text}_`),
|
||||||
// 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}`;
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("RecallItemSelect", () => {
|
describe("RecallItemSelect", () => {
|
||||||
@@ -97,15 +78,15 @@ describe("RecallItemSelect", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Question 1")).toBeInTheDocument();
|
expect(screen.getByText("_Question 1_")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Question 2")).toBeInTheDocument();
|
expect(screen.getByText("_Question 2_")).toBeInTheDocument();
|
||||||
expect(screen.getByText("hidden1")).toBeInTheDocument();
|
expect(screen.getByText("_hidden1_")).toBeInTheDocument();
|
||||||
expect(screen.getByText("hidden2")).toBeInTheDocument();
|
expect(screen.getByText("_hidden2_")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Variable 1")).toBeInTheDocument();
|
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Variable 2")).toBeInTheDocument();
|
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.queryByText("Current Question")).not.toBeInTheDocument();
|
expect(screen.queryByText("_Current Question_")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("File Upload Question")).not.toBeInTheDocument();
|
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("do not render questions if questionId is 'start' (welcome card)", async () => {
|
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 1_")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Question 2")).not.toBeInTheDocument();
|
expect(screen.queryByText("_Question 2_")).not.toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByText("hidden1")).toBeInTheDocument();
|
expect(screen.getByText("_hidden1_")).toBeInTheDocument();
|
||||||
expect(screen.getByText("hidden2")).toBeInTheDocument();
|
expect(screen.getByText("_hidden2_")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Variable 1")).toBeInTheDocument();
|
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Variable 2")).toBeInTheDocument();
|
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.queryByText("Current Question")).not.toBeInTheDocument();
|
expect(screen.queryByText("_Current Question_")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("File Upload Question")).not.toBeInTheDocument();
|
expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("filters recall items based on search input", async () => {
|
test("filters recall items based on search input", async () => {
|
||||||
@@ -150,9 +131,9 @@ describe("RecallItemSelect", () => {
|
|||||||
const searchInput = screen.getByPlaceholderText("Search options");
|
const searchInput = screen.getByPlaceholderText("Search options");
|
||||||
await user.type(searchInput, "Variable");
|
await user.type(searchInput, "Variable");
|
||||||
|
|
||||||
expect(screen.getByText("Variable 1")).toBeInTheDocument();
|
expect(screen.getByText("_Variable 1_")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Variable 2")).toBeInTheDocument();
|
expect(screen.getByText("_Variable 2_")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Question 1")).not.toBeInTheDocument();
|
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("calls addRecallItem and setShowRecallItemSelect when item is selected", async () => {
|
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);
|
await user.click(firstItem);
|
||||||
|
|
||||||
expect(mockAddRecallItem).toHaveBeenCalledWith({
|
expect(mockAddRecallItem).toHaveBeenCalledWith({
|
||||||
@@ -195,8 +176,8 @@ describe("RecallItemSelect", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByText("Question 1")).not.toBeInTheDocument();
|
expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("Question 2")).toBeInTheDocument();
|
expect(screen.getByText("_Question 2_")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows 'No recall items found' when search has no results", async () => {
|
test("shows 'No recall items found' when search has no results", async () => {
|
||||||
|
|||||||
+8
-3
@@ -22,7 +22,7 @@ import {
|
|||||||
TSurveyQuestionId,
|
TSurveyQuestionId,
|
||||||
TSurveyRecallItem,
|
TSurveyRecallItem,
|
||||||
} from "@formbricks/types/surveys/types";
|
} from "@formbricks/types/surveys/types";
|
||||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -130,7 +130,7 @@ export const RecallItemSelect = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return filteredQuestions;
|
return filteredQuestions;
|
||||||
}, [localSurvey.questions, questionId, recallItemIds, selectedLanguageCode]);
|
}, [localSurvey.questions, questionId, recallItemIds]);
|
||||||
|
|
||||||
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
|
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
|
||||||
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...variableRecallItems].filter(
|
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...variableRecallItems].filter(
|
||||||
@@ -143,6 +143,11 @@ export const RecallItemSelect = ({
|
|||||||
);
|
);
|
||||||
}, [surveyQuestionRecallItems, hiddenFieldRecallItems, variableRecallItems, searchValue]);
|
}, [surveyQuestionRecallItems, hiddenFieldRecallItems, variableRecallItems, searchValue]);
|
||||||
|
|
||||||
|
// function to modify headline (recallInfo to corresponding headline)
|
||||||
|
const getRecallLabel = (label: string): string => {
|
||||||
|
return replaceRecallInfoWithUnderline(label);
|
||||||
|
};
|
||||||
|
|
||||||
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
|
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
|
||||||
switch (recallItem.type) {
|
switch (recallItem.type) {
|
||||||
case "question":
|
case "question":
|
||||||
@@ -207,7 +212,7 @@ export const RecallItemSelect = ({
|
|||||||
}}>
|
}}>
|
||||||
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
|
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
|
||||||
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||||
{getTextContentWithRecallTruncated(recallItem.label)}
|
{getRecallLabel(recallItem.label)}
|
||||||
</p>
|
</p>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -162,26 +162,6 @@ vi.mock("@/modules/ui/components/tooltip", () => ({
|
|||||||
TooltipRenderer: ({ children, tooltipContent }: any) => (
|
TooltipRenderer: ({ children, tooltipContent }: any) => (
|
||||||
<span data-tooltip={tooltipContent}>{children}</span>
|
<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
|
// Mock component imports to avoid rendering real components that might access server-side resources
|
||||||
@@ -300,7 +280,7 @@ const mockSurvey = {
|
|||||||
welcomeCard: {
|
welcomeCard: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headline: createI18nString("Welcome", ["en", "fr"]),
|
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"]),
|
buttonLabel: createI18nString("Start", ["en", "fr"]),
|
||||||
fileUrl: "",
|
fileUrl: "",
|
||||||
videoUrl: "",
|
videoUrl: "",
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ import {
|
|||||||
TSurveyEndScreenCard,
|
TSurveyEndScreenCard,
|
||||||
TSurveyQuestion,
|
TSurveyQuestion,
|
||||||
TSurveyQuestionChoice,
|
TSurveyQuestionChoice,
|
||||||
TSurveyQuestionTypeEnum,
|
|
||||||
TSurveyRedirectUrlCard,
|
TSurveyRedirectUrlCard,
|
||||||
} from "@formbricks/types/surveys/types";
|
} from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||||
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
|
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
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 { 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 { RecallWrapper } from "@/modules/survey/components/question-form-input/components/recall-wrapper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -57,9 +55,6 @@ interface QuestionFormInputProps {
|
|||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
||||||
isStorageConfigured: boolean;
|
isStorageConfigured: boolean;
|
||||||
autoFocus?: boolean;
|
|
||||||
firstRender?: boolean;
|
|
||||||
setFirstRender?: (value: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QuestionFormInput = ({
|
export const QuestionFormInput = ({
|
||||||
@@ -82,9 +77,6 @@ export const QuestionFormInput = ({
|
|||||||
locale,
|
locale,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
isStorageConfigured = true,
|
isStorageConfigured = true,
|
||||||
autoFocus,
|
|
||||||
firstRender: externalFirstRender,
|
|
||||||
setFirstRender: externalSetFirstRender,
|
|
||||||
}: QuestionFormInputProps) => {
|
}: QuestionFormInputProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const defaultLanguageCode =
|
const defaultLanguageCode =
|
||||||
@@ -282,132 +274,13 @@ export const QuestionFormInput = ({
|
|||||||
const debouncedHandleUpdate = useMemo(() => debounce((value) => handleUpdate(value), 100), [handleUpdate]);
|
const debouncedHandleUpdate = useMemo(() => debounce((value) => handleUpdate(value), 100), [handleUpdate]);
|
||||||
|
|
||||||
const [animationParent] = useAutoAnimate();
|
const [animationParent] = useAutoAnimate();
|
||||||
const [internalFirstRender, setInternalFirstRender] = useState(true);
|
|
||||||
|
|
||||||
// Use external firstRender state if provided, otherwise use internal state
|
const renderRemoveDescriptionButton = useMemo(() => {
|
||||||
const firstRender = externalFirstRender ?? internalFirstRender;
|
if (id !== "subheader") return false;
|
||||||
const setFirstRender = externalSetFirstRender ?? setInternalFirstRender;
|
return !!question?.subheader || (endingCard?.type === "endScreen" && !!endingCard?.subheader);
|
||||||
|
|
||||||
const renderRemoveDescriptionButton = () => {
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
if (
|
}, [endingCard?.type, id, question?.subheader]);
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -547,7 +420,7 @@ export const QuestionFormInput = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipRenderer>
|
</TooltipRenderer>
|
||||||
)}
|
)}
|
||||||
{renderRemoveDescriptionButton() ? (
|
{renderRemoveDescriptionButton ? (
|
||||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
|
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -556,14 +429,12 @@ export const QuestionFormInput = ({
|
|||||||
className="ml-2"
|
className="ml-2"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (updateSurvey) {
|
|
||||||
updateSurvey({ subheader: undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateQuestion) {
|
if (updateQuestion) {
|
||||||
updateQuestion(questionIdx, { subheader: undefined });
|
updateQuestion(questionIdx, { subheader: undefined });
|
||||||
}
|
}
|
||||||
|
if (updateSurvey) {
|
||||||
|
updateSurvey({ subheader: undefined });
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export const AddressQuestionForm = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const [parent] = useAutoAnimate();
|
const [parent] = useAutoAnimate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<form>
|
||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
@@ -108,7 +107,6 @@ export const AddressQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={parent}>
|
<div ref={parent}>
|
||||||
@@ -127,7 +125,6 @@ export const AddressQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export const CalQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
{question.subheader !== undefined && (
|
{question.subheader !== undefined && (
|
||||||
@@ -81,7 +80,6 @@ export const CalQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,6 +95,7 @@ export const CalQuestionForm = ({
|
|||||||
subheader: createI18nString("", surveyLanguageCodes),
|
subheader: createI18nString("", surveyLanguageCodes),
|
||||||
});
|
});
|
||||||
}}>
|
}}>
|
||||||
|
{" "}
|
||||||
<PlusIcon className="mr-1 h-4 w-4" />
|
<PlusIcon className="mr-1 h-4 w-4" />
|
||||||
{t("environments.surveys.edit.add_description")}
|
{t("environments.surveys.edit.add_description")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import { TUserLocale } from "@formbricks/types/user";
|
|||||||
import { ConsentQuestionForm } from "./consent-question-form";
|
import { ConsentQuestionForm } from "./consent-question-form";
|
||||||
|
|
||||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||||
QuestionFormInput: ({ label, id }: { label: string; id: string }) => (
|
QuestionFormInput: ({ label }: { label: string }) => <div data-testid="question-form-input">{label}</div>,
|
||||||
<div data-testid="question-form-input" data-field-id={id}>
|
}));
|
||||||
{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", () => {
|
describe("ConsentQuestionForm", () => {
|
||||||
@@ -57,18 +61,9 @@ describe("ConsentQuestionForm", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const questionFormInputs = screen.getAllByTestId("question-form-input");
|
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]).toHaveTextContent("environments.surveys.edit.question*");
|
||||||
expect(questionFormInputs[0]).toHaveAttribute("data-field-id", "headline");
|
expect(screen.getByTestId("label")).toHaveTextContent("common.description");
|
||||||
|
expect(screen.getByTestId("localized-editor")).toHaveTextContent("subheader");
|
||||||
// Check html (description) field
|
expect(questionFormInputs[1]).toHaveTextContent("environments.surveys.edit.checkbox_label*");
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
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 { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
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 { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||||
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
|
||||||
interface ConsentQuestionFormProps {
|
interface ConsentQuestionFormProps {
|
||||||
localSurvey: TSurvey;
|
localSurvey: TSurvey;
|
||||||
@@ -29,45 +31,57 @@ export const ConsentQuestionForm = ({
|
|||||||
locale,
|
locale,
|
||||||
isStorageConfigured = true,
|
isStorageConfigured = true,
|
||||||
}: ConsentQuestionFormProps): JSX.Element => {
|
}: ConsentQuestionFormProps): JSX.Element => {
|
||||||
|
const [firstRender, setFirstRender] = useState(true);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
// Common props shared across all QuestionFormInput components
|
|
||||||
const commonInputProps = {
|
|
||||||
localSurvey,
|
|
||||||
questionIdx,
|
|
||||||
isInvalid,
|
|
||||||
updateQuestion,
|
|
||||||
selectedLanguageCode,
|
|
||||||
setSelectedLanguageCode,
|
|
||||||
locale,
|
|
||||||
isStorageConfigured,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<form>
|
||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
{...commonInputProps}
|
|
||||||
id="headline"
|
id="headline"
|
||||||
value={question.headline}
|
|
||||||
label={t("environments.surveys.edit.question") + "*"}
|
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">
|
<div className="mt-3">
|
||||||
<QuestionFormInput
|
<Label htmlFor="subheader">{t("common.description")}</Label>
|
||||||
{...commonInputProps}
|
<div className="mt-2">
|
||||||
id="subheader"
|
<LocalizedEditor
|
||||||
value={question.subheader}
|
id="subheader"
|
||||||
label={t("common.description")}
|
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>
|
||||||
|
|
||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
{...commonInputProps}
|
|
||||||
id="label"
|
id="label"
|
||||||
label={t("environments.surveys.edit.checkbox_label") + "*"}
|
label={t("environments.surveys.edit.checkbox_label") + "*"}
|
||||||
placeholder="I agree to the terms and conditions"
|
placeholder="I agree to the terms and conditions"
|
||||||
value={question.label}
|
value={question.label}
|
||||||
|
localSurvey={localSurvey}
|
||||||
|
questionIdx={questionIdx}
|
||||||
|
isInvalid={isInvalid}
|
||||||
|
updateQuestion={updateQuestion}
|
||||||
|
selectedLanguageCode={selectedLanguageCode}
|
||||||
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
|
locale={locale}
|
||||||
|
isStorageConfigured={isStorageConfigured}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const ContactInfoQuestionForm = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [question.firstName, question.lastName, question.email, question.phone, question.company]);
|
}, [question.firstName, question.lastName, question.email, question.phone, question.company]);
|
||||||
|
|
||||||
|
// Auto animate
|
||||||
const [parent] = useAutoAnimate();
|
const [parent] = useAutoAnimate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,7 +99,6 @@ export const ContactInfoQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={parent}>
|
<div ref={parent}>
|
||||||
@@ -117,7 +117,6 @@ export const ContactInfoQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ vi.mock("@formkit/auto-animate/react", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||||
QuestionFormInput: ({ id }: { id: string }) => (
|
QuestionFormInput: () => <div data-testid="question-form-input">QuestionFormInput</div>,
|
||||||
<div data-testid="question-form-input" data-field-id={id}>
|
}));
|
||||||
QuestionFormInput-{id}
|
|
||||||
</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", () => ({
|
vi.mock("@/modules/ui/components/options-switch", () => ({
|
||||||
@@ -69,13 +69,8 @@ describe("CTAQuestionForm", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const questionFormInputs = screen.getAllByTestId("question-form-input");
|
const questionFormInputs = screen.getAllByTestId("question-form-input");
|
||||||
expect(questionFormInputs.length).toBe(3);
|
expect(questionFormInputs.length).toBe(2);
|
||||||
|
expect(screen.getByTestId("localized-editor")).toBeInTheDocument();
|
||||||
// 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(screen.getByTestId("options-switch")).toBeInTheDocument();
|
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { useTranslate } from "@tolgee/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 { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
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 { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||||
import { Input } from "@/modules/ui/components/input";
|
import { Input } from "@/modules/ui/components/input";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
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") },
|
{ value: "external", label: t("environments.surveys.edit.button_to_link_to_external_url") },
|
||||||
];
|
];
|
||||||
|
const [firstRender, setFirstRender] = useState(true);
|
||||||
|
const [parent] = useAutoAnimate();
|
||||||
return (
|
return (
|
||||||
<form>
|
<form ref={parent}>
|
||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
id="headline"
|
id="headline"
|
||||||
value={question.headline}
|
value={question.headline}
|
||||||
@@ -57,23 +60,26 @@ export const CTAQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<QuestionFormInput
|
<Label htmlFor="subheader">{t("common.description")}</Label>
|
||||||
id="subheader"
|
<div className="mt-2">
|
||||||
value={question.subheader}
|
<LocalizedEditor
|
||||||
label={t("common.description")}
|
id="subheader"
|
||||||
localSurvey={localSurvey}
|
value={question.html}
|
||||||
questionIdx={questionIdx}
|
localSurvey={localSurvey}
|
||||||
isInvalid={isInvalid}
|
isInvalid={isInvalid}
|
||||||
updateQuestion={updateQuestion}
|
updateQuestion={updateQuestion}
|
||||||
selectedLanguageCode={selectedLanguageCode}
|
selectedLanguageCode={selectedLanguageCode}
|
||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
firstRender={firstRender}
|
||||||
isStorageConfigured={isStorageConfigured}
|
setFirstRender={setFirstRender}
|
||||||
/>
|
questionIdx={questionIdx}
|
||||||
|
locale={locale}
|
||||||
|
questionId={question.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<OptionsSwitch
|
<OptionsSwitch
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { PlusIcon } from "lucide-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 { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||||
@@ -53,7 +53,6 @@ export const DateQuestionForm = ({
|
|||||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const [parent] = useAutoAnimate();
|
const [parent] = useAutoAnimate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<form>
|
||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
@@ -68,7 +67,6 @@ export const DateQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
<div ref={parent}>
|
<div ref={parent}>
|
||||||
{question.subheader !== undefined && (
|
{question.subheader !== undefined && (
|
||||||
@@ -77,7 +75,7 @@ export const DateQuestionForm = ({
|
|||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
id="subheader"
|
id="subheader"
|
||||||
value={question.subheader}
|
value={question.subheader}
|
||||||
label={t("common.description")}
|
label={t("environments.surveys.edit.description")}
|
||||||
localSurvey={localSurvey}
|
localSurvey={localSurvey}
|
||||||
questionIdx={questionIdx}
|
questionIdx={questionIdx}
|
||||||
isInvalid={isInvalid}
|
isInvalid={isInvalid}
|
||||||
@@ -86,7 +84,6 @@ export const DateQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createId } from "@paralleldrive/cuid2";
|
|||||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { GripIcon, Handshake, Undo2 } from "lucide-react";
|
import { GripIcon, Handshake, Undo2 } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
TSurveyQuestionId,
|
TSurveyQuestionId,
|
||||||
TSurveyRedirectUrlCard,
|
TSurveyRedirectUrlCard,
|
||||||
} from "@formbricks/types/surveys/types";
|
} from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
@@ -65,13 +64,8 @@ export const EditEndingCard = ({
|
|||||||
isStorageConfigured,
|
isStorageConfigured,
|
||||||
quotas,
|
quotas,
|
||||||
}: EditEndingCardProps) => {
|
}: EditEndingCardProps) => {
|
||||||
|
const endingCard = localSurvey.endings[endingCardIndex];
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const endingCard = useMemo(
|
|
||||||
() => localSurvey.endings[endingCardIndex],
|
|
||||||
[localSurvey.endings, endingCardIndex]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isRedirectToUrlDisabled = isFormbricksCloud
|
const isRedirectToUrlDisabled = isFormbricksCloud
|
||||||
? plan === "free" && endingCard.type !== "redirectToUrl"
|
? plan === "free" && endingCard.type !== "redirectToUrl"
|
||||||
: false;
|
: false;
|
||||||
@@ -101,30 +95,10 @@ export const EditEndingCard = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSurvey = (
|
const updateSurvey = (data: Partial<TSurveyEndScreenCard> | Partial<TSurveyRedirectUrlCard>) => {
|
||||||
data: Partial<TSurveyEndScreenCard & { _forceUpdate?: boolean }> | Partial<TSurveyRedirectUrlCard>
|
|
||||||
) => {
|
|
||||||
setLocalSurvey((prevSurvey) => {
|
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) =>
|
const updatedEndings = prevSurvey.endings.map((ending, idx) =>
|
||||||
idx === endingCardIndex ? { ...ending, ...filteredData } : ending
|
idx === endingCardIndex ? { ...ending, ...data } : ending
|
||||||
);
|
);
|
||||||
return { ...prevSurvey, endings: updatedEndings };
|
return { ...prevSurvey, endings: updatedEndings };
|
||||||
});
|
});
|
||||||
@@ -242,11 +216,9 @@ export const EditEndingCard = ({
|
|||||||
selectedLanguageCode
|
selectedLanguageCode
|
||||||
]
|
]
|
||||||
? formatTextWithSlashes(
|
? formatTextWithSlashes(
|
||||||
getTextContent(
|
recallToHeadline(endingCard.headline, localSurvey, true, selectedLanguageCode)[
|
||||||
recallToHeadline(endingCard.headline, localSurvey, true, selectedLanguageCode)[
|
selectedLanguageCode
|
||||||
selectedLanguageCode
|
]
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
: t("environments.surveys.edit.ending_card"))}
|
: t("environments.surveys.edit.ending_card"))}
|
||||||
{endingCard.type === "redirectToUrl" &&
|
{endingCard.type === "redirectToUrl" &&
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { EditWelcomeCard } from "@/modules/survey/editor/components/edit-welcome
|
|||||||
|
|
||||||
vi.mock("@/lib/cn");
|
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", () => ({
|
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||||
QuestionFormInput: vi.fn(({ value, id }) => (
|
QuestionFormInput: vi.fn(({ value, id }) => (
|
||||||
<input data-testid={`question-form-input-${id}`} defaultValue={value?.default}></input>
|
<input data-testid={`question-form-input-${id}`} defaultValue={value?.default}></input>
|
||||||
@@ -47,7 +53,7 @@ const mockSurvey = {
|
|||||||
mockSurvey.welcomeCard = {
|
mockSurvey.welcomeCard = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headline: { default: "Welcome!" },
|
headline: { default: "Welcome!" },
|
||||||
subheader: { default: "<p>Thank you for participating.</p>" },
|
html: { default: "<p>Thank you for participating.</p>" },
|
||||||
buttonLabel: { default: "Start Survey" },
|
buttonLabel: { default: "Start Survey" },
|
||||||
timeToFinish: true,
|
timeToFinish: true,
|
||||||
showResponseCount: false,
|
showResponseCount: false,
|
||||||
@@ -103,9 +109,7 @@ describe("EditWelcomeCard", () => {
|
|||||||
expect(screen.getByLabelText("common.on")).toBeInTheDocument();
|
expect(screen.getByLabelText("common.on")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("file-input")).toBeInTheDocument();
|
expect(screen.getByTestId("file-input")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("question-form-input-headline")).toHaveValue("Welcome!");
|
expect(screen.getByTestId("question-form-input-headline")).toHaveValue("Welcome!");
|
||||||
expect(screen.getByTestId("question-form-input-subheader")).toHaveValue(
|
expect(screen.getByTestId("localized-editor-html")).toHaveValue("<p>Thank you for participating.</p>");
|
||||||
"<p>Thank you for participating.</p>"
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("question-form-input-buttonLabel")).toHaveValue("Start Survey");
|
expect(screen.getByTestId("question-form-input-buttonLabel")).toHaveValue("Start Survey");
|
||||||
expect(screen.getByLabelText("common.time_to_finish")).toBeInTheDocument();
|
expect(screen.getByLabelText("common.time_to_finish")).toBeInTheDocument();
|
||||||
const timeToFinishSwitch = screen.getAllByRole("switch")[1]; // Assuming the second switch is for timeToFinish
|
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 { TSurvey, TSurveyQuestionId, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { cn } from "@/lib/cn";
|
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 { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||||
import { FileInput } from "@/modules/ui/components/file-input";
|
import { FileInput } from "@/modules/ui/components/file-input";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
import { Label } from "@/modules/ui/components/label";
|
||||||
@@ -37,8 +38,8 @@ export const EditWelcomeCard = ({
|
|||||||
isStorageConfigured = true,
|
isStorageConfigured = true,
|
||||||
}: EditWelcomeCardProps) => {
|
}: EditWelcomeCardProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const [firstRender, setFirstRender] = useState(true);
|
|
||||||
|
|
||||||
|
const [firstRender, setFirstRender] = useState(true);
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
const environmentId = path?.split("/environments/")[1]?.split("/")[0];
|
const environmentId = path?.split("/environments/")[1]?.split("/")[0];
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export const EditWelcomeCard = ({
|
|||||||
const setOpen = (e) => {
|
const setOpen = (e) => {
|
||||||
if (e) {
|
if (e) {
|
||||||
setActiveQuestionId("start");
|
setActiveQuestionId("start");
|
||||||
|
setFirstRender(true);
|
||||||
} else {
|
} else {
|
||||||
setActiveQuestionId(null);
|
setActiveQuestionId(null);
|
||||||
}
|
}
|
||||||
@@ -137,26 +139,26 @@ export const EditWelcomeCard = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
firstRender={firstRender}
|
|
||||||
setFirstRender={setFirstRender}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<QuestionFormInput
|
<Label htmlFor="subheader">{t("environments.surveys.edit.welcome_message")}</Label>
|
||||||
id="subheader"
|
<div className="mt-2">
|
||||||
value={localSurvey.welcomeCard.subheader}
|
<LocalizedEditor
|
||||||
label={t("environments.surveys.edit.welcome_message")}
|
id="html"
|
||||||
localSurvey={localSurvey}
|
value={localSurvey.welcomeCard.html}
|
||||||
questionIdx={-1}
|
localSurvey={localSurvey}
|
||||||
isInvalid={isInvalid}
|
isInvalid={isInvalid}
|
||||||
updateSurvey={updateSurvey}
|
updateQuestion={updateSurvey}
|
||||||
selectedLanguageCode={selectedLanguageCode}
|
selectedLanguageCode={selectedLanguageCode}
|
||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
firstRender={firstRender}
|
||||||
isStorageConfigured={isStorageConfigured}
|
setFirstRender={setFirstRender}
|
||||||
firstRender={firstRender}
|
questionIdx={-1}
|
||||||
setFirstRender={setFirstRender}
|
locale={locale}
|
||||||
/>
|
questionId="start"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex justify-between gap-8">
|
<div className="mt-3 flex justify-between gap-8">
|
||||||
@@ -176,8 +178,6 @@ export const EditWelcomeCard = ({
|
|||||||
label={t("environments.surveys.edit.next_button_label")}
|
label={t("environments.surveys.edit.next_button_label")}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
firstRender={firstRender}
|
|
||||||
setFirstRender={setFirstRender}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -140,13 +140,9 @@ describe("EndScreenForm", () => {
|
|||||||
|
|
||||||
if (buttonElement) {
|
if (buttonElement) {
|
||||||
await userEvent.click(buttonElement);
|
await userEvent.click(buttonElement);
|
||||||
// Check that the subheader was added (may be called multiple times due to autoFocus)
|
expect(mockUpdateSurvey).toHaveBeenCalledWith({
|
||||||
expect(mockUpdateSurvey).toHaveBeenCalledWith(
|
subheader: expect.any(Object),
|
||||||
expect.objectContaining({
|
});
|
||||||
subheader: expect.any(Object),
|
|
||||||
_forceUpdate: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { PlusIcon } from "lucide-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 { TSurvey, TSurveyEndScreenCard } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@/lib/i18n/utils";
|
import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
@@ -20,7 +21,7 @@ interface EndScreenFormProps {
|
|||||||
isInvalid: boolean;
|
isInvalid: boolean;
|
||||||
selectedLanguageCode: string;
|
selectedLanguageCode: string;
|
||||||
setSelectedLanguageCode: (languageCode: string) => void;
|
setSelectedLanguageCode: (languageCode: string) => void;
|
||||||
updateSurvey: (input: Partial<TSurveyEndScreenCard & { _forceUpdate?: boolean }>) => void;
|
updateSurvey: (input: Partial<TSurveyEndScreenCard>) => void;
|
||||||
endingCard: TSurveyEndScreenCard;
|
endingCard: TSurveyEndScreenCard;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
isStorageConfigured: boolean;
|
isStorageConfigured: boolean;
|
||||||
@@ -45,7 +46,6 @@ export const EndScreenForm = ({
|
|||||||
endingCard.type === "endScreen" &&
|
endingCard.type === "endScreen" &&
|
||||||
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
|
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<form>
|
||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
@@ -60,7 +60,6 @@ export const EndScreenForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
{endingCard.subheader !== undefined && (
|
{endingCard.subheader !== undefined && (
|
||||||
@@ -78,7 +77,6 @@ export const EndScreenForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,10 +89,8 @@ export const EndScreenForm = ({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Directly update the state, bypassing the guard in updateSurvey
|
|
||||||
updateSurvey({
|
updateSurvey({
|
||||||
subheader: createI18nString("", surveyLanguageCodes),
|
subheader: createI18nString("", surveyLanguageCodes),
|
||||||
_forceUpdate: true,
|
|
||||||
});
|
});
|
||||||
}}>
|
}}>
|
||||||
<PlusIcon className="mr-1 h-4 w-4" />
|
<PlusIcon className="mr-1 h-4 w-4" />
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ export const FileUploadQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
<div ref={parent}>
|
<div ref={parent}>
|
||||||
{question.subheader !== undefined && (
|
{question.subheader !== undefined && (
|
||||||
@@ -163,7 +162,6 @@ export const FileUploadQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export const MatrixQuestionForm = ({
|
|||||||
show: true,
|
show: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
/// Auto animate
|
||||||
const [parent] = useAutoAnimate();
|
const [parent] = useAutoAnimate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -200,7 +201,6 @@ export const MatrixQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
<div ref={parent}>
|
<div ref={parent}>
|
||||||
{question.subheader !== undefined && (
|
{question.subheader !== undefined && (
|
||||||
@@ -218,7 +218,6 @@ export const MatrixQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -195,7 +195,6 @@ export const MultipleChoiceQuestionForm = ({
|
|||||||
|
|
||||||
// Auto animate
|
// Auto animate
|
||||||
const [parent] = useAutoAnimate();
|
const [parent] = useAutoAnimate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<form>
|
||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
@@ -210,7 +209,6 @@ export const MultipleChoiceQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={parent}>
|
<div ref={parent}>
|
||||||
@@ -229,7 +227,6 @@ export const MultipleChoiceQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { PlusIcon } from "lucide-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 { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||||
@@ -38,6 +38,7 @@ export const NPSQuestionForm = ({
|
|||||||
}: NPSQuestionFormProps): JSX.Element => {
|
}: NPSQuestionFormProps): JSX.Element => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||||
|
// Auto animate
|
||||||
const [parent] = useAutoAnimate();
|
const [parent] = useAutoAnimate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +55,6 @@ export const NPSQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={parent}>
|
<div ref={parent}>
|
||||||
@@ -73,7 +73,6 @@ export const NPSQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,6 +88,7 @@ export const NPSQuestionForm = ({
|
|||||||
subheader: createI18nString("", surveyLanguageCodes),
|
subheader: createI18nString("", surveyLanguageCodes),
|
||||||
});
|
});
|
||||||
}}>
|
}}>
|
||||||
|
{" "}
|
||||||
<PlusIcon className="mr-1 h-4 w-4" />
|
<PlusIcon className="mr-1 h-4 w-4" />
|
||||||
{t("environments.surveys.edit.add_description")}
|
{t("environments.surveys.edit.add_description")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -86,16 +86,15 @@ export const OpenQuestionForm = ({
|
|||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
id="headline"
|
id="headline"
|
||||||
value={question.headline}
|
value={question.headline}
|
||||||
label={t("environments.surveys.edit.question") + "*"}
|
|
||||||
localSurvey={localSurvey}
|
localSurvey={localSurvey}
|
||||||
questionIdx={questionIdx}
|
questionIdx={questionIdx}
|
||||||
isInvalid={isInvalid}
|
isInvalid={isInvalid}
|
||||||
updateQuestion={updateQuestion}
|
updateQuestion={updateQuestion}
|
||||||
selectedLanguageCode={selectedLanguageCode}
|
selectedLanguageCode={selectedLanguageCode}
|
||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
|
label={t("environments.surveys.edit.question") + "*"}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={parent}>
|
<div ref={parent}>
|
||||||
@@ -105,16 +104,15 @@ export const OpenQuestionForm = ({
|
|||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
id="subheader"
|
id="subheader"
|
||||||
value={question.subheader}
|
value={question.subheader}
|
||||||
label={t("common.description")}
|
|
||||||
localSurvey={localSurvey}
|
localSurvey={localSurvey}
|
||||||
questionIdx={questionIdx}
|
questionIdx={questionIdx}
|
||||||
isInvalid={isInvalid}
|
isInvalid={isInvalid}
|
||||||
updateQuestion={updateQuestion}
|
updateQuestion={updateQuestion}
|
||||||
selectedLanguageCode={selectedLanguageCode}
|
selectedLanguageCode={selectedLanguageCode}
|
||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
|
label={t("common.description")}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { PlusIcon } from "lucide-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 { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
@@ -70,14 +70,14 @@ export const PictureSelectionForm = ({
|
|||||||
choices: updatedChoices,
|
choices: updatedChoices,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
// Auto animate
|
||||||
const [parent] = useAutoAnimate();
|
const [parent] = useAutoAnimate();
|
||||||
return (
|
return (
|
||||||
<form>
|
<form>
|
||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
id="headline"
|
id="headline"
|
||||||
value={question.headline}
|
|
||||||
label={t("environments.surveys.edit.question") + "*"}
|
label={t("environments.surveys.edit.question") + "*"}
|
||||||
|
value={question.headline}
|
||||||
localSurvey={localSurvey}
|
localSurvey={localSurvey}
|
||||||
questionIdx={questionIdx}
|
questionIdx={questionIdx}
|
||||||
isInvalid={isInvalid}
|
isInvalid={isInvalid}
|
||||||
@@ -86,7 +86,6 @@ export const PictureSelectionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
<div ref={parent}>
|
<div ref={parent}>
|
||||||
{question.subheader !== undefined && (
|
{question.subheader !== undefined && (
|
||||||
@@ -104,7 +103,6 @@ export const PictureSelectionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
TSurveyQuestionId,
|
TSurveyQuestionId,
|
||||||
TSurveyQuestionTypeEnum,
|
TSurveyQuestionTypeEnum,
|
||||||
} from "@formbricks/types/surveys/types";
|
} from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
@@ -224,17 +223,18 @@ export const QuestionCard = ({
|
|||||||
aria-label="Toggle question details">
|
aria-label="Toggle question details">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex grow">
|
<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">
|
<div className="flex grow flex-col justify-center" dir="auto">
|
||||||
<h3 className="text-sm font-semibold">
|
<h3 className="text-sm font-semibold">
|
||||||
{recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
{recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||||
selectedLanguageCode
|
selectedLanguageCode
|
||||||
]
|
]
|
||||||
? formatTextWithSlashes(
|
? formatTextWithSlashes(
|
||||||
getTextContent(
|
recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||||
recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
selectedLanguageCode
|
||||||
selectedLanguageCode
|
] ?? ""
|
||||||
] ?? ""
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
: getTSurveyQuestionTypeEnumName(question.type, t)}
|
: getTSurveyQuestionTypeEnumName(question.type, t)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ export const RankingQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={parent}>
|
<div ref={parent}>
|
||||||
@@ -150,7 +149,6 @@ export const RankingQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export const RatingQuestionForm = ({
|
|||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||||
const [parent] = useAutoAnimate();
|
const [parent] = useAutoAnimate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<form>
|
||||||
<QuestionFormInput
|
<QuestionFormInput
|
||||||
@@ -54,7 +53,6 @@ export const RatingQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.headline?.default || question.headline.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={parent}>
|
<div ref={parent}>
|
||||||
@@ -73,7 +71,6 @@ export const RatingQuestionForm = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
autoFocus={!question.subheader?.default || question.subheader.default.trim() === ""}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1599,5 +1599,25 @@ describe("Survey Editor Utils", () => {
|
|||||||
|
|
||||||
expect(result).toBe(10); // Index of question11
|
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,
|
TSurveyVariable,
|
||||||
TSurveyWelcomeCard,
|
TSurveyWelcomeCard,
|
||||||
} from "@formbricks/types/surveys/types";
|
} from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { isConditionGroup } from "@/lib/surveyLogic/utils";
|
import { isConditionGroup } from "@/lib/surveyLogic/utils";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
@@ -121,10 +120,9 @@ export const getConditionValueOptions = (
|
|||||||
.forEach((question) => {
|
.forEach((question) => {
|
||||||
if (question.type === TSurveyQuestionTypeEnum.Matrix) {
|
if (question.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||||
// Rows submenu
|
// Rows submenu
|
||||||
const questionHeadline = getTextContent(getLocalizedValue(question.headline, "default"));
|
|
||||||
const rows = question.rows.map((row, rowIdx) => ({
|
const rows = question.rows.map((row, rowIdx) => ({
|
||||||
icon: getQuestionIconMapping(t)[question.type],
|
icon: getQuestionIconMapping(t)[question.type],
|
||||||
label: `${getLocalizedValue(row.label, "default")} (${questionHeadline})`,
|
label: `${getLocalizedValue(row.label, "default")} (${getLocalizedValue(question.headline, "default")})`,
|
||||||
value: `${question.id}.${rowIdx}`,
|
value: `${question.id}.${rowIdx}`,
|
||||||
meta: {
|
meta: {
|
||||||
type: "question",
|
type: "question",
|
||||||
@@ -134,7 +132,7 @@ export const getConditionValueOptions = (
|
|||||||
|
|
||||||
questionOptions.push({
|
questionOptions.push({
|
||||||
icon: getQuestionIconMapping(t)[question.type],
|
icon: getQuestionIconMapping(t)[question.type],
|
||||||
label: questionHeadline,
|
label: getLocalizedValue(question.headline, "default"),
|
||||||
value: question.id,
|
value: question.id,
|
||||||
meta: {
|
meta: {
|
||||||
type: "question",
|
type: "question",
|
||||||
@@ -157,7 +155,7 @@ export const getConditionValueOptions = (
|
|||||||
} else {
|
} else {
|
||||||
questionOptions.push({
|
questionOptions.push({
|
||||||
icon: getQuestionIconMapping(t)[question.type],
|
icon: getQuestionIconMapping(t)[question.type],
|
||||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
label: getLocalizedValue(question.headline, "default"),
|
||||||
value: question.id,
|
value: question.id,
|
||||||
meta: {
|
meta: {
|
||||||
type: "question",
|
type: "question",
|
||||||
@@ -379,7 +377,7 @@ export const getMatchValueProps = (
|
|||||||
const questionOptions = allowedQuestions.map((question) => {
|
const questionOptions = allowedQuestions.map((question) => {
|
||||||
return {
|
return {
|
||||||
icon: getQuestionIconMapping(t)[question.type],
|
icon: getQuestionIconMapping(t)[question.type],
|
||||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
label: getLocalizedValue(question.headline, "default"),
|
||||||
value: question.id,
|
value: question.id,
|
||||||
meta: {
|
meta: {
|
||||||
type: "question",
|
type: "question",
|
||||||
@@ -929,7 +927,7 @@ export const getActionTargetOptions = (
|
|||||||
const questionOptions = questions.map((question) => {
|
const questionOptions = questions.map((question) => {
|
||||||
return {
|
return {
|
||||||
icon: getQuestionIconMapping(t)[question.type],
|
icon: getQuestionIconMapping(t)[question.type],
|
||||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
label: getLocalizedValue(question.headline, "default"),
|
||||||
value: question.id,
|
value: question.id,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -940,8 +938,7 @@ export const getActionTargetOptions = (
|
|||||||
return {
|
return {
|
||||||
label:
|
label:
|
||||||
ending.type === "endScreen"
|
ending.type === "endScreen"
|
||||||
? getTextContent(getLocalizedValue(ending.headline, "default")) ||
|
? getLocalizedValue(ending.headline, "default") || t("environments.surveys.edit.end_screen_card")
|
||||||
t("environments.surveys.edit.end_screen_card")
|
|
||||||
: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
|
: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
|
||||||
value: ending.id,
|
value: ending.id,
|
||||||
};
|
};
|
||||||
@@ -1048,7 +1045,7 @@ export const getActionValueOptions = (
|
|||||||
const questionOptions = allowedQuestions.map((question) => {
|
const questionOptions = allowedQuestions.map((question) => {
|
||||||
return {
|
return {
|
||||||
icon: getQuestionIconMapping(t)[question.type],
|
icon: getQuestionIconMapping(t)[question.type],
|
||||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
label: getLocalizedValue(question.headline, "default"),
|
||||||
value: question.id,
|
value: question.id,
|
||||||
meta: {
|
meta: {
|
||||||
type: "question",
|
type: "question",
|
||||||
@@ -1106,7 +1103,7 @@ export const getActionValueOptions = (
|
|||||||
const questionOptions = allowedQuestions.map((question) => {
|
const questionOptions = allowedQuestions.map((question) => {
|
||||||
return {
|
return {
|
||||||
icon: getQuestionIconMapping(t)[question.type],
|
icon: getQuestionIconMapping(t)[question.type],
|
||||||
label: getTextContent(getLocalizedValue(question.headline, "default")),
|
label: getLocalizedValue(question.headline, "default"),
|
||||||
value: question.id,
|
value: question.id,
|
||||||
meta: {
|
meta: {
|
||||||
type: "question",
|
type: "question",
|
||||||
@@ -1280,7 +1277,7 @@ const checkWelcomeCardForRecall = (welcomeCard: TSurveyWelcomeCard, recallPatter
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
checkTextForRecallPattern(welcomeCard.headline, recallPattern) ||
|
checkTextForRecallPattern(welcomeCard.headline, recallPattern) ||
|
||||||
checkTextForRecallPattern(welcomeCard.subheader, recallPattern)
|
checkTextForRecallPattern(welcomeCard.html, recallPattern)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1295,6 +1292,11 @@ const checkQuestionForRecall = (question: TSurveyQuestion, recallPattern: string
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check html field (for consent and CTA questions)
|
||||||
|
if ("html" in question && checkTextForRecallPattern(question.html, recallPattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1427,10 +1429,8 @@ export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: strin
|
|||||||
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
return survey.questions.findIndex((question) => question.logic?.some(isUsedInLogicRule));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSurveyFollowUpActionDefaultBody = (t: TFnType): string => {
|
export const getSurveyFollowUpActionDefaultBody = (t: TFnType) => {
|
||||||
return t("templates.follow_ups_modal_action_body")
|
return t("templates.follow_ups_modal_action_body") as string;
|
||||||
.replaceAll(/[\u200B-\u200D\uFEFF]/g, "")
|
|
||||||
.trim();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const findEndingCardUsedInLogic = (survey: TSurvey, endingCardId: string): number => {
|
export const findEndingCardUsedInLogic = (survey: TSurvey, endingCardId: string): number => {
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ describe("validation.isWelcomeCardValid", () => {
|
|||||||
const baseWelcomeCard: TSurveyWelcomeCard = {
|
const baseWelcomeCard: TSurveyWelcomeCard = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headline: { default: "Welcome", en: "Welcome", de: "Willkommen" },
|
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,
|
timeToFinish: false,
|
||||||
showResponseCount: false,
|
showResponseCount: false,
|
||||||
};
|
};
|
||||||
@@ -197,13 +197,13 @@ describe("validation.isWelcomeCardValid", () => {
|
|||||||
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return false if subheader is invalid (when subheader is provided)", () => {
|
test("should return false if html is invalid (when html is provided)", () => {
|
||||||
const card = { ...baseWelcomeCard, subheader: { default: "<p>Info</p>", en: "<p>Info</p>", de: " " } };
|
const card = { ...baseWelcomeCard, html: { default: "<p>Info</p>", en: "<p>Info</p>", de: " " } };
|
||||||
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return true if subheader is undefined", () => {
|
test("should return true if html is undefined", () => {
|
||||||
const card = { ...baseWelcomeCard, subheader: undefined };
|
const card = { ...baseWelcomeCard, html: undefined };
|
||||||
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(true);
|
expect(validation.isWelcomeCardValid(card, surveyLanguagesEnabled)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -372,7 +372,7 @@ describe("validation.validateQuestion", () => {
|
|||||||
type: TSurveyQuestionTypeEnum.Consent,
|
type: TSurveyQuestionTypeEnum.Consent,
|
||||||
headline: { default: "Consent", en: "Consent", de: "Zustimmung" },
|
headline: { default: "Consent", en: "Consent", de: "Zustimmung" },
|
||||||
label: { default: "I agree", en: "I agree", de: "Ich stimme zu" },
|
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", () => {
|
test("should return true for a valid Consent question", () => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
TSurveyRedirectUrlCard,
|
TSurveyRedirectUrlCard,
|
||||||
TSurveyWelcomeCard,
|
TSurveyWelcomeCard,
|
||||||
} from "@formbricks/types/surveys/types";
|
} 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 { extractLanguageCodes, getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { checkForEmptyFallBackValue } from "@/lib/utils/recall";
|
import { checkForEmptyFallBackValue } from "@/lib/utils/recall";
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export const isLabelValidForAllLanguages = (
|
|||||||
});
|
});
|
||||||
const languageCodes = extractLanguageCodes(filteredLanguages);
|
const languageCodes = extractLanguageCodes(filteredLanguages);
|
||||||
const languages = languageCodes.length === 0 ? ["default"] : languageCodes;
|
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
|
// Validation logic for multiple choice questions
|
||||||
@@ -145,7 +145,7 @@ export const validationRules = {
|
|||||||
let isValid = isHeadlineValid && isSubheaderValid;
|
let isValid = isHeadlineValid && isSubheaderValid;
|
||||||
const defaultLanguageCode = "default";
|
const defaultLanguageCode = "default";
|
||||||
//question specific fields
|
//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
|
// Remove backButtonLabel from validation if it is the first question
|
||||||
if (isFirstQuestion) {
|
if (isFirstQuestion) {
|
||||||
@@ -210,7 +210,7 @@ const isContentValid = (content: Record<string, string> | undefined, surveyLangu
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => {
|
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 = (
|
export const isEndingCardValid = (
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ describe("FollowUpItem", () => {
|
|||||||
},
|
},
|
||||||
endings: [],
|
endings: [],
|
||||||
welcomeCard: {
|
welcomeCard: {
|
||||||
subheader: {
|
html: {
|
||||||
default: "Thanks for providing your feedback - let's go!",
|
default: "Thanks for providing your feedback - let's go!",
|
||||||
},
|
},
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export const getQuestionTypes = (t: TFnType): TQuestion[] => [
|
|||||||
icon: MousePointerClickIcon,
|
icon: MousePointerClickIcon,
|
||||||
preset: {
|
preset: {
|
||||||
headline: createI18nString("", []),
|
headline: createI18nString("", []),
|
||||||
subheader: createI18nString("", []),
|
html: createI18nString("", []),
|
||||||
buttonLabel: createI18nString(t("templates.book_interview"), []),
|
buttonLabel: createI18nString(t("templates.book_interview"), []),
|
||||||
buttonExternal: false,
|
buttonExternal: false,
|
||||||
dismissButtonLabel: createI18nString(t("templates.skip"), []),
|
dismissButtonLabel: createI18nString(t("templates.skip"), []),
|
||||||
@@ -215,7 +215,7 @@ export const getQuestionTypes = (t: TFnType): TQuestion[] => [
|
|||||||
icon: CheckIcon,
|
icon: CheckIcon,
|
||||||
preset: {
|
preset: {
|
||||||
headline: createI18nString("", []),
|
headline: createI18nString("", []),
|
||||||
subheader: createI18nString("", []),
|
html: createI18nString("", []),
|
||||||
label: createI18nString("", []),
|
label: createI18nString("", []),
|
||||||
buttonLabel: createI18nString(t("templates.next"), []),
|
buttonLabel: createI18nString(t("templates.next"), []),
|
||||||
backButtonLabel: createI18nString(t("templates.back"), []),
|
backButtonLabel: createI18nString(t("templates.back"), []),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Toaster, toast } from "react-hot-toast";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TProjectStyling } from "@formbricks/types/project";
|
import { TProjectStyling } from "@formbricks/types/project";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
@@ -175,7 +174,7 @@ export const VerifyEmail = ({
|
|||||||
{localSurvey.questions.map((question, index) => (
|
{localSurvey.questions.map((question, index) => (
|
||||||
<p
|
<p
|
||||||
key={index}
|
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>
|
</div>
|
||||||
<Button variant="ghost" className="mt-6" onClick={handlePreviewClick}>
|
<Button variant="ghost" className="mt-6" onClick={handlePreviewClick}>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe("data", () => {
|
|||||||
timeToFinish: false,
|
timeToFinish: false,
|
||||||
showResponseCount: false,
|
showResponseCount: false,
|
||||||
headline: { default: "Welcome" },
|
headline: { default: "Welcome" },
|
||||||
subheader: { default: "" },
|
html: { default: "" },
|
||||||
buttonLabel: { default: "Start" },
|
buttonLabel: { default: "Start" },
|
||||||
},
|
},
|
||||||
questions: [],
|
questions: [],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
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 { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||||
import { getPrefillValue } from "./utils";
|
import { getPrefillValue } from "./utils";
|
||||||
@@ -85,6 +85,7 @@ describe("survey link utils", () => {
|
|||||||
required: false,
|
required: false,
|
||||||
logic: [],
|
logic: [],
|
||||||
buttonLabel: { default: "Click me" },
|
buttonLabel: { default: "Click me" },
|
||||||
|
html: { default: "" },
|
||||||
subheader: { default: "" },
|
subheader: { default: "" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -136,7 +137,7 @@ describe("survey link utils", () => {
|
|||||||
welcomeCard: {
|
welcomeCard: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headline: { default: "Welcome" },
|
headline: { default: "Welcome" },
|
||||||
subheader: { default: "" },
|
html: { default: "" },
|
||||||
buttonLabel: { default: "Start" },
|
buttonLabel: { default: "Start" },
|
||||||
},
|
},
|
||||||
hiddenFields: {},
|
hiddenFields: {},
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { CodeHighlightNode, CodeNode } from "@lexical/code";
|
|||||||
import { AutoLinkNode, LinkNode } from "@lexical/link";
|
import { AutoLinkNode, LinkNode } from "@lexical/link";
|
||||||
import { ListItemNode, ListNode } from "@lexical/list";
|
import { ListItemNode, ListNode } from "@lexical/list";
|
||||||
import { TRANSFORMERS } from "@lexical/markdown";
|
import { TRANSFORMERS } from "@lexical/markdown";
|
||||||
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
|
|
||||||
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
||||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||||
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
|
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
|
||||||
@@ -55,8 +54,6 @@ export type TextEditorProps = {
|
|||||||
selectedLanguageCode?: string;
|
selectedLanguageCode?: string;
|
||||||
fallbacks?: { [id: string]: string };
|
fallbacks?: { [id: string]: string };
|
||||||
addFallback?: () => void;
|
addFallback?: () => void;
|
||||||
autoFocus?: boolean;
|
|
||||||
id?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const editorConfig = {
|
const editorConfig = {
|
||||||
@@ -121,15 +118,10 @@ export const Editor = (props: TextEditorProps) => {
|
|||||||
style={{ height: props.height }}>
|
style={{ height: props.height }}>
|
||||||
<RichTextPlugin
|
<RichTextPlugin
|
||||||
contentEditable={
|
contentEditable={
|
||||||
<ContentEditable
|
<ContentEditable style={{ height: props.height }} className="editor-input" />
|
||||||
style={{ height: props.height }}
|
|
||||||
className="editor-input"
|
|
||||||
aria-labelledby={props.id}
|
|
||||||
dir="auto"
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
placeholder={
|
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 ?? ""}
|
{props.placeholder ?? ""}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -138,7 +130,6 @@ export const Editor = (props: TextEditorProps) => {
|
|||||||
<ListPlugin />
|
<ListPlugin />
|
||||||
<LinkPlugin />
|
<LinkPlugin />
|
||||||
<AutoLinkPlugin />
|
<AutoLinkPlugin />
|
||||||
{props.autoFocus && <AutoFocusPlugin />}
|
|
||||||
{props.localSurvey && props.questionId && props.selectedLanguageCode && (
|
{props.localSurvey && props.questionId && props.selectedLanguageCode && (
|
||||||
<RecallPlugin
|
<RecallPlugin
|
||||||
localSurvey={props.localSurvey}
|
localSurvey={props.localSurvey}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cleanup, render } from "@testing-library/react";
|
|||||||
import { $applyNodeReplacement } from "lexical";
|
import { $applyNodeReplacement } from "lexical";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
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";
|
import { $createRecallNode, RecallNode, RecallPayload, SerializedRecallNode } from "./recall-node";
|
||||||
|
|
||||||
vi.mock("lexical", () => ({
|
vi.mock("lexical", () => ({
|
||||||
@@ -23,19 +23,6 @@ vi.mock("@/lib/utils/recall", () => ({
|
|||||||
replaceRecallInfoWithUnderline: vi.fn((label: string) => {
|
replaceRecallInfoWithUnderline: vi.fn((label: string) => {
|
||||||
return label.replace(/#recall:[^#]+#/g, "___");
|
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", () => {
|
describe("RecallNode", () => {
|
||||||
@@ -366,15 +353,15 @@ describe("RecallNode", () => {
|
|||||||
expect(span?.textContent).toContain("@");
|
expect(span?.textContent).toContain("@");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("calls getTextContentWithRecallTruncated with label", () => {
|
test("calls replaceRecallInfoWithUnderline with label", () => {
|
||||||
const node = new RecallNode(mockPayload);
|
const node = new RecallNode(mockPayload);
|
||||||
node.decorate();
|
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", () => {
|
test("handles label with nested recall patterns", () => {
|
||||||
vi.mocked(getTextContentWithRecallTruncated).mockReturnValueOnce("Processed Label");
|
vi.mocked(replaceRecallInfoWithUnderline).mockReturnValueOnce("Processed Label");
|
||||||
|
|
||||||
const payloadWithNestedRecall: RecallPayload = {
|
const payloadWithNestedRecall: RecallPayload = {
|
||||||
recallItem: {
|
recallItem: {
|
||||||
@@ -389,7 +376,7 @@ describe("RecallNode", () => {
|
|||||||
const decorated = node.decorate();
|
const decorated = node.decorate();
|
||||||
|
|
||||||
const { container } = render(<>{decorated}</>);
|
const { container } = render(<>{decorated}</>);
|
||||||
expect(vi.mocked(getTextContentWithRecallTruncated)).toHaveBeenCalledWith(
|
expect(vi.mocked(replaceRecallInfoWithUnderline)).toHaveBeenCalledWith(
|
||||||
"What is your #recall:name/fallback:name# answer?"
|
"What is your #recall:name/fallback:name# answer?"
|
||||||
);
|
);
|
||||||
expect(container.textContent).toContain("@Processed Label");
|
expect(container.textContent).toContain("@Processed Label");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { DOMConversionMap, DOMConversionOutput, DOMExportOutput, NodeKey, S
|
|||||||
import { $applyNodeReplacement, DecoratorNode } from "lexical";
|
import { $applyNodeReplacement, DecoratorNode } from "lexical";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
|
||||||
|
|
||||||
export interface RecallPayload {
|
export interface RecallPayload {
|
||||||
recallItem: TSurveyRecallItem;
|
recallItem: TSurveyRecallItem;
|
||||||
@@ -134,13 +134,12 @@ export class RecallNode extends DecoratorNode<ReactNode> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
decorate(): ReactNode {
|
decorate(): ReactNode {
|
||||||
const displayLabel = getTextContentWithRecallTruncated(this.__recallItem.label);
|
const displayLabel = replaceRecallInfoWithUnderline(this.__recallItem.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="recall-node z-30 inline-flex h-fit justify-center whitespace-nowrap rounded-md bg-slate-100 text-sm text-slate-700"
|
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}`}
|
aria-label={`Recall: ${displayLabel}`}>
|
||||||
title={displayLabel}>
|
|
||||||
@{displayLabel}
|
@{displayLabel}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -223,15 +223,7 @@ export const RecallPlugin = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[localSurvey, selectedLanguageCode, editor, convertTextToRecallNodes, findAllRecallNodes]
|
||||||
findAllRecallNodes,
|
|
||||||
localSurvey,
|
|
||||||
selectedLanguageCode,
|
|
||||||
setRecallItems,
|
|
||||||
setFallbacks,
|
|
||||||
editor,
|
|
||||||
convertTextToRecallNodes,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle @ key press for recall trigger
|
// Handle @ key press for recall trigger
|
||||||
@@ -268,7 +260,7 @@ export const RecallPlugin = ({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[editor, setShowRecallItemSelect]
|
[editor]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
@@ -285,7 +277,7 @@ export const RecallPlugin = ({
|
|||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}
|
}
|
||||||
}, [setShowRecallItemSelect, showRecallItemSelect]);
|
}, [showRecallItemSelect]);
|
||||||
|
|
||||||
// Clean up when dropdown closes
|
// Clean up when dropdown closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -393,13 +385,11 @@ export const RecallPlugin = ({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
editor,
|
editor,
|
||||||
setShowRecallItemSelect,
|
|
||||||
recallItems,
|
|
||||||
setRecallItems,
|
|
||||||
atSymbolPosition,
|
atSymbolPosition,
|
||||||
replaceAtSymbolWithStoredPosition,
|
replaceAtSymbolWithStoredPosition,
|
||||||
replaceAtSymbolWithCurrentSelection,
|
replaceAtSymbolWithCurrentSelection,
|
||||||
onShowFallbackInput,
|
onShowFallbackInput,
|
||||||
|
recallItems,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import {
|
|||||||
$getRoot,
|
$getRoot,
|
||||||
$getSelection,
|
$getSelection,
|
||||||
$isRangeSelection,
|
$isRangeSelection,
|
||||||
|
COMMAND_PRIORITY_CRITICAL,
|
||||||
FORMAT_TEXT_COMMAND,
|
FORMAT_TEXT_COMMAND,
|
||||||
|
PASTE_COMMAND,
|
||||||
SELECTION_CHANGE_COMMAND,
|
SELECTION_CHANGE_COMMAND,
|
||||||
} from "lexical";
|
} from "lexical";
|
||||||
import { AtSign, Bold, ChevronDownIcon, Italic, Link, PencilIcon, Underline } from "lucide-react";
|
import { AtSign, Bold, ChevronDownIcon, Italic, Link, PencilIcon, Underline } from "lucide-react";
|
||||||
@@ -310,8 +312,25 @@ export const ToolbarPlugin = (
|
|||||||
}
|
}
|
||||||
}, [editor, isLink, props]);
|
}, [editor, isLink, props]);
|
||||||
|
|
||||||
// Removed custom PASTE_COMMAND handler to allow Lexical's default paste handler
|
useEffect(() => {
|
||||||
// to properly preserve rich text formatting (bold, italic, links, etc.)
|
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 <></>;
|
if (!props.editable) return <></>;
|
||||||
|
|
||||||
@@ -404,20 +423,18 @@ export const ToolbarPlugin = (
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
{items.map(({ key, icon, onClick, active, tooltipText, disabled }) =>
|
||||||
{items.map(({ key, icon, onClick, active, tooltipText, disabled }) =>
|
!props.excludedToolbarItems?.includes(key) ? (
|
||||||
!props.excludedToolbarItems?.includes(key) ? (
|
<ToolbarButton
|
||||||
<ToolbarButton
|
key={key}
|
||||||
key={key}
|
icon={icon}
|
||||||
icon={icon}
|
active={active}
|
||||||
active={active}
|
disabled={disabled}
|
||||||
disabled={disabled}
|
onClick={onClick}
|
||||||
onClick={onClick}
|
tooltipText={tooltipText}
|
||||||
tooltipText={tooltipText}
|
/>
|
||||||
/>
|
) : null
|
||||||
) : null
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
text-align: left;
|
||||||
border-color: #cbd5e1;
|
border-color: #cbd5e1;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
@@ -35,11 +36,11 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
border-bottom-left-radius: 6px;
|
border-bottom-left-radius: 6px;
|
||||||
border-bottom-right-radius: 6px;
|
border-bottom-right-radius: 6px;
|
||||||
overflow-y: auto;
|
overflow: auto;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: var(--editor-min-height, 48px);
|
min-height: var(--editor-min-height, 40px);
|
||||||
max-height: 200px;
|
max-height: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-input {
|
.editor-input {
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
tab-size: 1;
|
tab-size: 1;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
padding: 5px 10px 10px 10px;
|
padding: 10px 10px;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,4 +349,4 @@ i.link {
|
|||||||
|
|
||||||
.inactive-button {
|
.inactive-button {
|
||||||
color: #777;
|
color: #777;
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import { surveys } from "@/playwright/utils/mock";
|
import { surveys } from "@/playwright/utils/mock";
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
import * as helper from "./utils/helper";
|
|
||||||
import { createSurvey, createSurveyWithLogic, uploadFileForFileUploadQuestion } from "./utils/helper";
|
import { createSurvey, createSurveyWithLogic, uploadFileForFileUploadQuestion } from "./utils/helper";
|
||||||
|
|
||||||
test.use({
|
test.use({
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
slowMo: 150,
|
slowMo: 110,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Survey Create & Submit Response without logic", async () => {
|
test.describe("Survey Create & Submit Response without logic", async () => {
|
||||||
// 5 minutes
|
|
||||||
test.setTimeout(1000 * 60 * 5);
|
|
||||||
|
|
||||||
let url: string | null;
|
let url: string | null;
|
||||||
|
|
||||||
test("Create survey and submit response", async ({ page, users }) => {
|
test("Create survey and submit response", async ({ page, users }) => {
|
||||||
@@ -202,9 +198,18 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
|
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
|
||||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
|
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
|
||||||
await page.getByRole("cell", { name: "Roses – 0" }).locator("div").click();
|
await page
|
||||||
await page.getByRole("cell", { name: "Trees – 0" }).locator("div").click();
|
.getByRole("cell", { name: "How much do you love these flowers?: Roses – 0" })
|
||||||
await page.getByRole("cell", { name: "Ocean – 0" }).locator("div").click();
|
.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();
|
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
|
||||||
|
|
||||||
// Address Question
|
// Address Question
|
||||||
@@ -238,8 +243,8 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Multi Language Survey Create", async () => {
|
test.describe("Multi Language Survey Create", async () => {
|
||||||
// 5 minutes
|
// 4 minutes
|
||||||
test.setTimeout(1000 * 60 * 5);
|
test.setTimeout(1000 * 60 * 4);
|
||||||
|
|
||||||
test("Create Survey", async ({ page, users }) => {
|
test("Create Survey", async ({ page, users }) => {
|
||||||
const user = await users.create();
|
const user = await users.create();
|
||||||
@@ -280,7 +285,7 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
// Add questions in default language
|
// Add questions in default language
|
||||||
await page.getByText("Add question").click();
|
await page.getByText("Add question").click();
|
||||||
await page.getByRole("button", { name: "Single-Select" }).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 1").fill(surveys.createAndSubmit.singleSelectQuestion.options[0]);
|
||||||
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.singleSelectQuestion.options[1]);
|
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.singleSelectQuestion.options[1]);
|
||||||
|
|
||||||
@@ -290,7 +295,7 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).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 1").fill(surveys.createAndSubmit.multiSelectQuestion.options[0]);
|
||||||
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.multiSelectQuestion.options[1]);
|
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.multiSelectQuestion.options[1]);
|
||||||
await page.getByPlaceholder("Option 3").fill(surveys.createAndSubmit.multiSelectQuestion.options[2]);
|
await page.getByPlaceholder("Option 3").fill(surveys.createAndSubmit.multiSelectQuestion.options[2]);
|
||||||
@@ -300,11 +305,7 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Picture Selection" }).click();
|
await page.getByRole("button", { name: "Picture Selection" }).click();
|
||||||
await helper.fillRichTextEditor(
|
await page.getByLabel("Question*").fill(surveys.createAndSubmit.pictureSelectQuestion.question);
|
||||||
page,
|
|
||||||
"Question*",
|
|
||||||
surveys.createAndSubmit.pictureSelectQuestion.question
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle file uploads
|
// Handle file uploads
|
||||||
await uploadFileForFileUploadQuestion(page);
|
await uploadFileForFileUploadQuestion(page);
|
||||||
@@ -315,7 +316,7 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Rating" }).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("Not good").fill(surveys.createAndSubmit.ratingQuestion.lowLabel);
|
||||||
await page.getByPlaceholder("Very satisfied").fill(surveys.createAndSubmit.ratingQuestion.highLabel);
|
await page.getByPlaceholder("Very satisfied").fill(surveys.createAndSubmit.ratingQuestion.highLabel);
|
||||||
|
|
||||||
@@ -325,7 +326,7 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).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("Lower label").fill(surveys.createAndSubmit.npsQuestion.lowLabel);
|
||||||
await page.getByLabel("Upper label").fill(surveys.createAndSubmit.npsQuestion.highLabel);
|
await page.getByLabel("Upper label").fill(surveys.createAndSubmit.npsQuestion.highLabel);
|
||||||
|
|
||||||
@@ -335,7 +336,7 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Date" }).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
|
await page
|
||||||
.locator("div")
|
.locator("div")
|
||||||
@@ -343,7 +344,7 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "File Upload" }).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
|
await page
|
||||||
.locator("div")
|
.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" }).scrollIntoViewIfNeeded();
|
||||||
await page.getByRole("button", { name: "Matrix" }).click();
|
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").click();
|
||||||
await page.locator("#row-0").fill(surveys.createAndSubmit.matrix.rows[0]);
|
await page.locator("#row-0").fill(surveys.createAndSubmit.matrix.rows[0]);
|
||||||
await page.locator("#row-1").click();
|
await page.locator("#row-1").click();
|
||||||
@@ -378,7 +379,7 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Address" }).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: "Address Line 2" }).getByRole("switch").nth(1).click();
|
||||||
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
||||||
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).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)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Ranking" }).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").click();
|
||||||
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.ranking.choices[0]);
|
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.ranking.choices[0]);
|
||||||
await page.getByPlaceholder("Option 2").click();
|
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: "English" }).nth(1).click();
|
||||||
await page.getByRole("button", { name: "German" }).click();
|
await page.getByRole("button", { name: "German" }).click();
|
||||||
|
|
||||||
// Fill welcome card in german using rich text editor helper
|
// Fill welcome card in german
|
||||||
await helper.fillRichTextEditor(page, "Note*", surveys.germanCreate.welcomeCard.headline);
|
await page.locator(".editor-input").click();
|
||||||
await helper.fillRichTextEditor(page, "Welcome message", surveys.germanCreate.welcomeCard.description);
|
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").click();
|
||||||
await page.getByPlaceholder("Next").fill(surveys.germanCreate.welcomeCard.buttonLabel);
|
await page.getByPlaceholder("Next").fill(surveys.germanCreate.welcomeCard.buttonLabel);
|
||||||
|
|
||||||
// Fill Open text question in german
|
// Fill Open text question in german
|
||||||
await page.getByRole("main").getByText("Free text").click();
|
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").click();
|
||||||
await page.getByLabel("Placeholder").fill(surveys.germanCreate.openTextQuestion.placeholder);
|
await page.getByLabel("Placeholder").fill(surveys.germanCreate.openTextQuestion.placeholder);
|
||||||
await page.getByText("Show Advanced settings").first().click();
|
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
|
// Fill Single select question in german
|
||||||
await page.getByRole("main").getByText("Single-Select").click();
|
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").click();
|
||||||
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.singleSelectQuestion.options[0]);
|
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.singleSelectQuestion.options[0]);
|
||||||
await page.getByPlaceholder("Option 2").click();
|
await page.getByPlaceholder("Option 2").click();
|
||||||
@@ -442,7 +451,10 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
// Fill Multi select question in german
|
// Fill Multi select question in german
|
||||||
await page.getByRole("main").getByRole("heading", { name: "Multi-Select" }).click();
|
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").click();
|
||||||
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.multiSelectQuestion.options[0]);
|
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.multiSelectQuestion.options[0]);
|
||||||
await page.getByPlaceholder("Option 2").click();
|
await page.getByPlaceholder("Option 2").click();
|
||||||
@@ -457,7 +469,10 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
|
|
||||||
// Fill Picture select question in german
|
// Fill Picture select question in german
|
||||||
await page.getByRole("main").getByText("Picture Selection").click();
|
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.getByText("Show Advanced settings").first().click();
|
||||||
await page.getByPlaceholder("Next").click();
|
await page.getByPlaceholder("Next").click();
|
||||||
await page.getByPlaceholder("Next").fill(surveys.germanCreate.next);
|
await page.getByPlaceholder("Next").fill(surveys.germanCreate.next);
|
||||||
@@ -466,7 +481,10 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
|
|
||||||
// Fill Rating question in german
|
// Fill Rating question in german
|
||||||
await page.getByRole("main").getByText("Rating").click();
|
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").click();
|
||||||
await page.getByPlaceholder("Not good").fill(surveys.germanCreate.ratingQuestion.lowLabel);
|
await page.getByPlaceholder("Not good").fill(surveys.germanCreate.ratingQuestion.lowLabel);
|
||||||
await page.getByPlaceholder("Very satisfied").click();
|
await page.getByPlaceholder("Very satisfied").click();
|
||||||
@@ -477,7 +495,8 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
|
|
||||||
// Fill NPS question in german
|
// Fill NPS question in german
|
||||||
await page.getByRole("main").getByText("Net Promoter Score (NPS)").click();
|
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").click();
|
||||||
await page.getByLabel("Lower Label").fill(surveys.germanCreate.npsQuestion.lowLabel);
|
await page.getByLabel("Lower Label").fill(surveys.germanCreate.npsQuestion.lowLabel);
|
||||||
await page.getByLabel("Upper Label").click();
|
await page.getByLabel("Upper Label").click();
|
||||||
@@ -488,7 +507,10 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
|
|
||||||
// Fill Date question in german
|
// Fill Date question in german
|
||||||
await page.getByRole("main").getByText("Date").click();
|
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.getByText("Show Advanced settings").first().click();
|
||||||
await page.getByPlaceholder("Next").click();
|
await page.getByPlaceholder("Next").click();
|
||||||
await page.getByPlaceholder("Next").fill(surveys.germanCreate.next);
|
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
|
// Fill File upload question in german
|
||||||
await page.getByRole("main").getByText("File Upload").click();
|
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.getByText("Show Advanced settings").first().click();
|
||||||
await page.getByPlaceholder("Next").click();
|
await page.getByPlaceholder("Next").click();
|
||||||
await page.getByPlaceholder("Next").fill(surveys.germanCreate.next);
|
await page.getByPlaceholder("Next").fill(surveys.germanCreate.next);
|
||||||
@@ -506,7 +531,8 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
|
|
||||||
// Fill Matrix question in german
|
// Fill Matrix question in german
|
||||||
await page.getByRole("main").getByText("Matrix").click();
|
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").click();
|
||||||
await page.locator("#row-0").fill(surveys.germanCreate.matrix.rows[0]);
|
await page.locator("#row-0").fill(surveys.germanCreate.matrix.rows[0]);
|
||||||
await page.locator("#row-1").click();
|
await page.locator("#row-1").click();
|
||||||
@@ -529,7 +555,10 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
|
|
||||||
// Fill Address question in german
|
// Fill Address question in german
|
||||||
await page.getByRole("main").getByText("Address").click();
|
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"]').click();
|
||||||
await page
|
await page
|
||||||
.locator('[id="addressLine1\\.placeholder"]')
|
.locator('[id="addressLine1\\.placeholder"]')
|
||||||
@@ -560,7 +589,8 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
|
|
||||||
// Fill Ranking question in german
|
// Fill Ranking question in german
|
||||||
await page.getByRole("main").getByText("Ranking").click();
|
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").click();
|
||||||
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.ranking.choices[0]);
|
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.ranking.choices[0]);
|
||||||
await page.getByPlaceholder("Option 2").click();
|
await page.getByPlaceholder("Option 2").click();
|
||||||
@@ -579,8 +609,12 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
|
|
||||||
// Fill Thank you card in german
|
// Fill Thank you card in german
|
||||||
await page.getByText("Ending card").first().click();
|
await page.getByText("Ending card").first().click();
|
||||||
await helper.fillRichTextEditor(page, "Note*", surveys.germanCreate.endingCard.headline);
|
await page.getByPlaceholder("Your question here. Recall").click();
|
||||||
await helper.fillRichTextEditor(page, "Description", surveys.germanCreate.endingCard.description);
|
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();
|
await page.locator("#showButton").check();
|
||||||
|
|
||||||
@@ -610,8 +644,8 @@ test.describe("Multi Language Survey Create", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Testing Survey with advanced logic", async () => {
|
test.describe("Testing Survey with advanced logic", async () => {
|
||||||
// 8 minutes
|
// 6 minutes
|
||||||
test.setTimeout(1000 * 60 * 8);
|
test.setTimeout(1000 * 60 * 6);
|
||||||
let url: string | null;
|
let url: string | null;
|
||||||
|
|
||||||
test("Create survey and submit response", async ({ page, users }) => {
|
test("Create survey and submit response", async ({ page, users }) => {
|
||||||
@@ -785,9 +819,9 @@ test.describe("Testing Survey with advanced logic", async () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Next" })).toBeVisible();
|
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Next" })).toBeVisible();
|
||||||
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Back" })).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: "This is my Matrix Question: Roses – 0" }).locator("div").click();
|
||||||
await page.getByRole("cell", { name: "Trees – 0" }).locator("div").click();
|
await page.getByRole("cell", { name: "This is my Matrix Question: 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: Ocean – 0" }).locator("div").click();
|
||||||
await page.locator("#questionCard-7").getByRole("button", { name: "Next" }).click();
|
await page.locator("#questionCard-7").getByRole("button", { name: "Next" }).click();
|
||||||
|
|
||||||
// CTA Question
|
// 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();
|
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) => {
|
export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||||
const addQuestion = "Add questionAdd a new question to your survey";
|
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 expect(page.locator("#welcome-toggle")).toBeVisible();
|
||||||
await page.getByText("Welcome Card").click();
|
await page.getByText("Welcome Card").click();
|
||||||
await page.locator("#welcome-toggle").check();
|
await page.locator("#welcome-toggle").check();
|
||||||
|
await page.getByLabel("Note*").fill(params.welcomeCard.headline);
|
||||||
// Use the helper function for rich text editors
|
await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description);
|
||||||
await fillRichTextEditor(page, "Note*", params.welcomeCard.headline);
|
|
||||||
await fillRichTextEditor(page, "Welcome message", params.welcomeCard.description);
|
|
||||||
|
|
||||||
await page.getByText("Welcome CardOn").click();
|
await page.getByText("Welcome CardOn").click();
|
||||||
|
|
||||||
// Open Text Question
|
// Open Text Question
|
||||||
await page.getByRole("main").getByText("What would you like to know?").click();
|
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 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.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||||
|
|
||||||
await page.locator("h3").filter({ hasText: params.openTextQuestion.question }).click();
|
await page.locator("h3").filter({ hasText: params.openTextQuestion.question }).click();
|
||||||
@@ -215,9 +190,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Single-Select" }).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 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 1").fill(params.singleSelectQuestion.options[0]);
|
||||||
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
||||||
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
|
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
|
||||||
@@ -229,9 +204,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).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 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 1").fill(params.multiSelectQuestion.options[0]);
|
||||||
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
|
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
|
||||||
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
|
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
|
||||||
@@ -243,9 +218,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Rating" }).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 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("Not good").fill(params.ratingQuestion.lowLabel);
|
||||||
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
|
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
|
||||||
|
|
||||||
@@ -256,7 +231,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).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("Lower label").fill(params.npsQuestion.lowLabel);
|
||||||
await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel);
|
await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel);
|
||||||
|
|
||||||
@@ -267,7 +242,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Statement (Call to Action)" }).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);
|
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
|
||||||
|
|
||||||
// Consent Question
|
// Consent Question
|
||||||
@@ -277,7 +252,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Consent" }).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);
|
await page.getByPlaceholder("I agree to the terms and").fill(params.consentQuestion.checkboxLabel);
|
||||||
|
|
||||||
// Picture Select Question
|
// Picture Select Question
|
||||||
@@ -287,9 +262,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Picture Selection" }).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 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
|
// Handle file uploads
|
||||||
await uploadFileForFileUploadQuestion(page);
|
await uploadFileForFileUploadQuestion(page);
|
||||||
@@ -301,7 +276,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "File Upload" }).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
|
// Matrix Upload Question
|
||||||
await page
|
await page
|
||||||
@@ -310,9 +285,9 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Matrix" }).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 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").click();
|
||||||
await page.locator("#row-0").fill(params.matrix.rows[0]);
|
await page.locator("#row-0").fill(params.matrix.rows[0]);
|
||||||
await page.locator("#row-1").click();
|
await page.locator("#row-1").click();
|
||||||
@@ -338,7 +313,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Address" }).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: "Address Line 2" }).getByRole("switch").nth(1).click();
|
||||||
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
||||||
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).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)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Contact Info" }).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: "Last Name" }).getByRole("switch").nth(1).click();
|
||||||
await page.getByRole("row", { name: "Email" }).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();
|
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)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Ranking" }).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").click();
|
||||||
await page.getByPlaceholder("Option 1").fill(params.ranking.choices[0]);
|
await page.getByPlaceholder("Option 1").fill(params.ranking.choices[0]);
|
||||||
await page.getByPlaceholder("Option 2").click();
|
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 expect(page.locator("#welcome-toggle")).toBeVisible();
|
||||||
await page.getByText("Welcome Card").click();
|
await page.getByText("Welcome Card").click();
|
||||||
await page.locator("#welcome-toggle").check();
|
await page.locator("#welcome-toggle").check();
|
||||||
|
await page.getByLabel("Note*").fill(params.welcomeCard.headline);
|
||||||
// Use the helper function for rich text editors
|
await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description);
|
||||||
await fillRichTextEditor(page, "Note*", params.welcomeCard.headline);
|
|
||||||
await fillRichTextEditor(page, "Welcome message", params.welcomeCard.description);
|
|
||||||
|
|
||||||
await page.getByText("Welcome CardOn").click();
|
await page.getByText("Welcome CardOn").click();
|
||||||
|
|
||||||
// Open Text Question
|
// Open Text Question
|
||||||
await page.getByRole("main").getByText("What would you like to know?").click();
|
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 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.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||||
|
|
||||||
await page.locator("h3").filter({ hasText: params.openTextQuestion.question }).click();
|
await page.locator("h3").filter({ hasText: params.openTextQuestion.question }).click();
|
||||||
@@ -431,9 +403,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Single-Select" }).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 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 1").fill(params.singleSelectQuestion.options[0]);
|
||||||
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
||||||
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
|
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
|
||||||
@@ -445,9 +417,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).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 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 1").fill(params.multiSelectQuestion.options[0]);
|
||||||
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
|
await page.getByPlaceholder("Option 2").fill(params.multiSelectQuestion.options[1]);
|
||||||
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
|
await page.getByPlaceholder("Option 3").fill(params.multiSelectQuestion.options[2]);
|
||||||
@@ -459,9 +431,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Picture Selection" }).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 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 fileInput = page.locator('input[type="file"]');
|
||||||
const response1 = await fetch("https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg");
|
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");
|
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)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Rating" }).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 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("Not good").fill(params.ratingQuestion.lowLabel);
|
||||||
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
|
await page.getByPlaceholder("Very satisfied").fill(params.ratingQuestion.highLabel);
|
||||||
|
|
||||||
@@ -501,7 +473,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).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("Lower label").fill(params.npsQuestion.lowLabel);
|
||||||
await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel);
|
await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel);
|
||||||
|
|
||||||
@@ -512,7 +484,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Ranking" }).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").click();
|
||||||
await page.getByPlaceholder("Option 1").fill(params.ranking.choices[0]);
|
await page.getByPlaceholder("Option 1").fill(params.ranking.choices[0]);
|
||||||
await page.getByPlaceholder("Option 2").click();
|
await page.getByPlaceholder("Option 2").click();
|
||||||
@@ -534,9 +506,9 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Matrix" }).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 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").click();
|
||||||
await page.locator("#row-0").fill(params.matrix.rows[0]);
|
await page.locator("#row-0").fill(params.matrix.rows[0]);
|
||||||
await page.locator("#row-1").click();
|
await page.locator("#row-1").click();
|
||||||
@@ -562,7 +534,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Statement (Call to Action)" }).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);
|
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
|
||||||
|
|
||||||
// Consent Question
|
// Consent Question
|
||||||
@@ -572,7 +544,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Consent" }).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);
|
await page.getByPlaceholder("I agree to the terms and").fill(params.consentQuestion.checkboxLabel);
|
||||||
|
|
||||||
// File Upload Question
|
// File Upload Question
|
||||||
@@ -582,7 +554,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "File Upload" }).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
|
// Date Question
|
||||||
await page
|
await page
|
||||||
@@ -591,7 +563,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Date" }).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
|
// Cal Question
|
||||||
await page
|
await page
|
||||||
@@ -600,7 +572,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Schedule a meeting" }).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
|
// Fill Address Question
|
||||||
await page
|
await page
|
||||||
@@ -609,7 +581,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("button", { name: "Address" }).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: "Address Line 2" }).getByRole("switch").nth(1).click();
|
||||||
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
|
||||||
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
|
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
|
||||||
|
|||||||
@@ -192,7 +192,6 @@
|
|||||||
"icon": "lightbulb",
|
"icon": "lightbulb",
|
||||||
"pages": [
|
"pages": [
|
||||||
"xm-and-surveys/xm/best-practices/contact-form",
|
"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/docs-feedback",
|
||||||
"xm-and-surveys/xm/best-practices/feature-chaser",
|
"xm-and-surveys/xm/best-practices/feature-chaser",
|
||||||
"xm-and-surveys/xm/best-practices/feedback-box",
|
"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> {
|
async function createMigration(): Promise<void> {
|
||||||
// Log the full path to verify directory location
|
// 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
|
// Check if migrations directory exists, create if not
|
||||||
const hasAccess = await fs
|
const hasAccess = await fs
|
||||||
@@ -27,7 +27,7 @@ async function createMigration(): Promise<void> {
|
|||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
await fs.mkdir(migrationsDir, { recursive: true });
|
await fs.mkdir(migrationsDir, { recursive: true });
|
||||||
logger.info({ migrationsDir }, `Created migrations directory`);
|
logger.info(`Created migrations directory: ${migrationsDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const migrationNameSpaced = await promptForMigrationName();
|
const migrationNameSpaced = await promptForMigrationName();
|
||||||
@@ -60,11 +60,11 @@ async function createMigration(): Promise<void> {
|
|||||||
|
|
||||||
// Create the migration directory
|
// Create the migration directory
|
||||||
await fs.mkdir(fullMigrationPath, { recursive: true });
|
await fs.mkdir(fullMigrationPath, { recursive: true });
|
||||||
logger.info({ fullMigrationPath }, "Created migration directory");
|
logger.info(fullMigrationPath, "Created migration directory");
|
||||||
|
|
||||||
// Create the migration file
|
// Create the migration file
|
||||||
await fs.writeFile(filePath, getTemplateContent(migrationFunctionName, migrationNameTimestamped));
|
await fs.writeFile(filePath, getTemplateContent(migrationFunctionName, migrationNameTimestamped));
|
||||||
logger.info({ filePath }, "New migration created");
|
logger.info(filePath, "New migration created");
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptForMigrationName(): Promise<string> {
|
function promptForMigrationName(): Promise<string> {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const ZSurveyBase = z.object({
|
|||||||
timeToFinish: z.boolean(),
|
timeToFinish: z.boolean(),
|
||||||
showResponseCount: z.boolean(),
|
showResponseCount: z.boolean(),
|
||||||
headline: z.record(z.string()).optional(),
|
headline: z.record(z.string()).optional(),
|
||||||
subheader: z.record(z.string()).optional(),
|
html: z.record(z.string()).optional(),
|
||||||
fileUrl: z.string().optional(),
|
fileUrl: z.string().optional(),
|
||||||
buttonLabel: z.record(z.string()).optional(),
|
buttonLabel: z.record(z.string()).optional(),
|
||||||
videoUrl: z.string().optional(),
|
videoUrl: z.string().optional(),
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import DOMPurify from "isomorphic-dompurify";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { isValidHTML } from "@/lib/html-utils";
|
|
||||||
|
|
||||||
interface HeadlineProps {
|
interface HeadlineProps {
|
||||||
headline: string;
|
headline: string;
|
||||||
@@ -9,12 +7,8 @@ interface HeadlineProps {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
alignTextCenter?: boolean;
|
alignTextCenter?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Headline({ headline, questionId, required = true, alignTextCenter = false }: HeadlineProps) {
|
export function Headline({ headline, questionId, required = true, alignTextCenter = false }: HeadlineProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isHeadlineHtml = isValidHTML(headline);
|
|
||||||
const safeHtml = isHeadlineHtml && headline ? DOMPurify.sanitize(headline, { ADD_ATTR: ["target"] }) : "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label htmlFor={questionId} className="fb-text-heading fb-mb-[3px] fb-flex fb-flex-col">
|
<label htmlFor={questionId} className="fb-text-heading fb-mb-[3px] fb-flex fb-flex-col">
|
||||||
{!required && (
|
{!required && (
|
||||||
@@ -28,17 +22,9 @@ export function Headline({ headline, questionId, required = true, alignTextCente
|
|||||||
<div
|
<div
|
||||||
className={`fb-flex fb-items-center ${alignTextCenter ? "fb-justify-center" : "fb-justify-between"}`}
|
className={`fb-flex fb-items-center ${alignTextCenter ? "fb-justify-center" : "fb-justify-between"}`}
|
||||||
dir="auto">
|
dir="auto">
|
||||||
{isHeadlineHtml ? (
|
<p data-testid="fb__surveys__headline-text-test" className="fb-text-base fb-font-semibold">
|
||||||
<div
|
{headline}
|
||||||
data-testid="fb__surveys__headline-text-test"
|
</p>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</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,
|
required: true,
|
||||||
subheader: { default: "" },
|
subheader: { default: "" },
|
||||||
format: "M-d-y" as const,
|
format: "M-d-y" as const,
|
||||||
|
html: { default: "" },
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<QuestionConditional {...baseProps} question={question} value="" />);
|
render(<QuestionConditional {...baseProps} question={question} value="" />);
|
||||||
|
|||||||
@@ -10,23 +10,30 @@ describe("Subheader", () => {
|
|||||||
test("renders subheader text when provided", () => {
|
test("renders subheader text when provided", () => {
|
||||||
const subheaderText = "Test subheader text";
|
const subheaderText = "Test subheader text";
|
||||||
const questionId = "q1";
|
const questionId = "q1";
|
||||||
const { container } = render(<Subheader subheader={subheaderText} questionId={questionId} />);
|
render(<Subheader subheader={subheaderText} questionId={questionId} />);
|
||||||
|
|
||||||
const labelElement = container.querySelector(`label[for="${questionId}"]`);
|
const subheaderElement = screen.getByText(subheaderText);
|
||||||
expect(labelElement).toBeInTheDocument();
|
expect(subheaderElement).toBeInTheDocument();
|
||||||
expect(labelElement?.textContent).toBe(subheaderText);
|
expect(subheaderElement.tagName).toBe("LABEL");
|
||||||
|
expect(subheaderElement).toHaveAttribute("for", questionId);
|
||||||
const textSpan = screen.getByText(subheaderText);
|
|
||||||
expect(textSpan).toBeInTheDocument();
|
|
||||||
expect(textSpan.tagName).toBe("SPAN");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null when no subheader text provided", () => {
|
test("renders empty label when no subheader text provided", () => {
|
||||||
const questionId = "q1";
|
const questionId = "q1";
|
||||||
const { container } = render(<Subheader questionId={questionId} />);
|
const { container } = render(<Subheader questionId={questionId} />);
|
||||||
|
|
||||||
const subheaderElement = container.querySelector(`label[for="${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", () => {
|
test("applies correct styling classes", () => {
|
||||||
@@ -49,33 +56,4 @@ describe("Subheader", () => {
|
|||||||
const subheaderElement = container.querySelector('label[for="q1"]');
|
const subheaderElement = container.querySelector('label[for="q1"]');
|
||||||
expect(subheaderElement).toHaveAttribute("dir", "auto");
|
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 { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { isValidHTML } from "@/lib/html-utils";
|
|
||||||
|
|
||||||
interface SubheaderProps {
|
interface SubheaderProps {
|
||||||
subheader?: string;
|
subheader?: string;
|
||||||
@@ -8,22 +6,12 @@ interface SubheaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Subheader({ subheader, questionId }: 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 (
|
return (
|
||||||
<label
|
<label
|
||||||
htmlFor={questionId}
|
htmlFor={questionId}
|
||||||
className="fb-text-subheading fb-block fb-break-words fb-text-sm fb-font-normal fb-leading-6"
|
className="fb-text-subheading fb-block fb-break-words fb-text-sm fb-font-normal fb-leading-6"
|
||||||
data-testid="subheader"
|
|
||||||
dir="auto">
|
dir="auto">
|
||||||
{isHtml ? (
|
{subheader}
|
||||||
<span className="fb-htmlbody" dangerouslySetInnerHTML={{ __html: safeHtml }} />
|
|
||||||
) : (
|
|
||||||
<span>{subheader}</span>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -681,7 +681,7 @@ export function Survey({
|
|||||||
<WelcomeCard
|
<WelcomeCard
|
||||||
key="start"
|
key="start"
|
||||||
headline={localSurvey.welcomeCard.headline}
|
headline={localSurvey.welcomeCard.headline}
|
||||||
subheader={localSurvey.welcomeCard.subheader}
|
html={localSurvey.welcomeCard.html}
|
||||||
fileUrl={localSurvey.welcomeCard.fileUrl}
|
fileUrl={localSurvey.welcomeCard.fileUrl}
|
||||||
buttonLabel={localSurvey.welcomeCard.buttonLabel}
|
buttonLabel={localSurvey.welcomeCard.buttonLabel}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("WelcomeCard", () => {
|
|||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
headline: { default: "Welcome to our survey" },
|
headline: { default: "Welcome to our survey" },
|
||||||
subheader: { default: "This is a test survey" },
|
html: { default: "This is a test survey" },
|
||||||
buttonLabel: { default: "Start" },
|
buttonLabel: { default: "Start" },
|
||||||
onSubmit: vi.fn(),
|
onSubmit: vi.fn(),
|
||||||
survey: mockSurvey,
|
survey: mockSurvey,
|
||||||
@@ -38,28 +38,17 @@ describe("WelcomeCard", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test("renders welcome card with basic content", () => {
|
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(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");
|
expect(container.querySelector("button")).toHaveTextContent("Start");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows time to complete when timeToFinish is true", () => {
|
test("shows time to complete when timeToFinish is true", () => {
|
||||||
const propsWithTimeOnly = {
|
const { container } = render(<WelcomeCard {...defaultProps} />);
|
||||||
...defaultProps,
|
|
||||||
survey: {
|
|
||||||
...mockSurvey,
|
|
||||||
welcomeCard: {
|
|
||||||
...mockSurvey.welcomeCard,
|
|
||||||
timeToFinish: true,
|
|
||||||
showResponseCount: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const { getByTestId } = render(<WelcomeCard {...propsWithTimeOnly} />);
|
|
||||||
|
|
||||||
const timeDisplay = getByTestId("fb__surveys__welcome-card__time-display");
|
const timeDisplay = container.querySelector(".fb-text-subheading");
|
||||||
expect(timeDisplay).toBeInTheDocument();
|
expect(timeDisplay).toBeInTheDocument();
|
||||||
expect(timeDisplay).toHaveTextContent(/common.takes/);
|
expect(timeDisplay).toHaveTextContent(/common.takes/);
|
||||||
});
|
});
|
||||||
@@ -111,48 +100,26 @@ describe("WelcomeCard", () => {
|
|||||||
|
|
||||||
test("calculates time to complete correctly for different survey lengths", () => {
|
test("calculates time to complete correctly for different survey lengths", () => {
|
||||||
// Test short survey (2 questions)
|
// Test short survey (2 questions)
|
||||||
const shortSurvey = {
|
const { container } = render(<WelcomeCard {...defaultProps} />);
|
||||||
...mockSurvey,
|
const timeDisplay = container.querySelector(".fb-text-subheading");
|
||||||
welcomeCard: {
|
|
||||||
...mockSurvey.welcomeCard,
|
|
||||||
timeToFinish: true,
|
|
||||||
showResponseCount: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const { getByTestId, unmount } = render(<WelcomeCard {...defaultProps} survey={shortSurvey} />);
|
|
||||||
const timeDisplay = getByTestId("fb__surveys__welcome-card__time-display");
|
|
||||||
expect(timeDisplay).toHaveTextContent(/common.takes common.less_than_x_minutes/);
|
expect(timeDisplay).toHaveTextContent(/common.takes common.less_than_x_minutes/);
|
||||||
unmount();
|
|
||||||
|
|
||||||
// Test medium survey (12 questions)
|
// Test medium survey (12 questions)
|
||||||
const mediumSurvey = {
|
const mediumSurvey = {
|
||||||
...mockSurvey,
|
...mockSurvey,
|
||||||
questions: Array(12).fill({ id: "q", logic: [] }),
|
questions: Array(12).fill({ id: "q", logic: [] }),
|
||||||
welcomeCard: {
|
|
||||||
...mockSurvey.welcomeCard,
|
|
||||||
timeToFinish: true,
|
|
||||||
showResponseCount: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
const { getByTestId: getByTestIdMedium, unmount: unmountMedium } = render(
|
const { container: mediumContainer } = render(<WelcomeCard {...defaultProps} survey={mediumSurvey} />);
|
||||||
<WelcomeCard {...defaultProps} survey={mediumSurvey} />
|
const mediumTimeDisplay = mediumContainer.querySelector(".fb-text-subheading");
|
||||||
);
|
|
||||||
const mediumTimeDisplay = getByTestIdMedium("fb__surveys__welcome-card__time-display");
|
|
||||||
expect(mediumTimeDisplay).toHaveTextContent(/common.takes common.x_minutes/);
|
expect(mediumTimeDisplay).toHaveTextContent(/common.takes common.x_minutes/);
|
||||||
unmountMedium();
|
|
||||||
|
|
||||||
// Test long survey (25 questions)
|
// Test long survey (25 questions)
|
||||||
const longSurvey = {
|
const longSurvey = {
|
||||||
...mockSurvey,
|
...mockSurvey,
|
||||||
questions: Array(25).fill({ id: "q", logic: [] }),
|
questions: Array(25).fill({ id: "q", logic: [] }),
|
||||||
welcomeCard: {
|
|
||||||
...mockSurvey.welcomeCard,
|
|
||||||
timeToFinish: true,
|
|
||||||
showResponseCount: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
const { getByTestId: getByTestIdLong } = render(<WelcomeCard {...defaultProps} survey={longSurvey} />);
|
const { container: longContainer } = render(<WelcomeCard {...defaultProps} survey={longSurvey} />);
|
||||||
const longTimeDisplay = getByTestIdLong("fb__surveys__welcome-card__time-display");
|
const longTimeDisplay = longContainer.querySelector(".fb-text-subheading");
|
||||||
expect(longTimeDisplay).toHaveTextContent(/common.takes common.x_plus_minutes/);
|
expect(longTimeDisplay).toHaveTextContent(/common.takes common.x_plus_minutes/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,7 +147,7 @@ describe("WelcomeCard", () => {
|
|||||||
const minimalProps = {
|
const minimalProps = {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
headline: undefined,
|
headline: undefined,
|
||||||
subheader: undefined,
|
html: undefined,
|
||||||
buttonLabel: undefined,
|
buttonLabel: undefined,
|
||||||
fileUrl: undefined,
|
fileUrl: undefined,
|
||||||
responseCount: undefined,
|
responseCount: undefined,
|
||||||
@@ -258,34 +225,23 @@ describe("WelcomeCard", () => {
|
|||||||
...mockSurvey,
|
...mockSurvey,
|
||||||
questions: [{ id: "dummy", logic: [] }], // Add dummy question to avoid logic error
|
questions: [{ id: "dummy", logic: [] }], // Add dummy question to avoid logic error
|
||||||
endings: [],
|
endings: [],
|
||||||
welcomeCard: {
|
|
||||||
...mockSurvey.welcomeCard,
|
|
||||||
timeToFinish: true,
|
|
||||||
showResponseCount: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
const { getByTestId: getByTestIdEmpty, unmount: unmountEmpty } = render(
|
const { container: emptyContainer } = render(
|
||||||
<WelcomeCard {...defaultProps} survey={emptyQuestionsSurvey} />
|
<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/
|
/common.takes common.less_than_x_minutes/
|
||||||
);
|
);
|
||||||
unmountEmpty();
|
|
||||||
|
|
||||||
// Test with exactly 24 questions (6 minutes boundary)
|
// Test with exactly 24 questions (6 minutes boundary)
|
||||||
const boundaryQuestionsSurvey = {
|
const boundaryQuestionsSurvey = {
|
||||||
...mockSurvey,
|
...mockSurvey,
|
||||||
questions: Array(24).fill({ id: "q", logic: [] }),
|
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} />
|
<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/
|
/common.takes common.x_minutes/
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -294,15 +250,15 @@ describe("WelcomeCard", () => {
|
|||||||
const localizedProps = {
|
const localizedProps = {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
headline: { default: "Welcome", es: "Bienvenido" },
|
headline: { default: "Welcome", es: "Bienvenido" },
|
||||||
subheader: { default: "Test", es: "Prueba" },
|
html: { default: "Test", es: "Prueba" },
|
||||||
buttonLabel: { default: "Start", es: "Comenzar" },
|
buttonLabel: { default: "Start", es: "Comenzar" },
|
||||||
languageCode: "es",
|
languageCode: "es",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { container, getByTestId } = render(<WelcomeCard {...localizedProps} />);
|
const { container } = render(<WelcomeCard {...localizedProps} />);
|
||||||
|
|
||||||
expect(container.querySelector(".fb-text-heading")).toHaveTextContent("Bienvenido");
|
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");
|
expect(container.querySelector("button")).toHaveTextContent("Comenzar");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import { getLocalizedValue } from "@/lib/i18n";
|
|||||||
import { replaceRecallInfo } from "@/lib/recall";
|
import { replaceRecallInfo } from "@/lib/recall";
|
||||||
import { calculateElementIdx } from "@/lib/utils";
|
import { calculateElementIdx } from "@/lib/utils";
|
||||||
import { Headline } from "./headline";
|
import { Headline } from "./headline";
|
||||||
import { Subheader } from "./subheader";
|
import { HtmlBody } from "./html-body";
|
||||||
|
|
||||||
interface WelcomeCardProps {
|
interface WelcomeCardProps {
|
||||||
headline?: TI18nString;
|
headline?: TI18nString;
|
||||||
subheader?: TI18nString;
|
html?: TI18nString;
|
||||||
fileUrl?: string;
|
fileUrl?: string;
|
||||||
buttonLabel?: TI18nString;
|
buttonLabel?: TI18nString;
|
||||||
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
||||||
@@ -65,7 +65,7 @@ function UsersIcon() {
|
|||||||
|
|
||||||
export function WelcomeCard({
|
export function WelcomeCard({
|
||||||
headline,
|
headline,
|
||||||
subheader,
|
html,
|
||||||
fileUrl,
|
fileUrl,
|
||||||
buttonLabel,
|
buttonLabel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -150,13 +150,8 @@ export function WelcomeCard({
|
|||||||
headline={replaceRecallInfo(getLocalizedValue(headline, languageCode), responseData, variablesData)}
|
headline={replaceRecallInfo(getLocalizedValue(headline, languageCode), responseData, variablesData)}
|
||||||
questionId="welcomeCard"
|
questionId="welcomeCard"
|
||||||
/>
|
/>
|
||||||
<Subheader
|
<HtmlBody
|
||||||
subheader={replaceRecallInfo(
|
htmlString={replaceRecallInfo(getLocalizedValue(html, languageCode), responseData, variablesData)}
|
||||||
getLocalizedValue(subheader, languageCode),
|
|
||||||
responseData,
|
|
||||||
variablesData
|
|
||||||
)}
|
|
||||||
questionId="welcomeCard"
|
|
||||||
/>
|
/>
|
||||||
<div className="fb-mt-4 fb-flex fb-gap-4 fb-pt-4">
|
<div className="fb-mt-4 fb-flex fb-gap-4 fb-pt-4">
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
@@ -174,9 +169,7 @@ export function WelcomeCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{timeToFinish && !showResponseCount ? (
|
{timeToFinish && !showResponseCount ? (
|
||||||
<div
|
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
|
||||||
className="fb-items-center fb-text-subheading fb-my-4 fb-flex"
|
|
||||||
data-testid="fb__surveys__welcome-card__time-display">
|
|
||||||
<TimerIcon />
|
<TimerIcon />
|
||||||
<p className="fb-pt-1 fb-text-xs">
|
<p className="fb-pt-1 fb-text-xs">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("ConsentQuestion", () => {
|
|||||||
id: "consent-q",
|
id: "consent-q",
|
||||||
type: TSurveyQuestionTypeEnum.Consent,
|
type: TSurveyQuestionTypeEnum.Consent,
|
||||||
headline: { default: "Consent Headline" },
|
headline: { default: "Consent Headline" },
|
||||||
subheader: { default: "Consent Subheader" },
|
html: { default: "This is the consent text" },
|
||||||
label: { default: "I agree to the terms" },
|
label: { default: "I agree to the terms" },
|
||||||
buttonLabel: { default: "Submit" },
|
buttonLabel: { default: "Submit" },
|
||||||
backButtonLabel: { default: "Back" },
|
backButtonLabel: { default: "Back" },
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type { TSurveyConsentQuestion, TSurveyQuestionId } from "@formbricks/type
|
|||||||
import { BackButton } from "@/components/buttons/back-button";
|
import { BackButton } from "@/components/buttons/back-button";
|
||||||
import { SubmitButton } from "@/components/buttons/submit-button";
|
import { SubmitButton } from "@/components/buttons/submit-button";
|
||||||
import { Headline } from "@/components/general/headline";
|
import { Headline } from "@/components/general/headline";
|
||||||
|
import { HtmlBody } from "@/components/general/html-body";
|
||||||
import { QuestionMedia } from "@/components/general/question-media";
|
import { QuestionMedia } from "@/components/general/question-media";
|
||||||
import { Subheader } from "@/components/general/subheader";
|
|
||||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||||
import { getLocalizedValue } from "@/lib/i18n";
|
import { getLocalizedValue } from "@/lib/i18n";
|
||||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||||
@@ -75,10 +75,7 @@ export function ConsentQuestion({
|
|||||||
questionId={question.id}
|
questionId={question.id}
|
||||||
required={question.required}
|
required={question.required}
|
||||||
/>
|
/>
|
||||||
<Subheader
|
<HtmlBody htmlString={getLocalizedValue(question.html, languageCode) || ""} />
|
||||||
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
|
|
||||||
questionId={question.id}
|
|
||||||
/>
|
|
||||||
<label
|
<label
|
||||||
ref={consentRef}
|
ref={consentRef}
|
||||||
tabIndex={isCurrent ? 0 : -1}
|
tabIndex={isCurrent ? 0 : -1}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ vi.mock("@/components/general/headline", () => ({
|
|||||||
Headline: vi.fn(({ headline }) => <div data-testid="headline">{headline}</div>),
|
Headline: vi.fn(({ headline }) => <div data-testid="headline">{headline}</div>),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/general/subheader", () => ({
|
vi.mock("@/components/general/html-body", () => ({
|
||||||
Subheader: vi.fn(({ subheader }) => <div data-testid="subheader">{subheader}</div>),
|
HtmlBody: vi.fn(({ htmlString }) => <div data-testid="html-body">{htmlString}</div>),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/general/question-media", () => ({
|
vi.mock("@/components/general/question-media", () => ({
|
||||||
@@ -58,7 +58,7 @@ describe("CTAQuestion", () => {
|
|||||||
id: "q1",
|
id: "q1",
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
type: TSurveyQuestionTypeEnum.CTA,
|
||||||
headline: { default: "Test Headline" },
|
headline: { default: "Test Headline" },
|
||||||
subheader: { default: "Test Subheader" },
|
html: { default: "Test HTML content" },
|
||||||
buttonLabel: { default: "Click Me" },
|
buttonLabel: { default: "Click Me" },
|
||||||
dismissButtonLabel: { default: "Skip This" },
|
dismissButtonLabel: { default: "Skip This" },
|
||||||
backButtonLabel: { default: "Go Back" },
|
backButtonLabel: { default: "Go Back" },
|
||||||
@@ -86,7 +86,7 @@ describe("CTAQuestion", () => {
|
|||||||
test("renders correctly without media", () => {
|
test("renders correctly without media", () => {
|
||||||
render(<CTAQuestion {...mockProps} />);
|
render(<CTAQuestion {...mockProps} />);
|
||||||
expect(screen.getByTestId("headline")).toBeInTheDocument();
|
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("back-button")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
|
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("question-media")).not.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 TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyCTAQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
import type { TSurveyCTAQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { BackButton } from "@/components/buttons/back-button";
|
import { BackButton } from "@/components/buttons/back-button";
|
||||||
import { SubmitButton } from "@/components/buttons/submit-button";
|
import { SubmitButton } from "@/components/buttons/submit-button";
|
||||||
import { Headline } from "@/components/general/headline";
|
import { Headline } from "@/components/general/headline";
|
||||||
|
import { HtmlBody } from "@/components/general/html-body";
|
||||||
import { QuestionMedia } from "@/components/general/question-media";
|
import { QuestionMedia } from "@/components/general/question-media";
|
||||||
import { Subheader } from "@/components/general/subheader";
|
|
||||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||||
import { getLocalizedValue } from "@/lib/i18n";
|
import { getLocalizedValue } from "@/lib/i18n";
|
||||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||||
@@ -59,10 +59,7 @@ export function CTAQuestion({
|
|||||||
questionId={question.id}
|
questionId={question.id}
|
||||||
required={question.required}
|
required={question.required}
|
||||||
/>
|
/>
|
||||||
<Subheader
|
<HtmlBody htmlString={getLocalizedValue(question.html, languageCode)} />
|
||||||
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
|
|
||||||
questionId={question.id}
|
|
||||||
/>
|
|
||||||
<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-between fb-pt-4">
|
||||||
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-start">
|
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-start">
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
|
|||||||
@@ -185,7 +185,10 @@ export function MatrixQuestion({
|
|||||||
getLocalizedValue(column.label, languageCode)
|
getLocalizedValue(column.label, languageCode)
|
||||||
: false
|
: false
|
||||||
}
|
}
|
||||||
aria-label={`${getLocalizedValue(row.label, languageCode)} – ${getLocalizedValue(
|
aria-label={`${getLocalizedValue(
|
||||||
|
question.headline,
|
||||||
|
languageCode
|
||||||
|
)}: ${getLocalizedValue(row.label, languageCode)} – ${getLocalizedValue(
|
||||||
column.label,
|
column.label,
|
||||||
languageCode
|
languageCode
|
||||||
)}`}
|
)}`}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ const mockSurvey: TJsEnvironmentStateSurvey = {
|
|||||||
welcomeCard: {
|
welcomeCard: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headline: { default: "Welcome!" },
|
headline: { default: "Welcome!" },
|
||||||
subheader: { default: "Welcome text" },
|
html: { default: "Welcome text" },
|
||||||
timeToFinish: false,
|
timeToFinish: false,
|
||||||
showResponseCount: false,
|
showResponseCount: false,
|
||||||
buttonLabel: { default: "Start" },
|
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 (
|
if (
|
||||||
(question.type === TSurveyQuestionTypeEnum.CTA || question.type === TSurveyQuestionTypeEnum.Consent) &&
|
(question.type === TSurveyQuestionTypeEnum.CTA || question.type === TSurveyQuestionTypeEnum.Consent) &&
|
||||||
question.subheader &&
|
question.html &&
|
||||||
question.subheader[languageCode].includes("recall:") &&
|
question.html[languageCode].includes("recall:") &&
|
||||||
modifiedQuestion.subheader
|
modifiedQuestion.html
|
||||||
) {
|
) {
|
||||||
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(
|
modifiedQuestion.html[languageCode] = replaceRecallInfo(
|
||||||
getLocalizedValue(modifiedQuestion.subheader, languageCode),
|
getLocalizedValue(modifiedQuestion.html, languageCode),
|
||||||
responseData,
|
responseData,
|
||||||
variables
|
variables
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,4 +122,4 @@ p.fb-editor-paragraph {
|
|||||||
|
|
||||||
.grecaptcha-badge {
|
.grecaptcha-badge {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
@@ -37,9 +37,6 @@ const config = ({ mode }) => {
|
|||||||
emptyOutDir: false,
|
emptyOutDir: false,
|
||||||
minify: "terser",
|
minify: "terser",
|
||||||
rollupOptions: {
|
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: {
|
output: {
|
||||||
inlineDynamicImports: true,
|
inlineDynamicImports: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,8 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "6.14.0",
|
"@prisma/client": "6.14.0",
|
||||||
"zod": "3.24.4",
|
"zod": "3.24.4"
|
||||||
"node-html-parser": "7.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formbricks/config-typescript": "workspace:*",
|
"@formbricks/config-typescript": "workspace:*",
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const ZSurveyWelcomeCard = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
headline: ZI18nString.optional(),
|
headline: ZI18nString.optional(),
|
||||||
subheader: ZI18nString.optional(),
|
html: ZI18nString.optional(),
|
||||||
fileUrl: z.string().optional(),
|
fileUrl: z.string().optional(),
|
||||||
buttonLabel: ZI18nString.optional(),
|
buttonLabel: ZI18nString.optional(),
|
||||||
timeToFinish: z.boolean().default(true),
|
timeToFinish: z.boolean().default(true),
|
||||||
@@ -554,6 +554,7 @@ export type TSurveyOpenTextQuestion = z.infer<typeof ZSurveyOpenTextQuestion>;
|
|||||||
|
|
||||||
export const ZSurveyConsentQuestion = ZSurveyQuestionBase.extend({
|
export const ZSurveyConsentQuestion = ZSurveyQuestionBase.extend({
|
||||||
type: z.literal(TSurveyQuestionTypeEnum.Consent),
|
type: z.literal(TSurveyQuestionTypeEnum.Consent),
|
||||||
|
html: ZI18nString.optional(),
|
||||||
label: ZI18nString,
|
label: ZI18nString,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -588,6 +589,7 @@ export type TSurveyNPSQuestion = z.infer<typeof ZSurveyNPSQuestion>;
|
|||||||
|
|
||||||
export const ZSurveyCTAQuestion = ZSurveyQuestionBase.extend({
|
export const ZSurveyCTAQuestion = ZSurveyQuestionBase.extend({
|
||||||
type: z.literal(TSurveyQuestionTypeEnum.CTA),
|
type: z.literal(TSurveyQuestionTypeEnum.CTA),
|
||||||
|
html: ZI18nString.optional(),
|
||||||
buttonUrl: z.string().optional(),
|
buttonUrl: z.string().optional(),
|
||||||
buttonExternal: z.boolean(),
|
buttonExternal: z.boolean(),
|
||||||
dismissButtonLabel: ZI18nString.optional(),
|
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(
|
multiLangIssue = validateCardFieldsForAllLanguages(
|
||||||
"welcomeCardSubheader",
|
"welcomeCardHtml",
|
||||||
welcomeCard.subheader,
|
welcomeCard.html,
|
||||||
languages,
|
languages,
|
||||||
"welcome"
|
"welcome"
|
||||||
);
|
);
|
||||||
@@ -928,7 +930,14 @@ export const ZSurvey = z
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultLanguageCode = "default";
|
const defaultLanguageCode = "default";
|
||||||
const initialFieldsToValidate = ["buttonLabel", "upperLabel", "lowerLabel", "label", "placeholder"];
|
const initialFieldsToValidate = [
|
||||||
|
"html",
|
||||||
|
"buttonLabel",
|
||||||
|
"upperLabel",
|
||||||
|
"lowerLabel",
|
||||||
|
"label",
|
||||||
|
"placeholder",
|
||||||
|
];
|
||||||
|
|
||||||
let fieldsToValidate =
|
let fieldsToValidate =
|
||||||
questionIndex === 0 || isBackButtonHidden
|
questionIndex === 0 || isBackButtonHidden
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { parse } from "node-html-parser";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type {
|
import type {
|
||||||
TActionJumpToQuestion,
|
TActionJumpToQuestion,
|
||||||
@@ -11,47 +10,6 @@ import type {
|
|||||||
TSurveyQuestionId,
|
TSurveyQuestionId,
|
||||||
} from "./types";
|
} 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 = [
|
export const FORBIDDEN_IDS = [
|
||||||
"userId",
|
"userId",
|
||||||
"source",
|
"source",
|
||||||
@@ -94,15 +52,9 @@ const validateLabelForAllLanguages = (label: TI18nString, surveyLanguages: TSurv
|
|||||||
const languageCodes = extractLanguageCodes(enabledLanguages);
|
const languageCodes = extractLanguageCodes(enabledLanguages);
|
||||||
|
|
||||||
const languages = !languageCodes.length ? ["default"] : languageCodes;
|
const languages = !languageCodes.length ? ["default"] : languageCodes;
|
||||||
const invalidLanguageCodes = languages.filter((language) => {
|
const invalidLanguageCodes = languages.filter(
|
||||||
// Check if label exists and is not undefined
|
(language) => !label[language] || label[language].trim() === ""
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
return invalidLanguageCodes.map((invalidLanguageCode) => {
|
return invalidLanguageCodes.map((invalidLanguageCode) => {
|
||||||
if (invalidLanguageCode === "default") {
|
if (invalidLanguageCode === "default") {
|
||||||
|
|||||||
Generated
-11
@@ -871,9 +871,6 @@ importers:
|
|||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: 6.14.0
|
specifier: 6.14.0
|
||||||
version: 6.14.0(prisma@6.14.0(magicast@0.3.5)(typescript@5.8.3))(typescript@5.8.3)
|
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:
|
zod:
|
||||||
specifier: 3.24.4
|
specifier: 3.24.4
|
||||||
version: 3.24.4
|
version: 3.24.4
|
||||||
@@ -8123,9 +8120,6 @@ packages:
|
|||||||
node-html-parser@6.1.13:
|
node-html-parser@6.1.13:
|
||||||
resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
|
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:
|
node-releases@2.0.19:
|
||||||
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
||||||
|
|
||||||
@@ -19496,11 +19490,6 @@ snapshots:
|
|||||||
css-select: 5.2.2
|
css-select: 5.2.2
|
||||||
he: 1.2.0
|
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: {}
|
node-releases@2.0.19: {}
|
||||||
|
|
||||||
nodemailer@7.0.9: {}
|
nodemailer@7.0.9: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user