mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-16 12:14:34 -06:00
feat: standardize URL prefilling with option ID support and MQB support (#6970)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
import Link from "next/link";
|
||||
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
|
||||
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
|
||||
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
|
||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||
|
||||
interface SurveyClientWrapperProps {
|
||||
|
||||
@@ -3,9 +3,9 @@ import { describe, expect, test } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||
import { getPrefillValue } from "./utils";
|
||||
import { getPrefillValue } from "./index";
|
||||
|
||||
describe("survey link utils", () => {
|
||||
describe("prefill integration tests", () => {
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
@@ -76,15 +76,7 @@ describe("survey link utils", () => {
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
},
|
||||
{
|
||||
id: "q7",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "CTA Question" },
|
||||
required: false,
|
||||
buttonLabel: { default: "Click me" },
|
||||
buttonExternal: false,
|
||||
buttonUrl: "",
|
||||
},
|
||||
|
||||
{
|
||||
id: "q8",
|
||||
type: TSurveyElementTypeEnum.Consent,
|
||||
@@ -162,13 +154,21 @@ describe("survey link utils", () => {
|
||||
expect(result).toEqual({ q1: "Open text answer" });
|
||||
});
|
||||
|
||||
test("validates MultipleChoiceSingle questions", () => {
|
||||
test("validates MultipleChoiceSingle questions with label", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q2", "Option 1");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q2: "Option 1" });
|
||||
});
|
||||
|
||||
test("validates MultipleChoiceSingle questions with option ID", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q2", "c2");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
// Option ID is converted to label
|
||||
expect(result).toEqual({ q2: "Option 2" });
|
||||
});
|
||||
|
||||
test("invalidates MultipleChoiceSingle with non-existent option", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q2", "Non-existent option");
|
||||
@@ -183,13 +183,29 @@ describe("survey link utils", () => {
|
||||
expect(result).toEqual({ q3: "Custom answer" });
|
||||
});
|
||||
|
||||
test("handles MultipleChoiceMulti questions", () => {
|
||||
test("handles MultipleChoiceMulti questions with labels", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q4", "Option 4,Option 5");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
|
||||
});
|
||||
|
||||
test("handles MultipleChoiceMulti questions with option IDs", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q4", "c4,c5");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
// Option IDs are converted to labels
|
||||
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
|
||||
});
|
||||
|
||||
test("handles MultipleChoiceMulti with mixed IDs and labels", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q4", "c4,Option 5");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
// Mixed: ID converted to label + label stays as-is
|
||||
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
|
||||
});
|
||||
|
||||
test("handles MultipleChoiceMulti with Other", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q5", "Option 6,Custom answer");
|
||||
@@ -211,20 +227,6 @@ describe("survey link utils", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles CTA questions with clicked value", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q7", "clicked");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q7: "clicked" });
|
||||
});
|
||||
|
||||
test("handles CTA questions with dismissed value", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q7", "dismissed");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q7: "" });
|
||||
});
|
||||
|
||||
test("validates Consent questions", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q8", "accepted");
|
||||
@@ -293,4 +295,18 @@ describe("survey link utils", () => {
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles whitespace in comma-separated values", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q4", "Option 4 , Option 5");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
|
||||
});
|
||||
|
||||
test("ignores trailing commas in multi-select", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q4", "Option 4,Option 5,");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
|
||||
});
|
||||
});
|
||||
73
apps/web/modules/survey/link/lib/prefill/index.ts
Normal file
73
apps/web/modules/survey/link/lib/prefill/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { transformElement } from "./transformers";
|
||||
import { validateElement } from "./validators";
|
||||
|
||||
/**
|
||||
* Extract prefilled values from URL search parameters
|
||||
*
|
||||
* Supports prefilling for all survey element types with the following features:
|
||||
* - Option ID or label matching for choice-based elements (single/multi-select, ranking, picture selection)
|
||||
* - Comma-separated values for multi-select and ranking
|
||||
* - Backward compatibility with label-based prefilling
|
||||
*
|
||||
* @param survey - The survey object containing blocks and elements
|
||||
* @param searchParams - URL search parameters (e.g., from useSearchParams() or new URLSearchParams())
|
||||
* @param languageId - Current language code for label matching
|
||||
* @returns Object with element IDs as keys and prefilled values, or undefined if no valid prefills
|
||||
*
|
||||
* @example
|
||||
* // Single select with option ID
|
||||
* ?questionId=option-abc123
|
||||
*
|
||||
* // Multi-select with labels (backward compatible)
|
||||
* ?questionId=Option1,Option2,Option3
|
||||
*
|
||||
* // Ranking with option IDs
|
||||
* ?rankingId=choice-3,choice-1,choice-2
|
||||
*
|
||||
* // NPS question
|
||||
* ?npsId=9
|
||||
*
|
||||
* // Multiple questions
|
||||
* ?q1=answer1&q2=10&q3=option-xyz
|
||||
*/
|
||||
export const getPrefillValue = (
|
||||
survey: TSurvey,
|
||||
searchParams: URLSearchParams,
|
||||
languageId: string
|
||||
): TResponseData | undefined => {
|
||||
const prefillData: TResponseData = {};
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
searchParams.forEach((value, key) => {
|
||||
try {
|
||||
// Skip reserved parameter names
|
||||
if (FORBIDDEN_IDS.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching element
|
||||
const element = elements.find((el) => el.id === key);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the value for this element type (returns match data)
|
||||
const validationResult = validateElement(element, value, languageId);
|
||||
if (!validationResult.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform the value using pre-matched data from validation
|
||||
const transformedValue = transformElement(validationResult, value, languageId);
|
||||
prefillData[element.id] = transformedValue;
|
||||
} catch (error) {
|
||||
// Catch any errors to prevent one bad prefill from breaking all prefills
|
||||
console.error(`[Prefill] Error processing prefill for ${key}:`, error);
|
||||
}
|
||||
});
|
||||
return Object.keys(prefillData).length > 0 ? prefillData : undefined;
|
||||
};
|
||||
94
apps/web/modules/survey/link/lib/prefill/matchers.test.ts
Normal file
94
apps/web/modules/survey/link/lib/prefill/matchers.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { matchMultipleOptionsByIdOrLabel, matchOptionByIdOrLabel } from "./matchers";
|
||||
|
||||
describe("matchOptionByIdOrLabel", () => {
|
||||
const choices = [
|
||||
{ id: "choice-1", label: { en: "First", de: "Erste" } },
|
||||
{ id: "choice-2", label: { en: "Second", de: "Zweite" } },
|
||||
{ id: "other", label: { en: "Other", de: "Andere" } },
|
||||
];
|
||||
|
||||
test("matches by ID", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "choice-1", "en");
|
||||
expect(result).toEqual(choices[0]);
|
||||
});
|
||||
|
||||
test("matches by label in English", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "First", "en");
|
||||
expect(result).toEqual(choices[0]);
|
||||
});
|
||||
|
||||
test("matches by label in German", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "Zweite", "de");
|
||||
expect(result).toEqual(choices[1]);
|
||||
});
|
||||
|
||||
test("prefers ID match over label match", () => {
|
||||
const choicesWithConflict = [
|
||||
{ id: "First", label: { en: "Not First" } },
|
||||
{ id: "choice-2", label: { en: "First" } },
|
||||
];
|
||||
const result = matchOptionByIdOrLabel(choicesWithConflict, "First", "en");
|
||||
expect(result).toEqual(choicesWithConflict[0]); // Matches by ID, not label
|
||||
});
|
||||
|
||||
test("returns null for no match", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "NonExistent", "en");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "", "en");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("handles special characters in labels", () => {
|
||||
const specialChoices = [{ id: "c1", label: { en: "Option (1)" } }];
|
||||
const result = matchOptionByIdOrLabel(specialChoices, "Option (1)", "en");
|
||||
expect(result).toEqual(specialChoices[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchMultipleOptionsByIdOrLabel", () => {
|
||||
const choices = [
|
||||
{ id: "choice-1", label: { en: "First" } },
|
||||
{ id: "choice-2", label: { en: "Second" } },
|
||||
{ id: "choice-3", label: { en: "Third" } },
|
||||
];
|
||||
|
||||
test("matches multiple values by ID", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "choice-3"], "en");
|
||||
expect(result).toEqual([choices[0], choices[2]]);
|
||||
});
|
||||
|
||||
test("matches multiple values by label", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "Third"], "en");
|
||||
expect(result).toEqual([choices[0], choices[2]]);
|
||||
});
|
||||
|
||||
test("matches mixed IDs and labels", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "Second", "choice-3"], "en");
|
||||
expect(result).toEqual([choices[0], choices[1], choices[2]]);
|
||||
});
|
||||
|
||||
test("preserves order of values", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["Third", "First", "Second"], "en");
|
||||
expect(result).toEqual([choices[2], choices[0], choices[1]]);
|
||||
});
|
||||
|
||||
test("skips non-matching values", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "NonExistent", "Third"], "en");
|
||||
expect(result).toEqual([choices[0], choices[2]]);
|
||||
});
|
||||
|
||||
test("returns empty array for all non-matching values", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["NonExistent1", "NonExistent2"], "en");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles empty values array", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, [], "en");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
42
apps/web/modules/survey/link/lib/prefill/matchers.ts
Normal file
42
apps/web/modules/survey/link/lib/prefill/matchers.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { TSurveyElementChoice } from "@formbricks/types/surveys/elements";
|
||||
|
||||
/**
|
||||
* Match a value against element choices by ID first, then by label
|
||||
* This enables both option ID-based and label-based prefilling
|
||||
*
|
||||
* @param choices - Array of choice objects with id and label
|
||||
* @param value - Value from URL parameter (either choice ID or label text)
|
||||
* @param languageCode - Current language code for label matching
|
||||
* @returns Matched choice or null if no match found
|
||||
*/
|
||||
export const matchOptionByIdOrLabel = (
|
||||
choices: TSurveyElementChoice[],
|
||||
value: string,
|
||||
languageCode: string
|
||||
): TSurveyElementChoice | null => {
|
||||
const matchById = choices.find((choice) => choice.id === value);
|
||||
if (matchById) return matchById;
|
||||
|
||||
const matchByLabel = choices.find((choice) => choice.label[languageCode] === value);
|
||||
if (matchByLabel) return matchByLabel;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Match multiple values against choices
|
||||
* Used for multi-select and ranking elements
|
||||
*
|
||||
* @param choices - Array of choice objects
|
||||
* @param values - Array of values from URL parameter
|
||||
* @param languageCode - Current language code
|
||||
* @returns Array of matched choices (preserves order)
|
||||
*/
|
||||
export const matchMultipleOptionsByIdOrLabel = (
|
||||
choices: TSurveyElementChoice[],
|
||||
values: string[],
|
||||
languageCode: string
|
||||
): TSurveyElementChoice[] =>
|
||||
values
|
||||
.map((value) => matchOptionByIdOrLabel(choices, value, languageCode))
|
||||
.filter((match) => match !== null);
|
||||
64
apps/web/modules/survey/link/lib/prefill/parsers.test.ts
Normal file
64
apps/web/modules/survey/link/lib/prefill/parsers.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parseCommaSeparated, parseNumber } from "./parsers";
|
||||
|
||||
describe("parseCommaSeparated", () => {
|
||||
test("parses simple comma-separated values", () => {
|
||||
expect(parseCommaSeparated("a,b,c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("trims whitespace from values", () => {
|
||||
expect(parseCommaSeparated("a , b , c")).toEqual(["a", "b", "c"]);
|
||||
expect(parseCommaSeparated(" a, b, c ")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("filters out empty values", () => {
|
||||
expect(parseCommaSeparated("a,,b")).toEqual(["a", "b"]);
|
||||
expect(parseCommaSeparated("a,b,")).toEqual(["a", "b"]);
|
||||
expect(parseCommaSeparated(",a,b")).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(parseCommaSeparated("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles single value", () => {
|
||||
expect(parseCommaSeparated("single")).toEqual(["single"]);
|
||||
});
|
||||
|
||||
test("handles values with spaces", () => {
|
||||
expect(parseCommaSeparated("First Choice,Second Choice")).toEqual(["First Choice", "Second Choice"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNumber", () => {
|
||||
test("parses valid integers", () => {
|
||||
expect(parseNumber("5")).toBe(5);
|
||||
expect(parseNumber("0")).toBe(0);
|
||||
expect(parseNumber("10")).toBe(10);
|
||||
});
|
||||
|
||||
test("parses valid floats", () => {
|
||||
expect(parseNumber("5.5")).toBe(5.5);
|
||||
expect(parseNumber("0.1")).toBe(0.1);
|
||||
});
|
||||
|
||||
test("parses negative numbers", () => {
|
||||
expect(parseNumber("-5")).toBe(-5);
|
||||
expect(parseNumber("-5.5")).toBe(-5.5);
|
||||
});
|
||||
|
||||
test("handles ampersand replacement", () => {
|
||||
expect(parseNumber("5&5")).toBe(null); // Invalid after replacement
|
||||
});
|
||||
|
||||
test("returns null for invalid strings", () => {
|
||||
expect(parseNumber("abc")).toBeNull();
|
||||
expect(parseNumber("")).toBeNull();
|
||||
expect(parseNumber("5a")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for NaN result", () => {
|
||||
expect(parseNumber("NaN")).toBeNull();
|
||||
});
|
||||
});
|
||||
31
apps/web/modules/survey/link/lib/prefill/parsers.ts
Normal file
31
apps/web/modules/survey/link/lib/prefill/parsers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Simple parsing helpers for URL parameter values
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse comma-separated values from URL parameter
|
||||
* Used for multi-select and ranking elements
|
||||
* Handles whitespace trimming and empty values
|
||||
*/
|
||||
export const parseCommaSeparated = (value: string): string[] => {
|
||||
return value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse number from URL parameter
|
||||
* Used for NPS and Rating elements
|
||||
* Returns null if parsing fails
|
||||
*/
|
||||
export const parseNumber = (value: string): number | null => {
|
||||
try {
|
||||
// Handle `&` being used instead of `;` in some cases
|
||||
const cleanedValue = value.replaceAll("&", ";");
|
||||
const num = Number(JSON.parse(cleanedValue));
|
||||
return Number.isNaN(num) ? null : num;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
100
apps/web/modules/survey/link/lib/prefill/transformers.ts
Normal file
100
apps/web/modules/survey/link/lib/prefill/transformers.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { parseNumber } from "./parsers";
|
||||
import {
|
||||
TValidationResult,
|
||||
isMultiChoiceResult,
|
||||
isPictureSelectionResult,
|
||||
isSingleChoiceResult,
|
||||
} from "./types";
|
||||
|
||||
export const transformOpenText = (answer: string): string => {
|
||||
return answer;
|
||||
};
|
||||
|
||||
export const transformMultipleChoiceSingle = (
|
||||
validationResult: TValidationResult,
|
||||
answer: string,
|
||||
language: string
|
||||
): string => {
|
||||
if (!isSingleChoiceResult(validationResult)) return answer;
|
||||
|
||||
const { matchedChoice } = validationResult;
|
||||
|
||||
// If we have a matched choice, return its label
|
||||
if (matchedChoice) {
|
||||
return matchedChoice.label[language] || answer;
|
||||
}
|
||||
|
||||
// If no matched choice (null), it's an "other" value - return original
|
||||
return answer;
|
||||
};
|
||||
|
||||
export const transformMultipleChoiceMulti = (validationResult: TValidationResult): string[] => {
|
||||
if (!isMultiChoiceResult(validationResult)) return [];
|
||||
|
||||
const { matched, others } = validationResult;
|
||||
|
||||
// Return matched choices + joined "other" values as single string
|
||||
if (others.length > 0) {
|
||||
return [...matched, others.join(",")];
|
||||
}
|
||||
|
||||
return matched;
|
||||
};
|
||||
|
||||
export const transformNPS = (answer: string): number => {
|
||||
const num = parseNumber(answer);
|
||||
return num ?? 0;
|
||||
};
|
||||
|
||||
export const transformRating = (answer: string): number => {
|
||||
const num = parseNumber(answer);
|
||||
return num ?? 0;
|
||||
};
|
||||
|
||||
export const transformConsent = (answer: string): string => {
|
||||
if (answer === "dismissed") return "";
|
||||
return answer;
|
||||
};
|
||||
|
||||
export const transformPictureSelection = (validationResult: TValidationResult): string[] => {
|
||||
if (!isPictureSelectionResult(validationResult)) return [];
|
||||
|
||||
return validationResult.selectedIds;
|
||||
};
|
||||
|
||||
/**
|
||||
* Main transformation dispatcher
|
||||
* Routes to appropriate transformer based on element type
|
||||
* Uses pre-matched data from validation result to avoid duplicate matching
|
||||
*/
|
||||
export const transformElement = (
|
||||
validationResult: TValidationResult,
|
||||
answer: string,
|
||||
language: string
|
||||
): string | number | string[] => {
|
||||
if (!validationResult.isValid) return "";
|
||||
|
||||
try {
|
||||
switch (validationResult.type) {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
return transformOpenText(answer);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
return transformMultipleChoiceSingle(validationResult, answer, language);
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
return transformConsent(answer);
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
return transformRating(answer);
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
return transformNPS(answer);
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return transformPictureSelection(validationResult);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
return transformMultipleChoiceMulti(validationResult);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
62
apps/web/modules/survey/link/lib/prefill/types.ts
Normal file
62
apps/web/modules/survey/link/lib/prefill/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { TSurveyElementChoice, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
|
||||
type TInvalidResult = {
|
||||
isValid: false;
|
||||
};
|
||||
|
||||
// Base valid result for simple types (no match data needed)
|
||||
type TSimpleValidResult = {
|
||||
isValid: true;
|
||||
};
|
||||
|
||||
// Single choice match result (MultipleChoiceSingle)
|
||||
type TSingleChoiceValidResult = {
|
||||
isValid: true;
|
||||
matchedChoice: TSurveyElementChoice | null; // null means "other" value
|
||||
};
|
||||
|
||||
// Multi choice match result (MultipleChoiceMulti)
|
||||
type TMultiChoiceValidResult = {
|
||||
isValid: true;
|
||||
matched: string[]; // matched labels
|
||||
others: string[]; // other text values
|
||||
};
|
||||
|
||||
// Picture selection result (indices are already validated)
|
||||
type TPictureSelectionValidResult = {
|
||||
isValid: true;
|
||||
selectedIds: string[];
|
||||
};
|
||||
|
||||
// Discriminated union for all validation results
|
||||
export type TValidationResult =
|
||||
| (TInvalidResult & { type?: TSurveyElementTypeEnum })
|
||||
| (TSimpleValidResult & {
|
||||
type:
|
||||
| TSurveyElementTypeEnum.OpenText
|
||||
| TSurveyElementTypeEnum.NPS
|
||||
| TSurveyElementTypeEnum.Rating
|
||||
| TSurveyElementTypeEnum.Consent;
|
||||
})
|
||||
| (TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle })
|
||||
| (TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti })
|
||||
| (TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection });
|
||||
|
||||
// Type guards for narrowing validation results
|
||||
export const isValidResult = (result: TValidationResult): result is TValidationResult & { isValid: true } =>
|
||||
result.isValid;
|
||||
|
||||
export const isSingleChoiceResult = (
|
||||
result: TValidationResult
|
||||
): result is TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle } =>
|
||||
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceSingle;
|
||||
|
||||
export const isMultiChoiceResult = (
|
||||
result: TValidationResult
|
||||
): result is TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti } =>
|
||||
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceMulti;
|
||||
|
||||
export const isPictureSelectionResult = (
|
||||
result: TValidationResult
|
||||
): result is TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection } =>
|
||||
result.isValid && result.type === TSurveyElementTypeEnum.PictureSelection;
|
||||
228
apps/web/modules/survey/link/lib/prefill/validators.ts
Normal file
228
apps/web/modules/survey/link/lib/prefill/validators.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
TSurveyConsentElement,
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyPictureSelectionElement,
|
||||
TSurveyRatingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { matchOptionByIdOrLabel } from "./matchers";
|
||||
import { parseCommaSeparated, parseNumber } from "./parsers";
|
||||
import { TValidationResult } from "./types";
|
||||
|
||||
const invalid = (type?: TSurveyElementTypeEnum): TValidationResult => ({ isValid: false, type });
|
||||
|
||||
export const validateOpenText = (): TValidationResult => {
|
||||
return { isValid: true, type: TSurveyElementTypeEnum.OpenText };
|
||||
};
|
||||
|
||||
export const validateMultipleChoiceSingle = (
|
||||
element: TSurveyMultipleChoiceElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): TValidationResult => {
|
||||
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
|
||||
}
|
||||
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
|
||||
}
|
||||
|
||||
const hasOther = element.choices.at(-1)?.id === "other";
|
||||
|
||||
// Try matching by ID or label (new: supports both)
|
||||
const matchedChoice = matchOptionByIdOrLabel(element.choices, answer, language);
|
||||
if (matchedChoice) {
|
||||
return {
|
||||
isValid: true,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
matchedChoice,
|
||||
};
|
||||
}
|
||||
|
||||
// If no match and has "other" option, accept any non-empty text as "other" value
|
||||
if (hasOther) {
|
||||
const trimmedAnswer = answer.trim();
|
||||
if (trimmedAnswer !== "") {
|
||||
return {
|
||||
isValid: true,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
matchedChoice: null, // null indicates "other" value
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
|
||||
};
|
||||
|
||||
export const validateMultipleChoiceMulti = (
|
||||
element: TSurveyMultipleChoiceElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): TValidationResult => {
|
||||
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
|
||||
}
|
||||
|
||||
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
|
||||
}
|
||||
|
||||
const hasOther = element.choices.at(-1)?.id === "other";
|
||||
const lastChoiceLabel = hasOther ? element.choices.at(-1)?.label?.[language] : undefined;
|
||||
|
||||
const answerChoices = parseCommaSeparated(answer);
|
||||
|
||||
if (answerChoices.length === 0) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
|
||||
}
|
||||
|
||||
// Process all answers and collect results
|
||||
const matched: string[] = [];
|
||||
const others: string[] = [];
|
||||
let freeTextOtherCount = 0;
|
||||
|
||||
for (const ans of answerChoices) {
|
||||
const matchedChoice = matchOptionByIdOrLabel(element.choices, ans, language);
|
||||
|
||||
if (matchedChoice) {
|
||||
const label = matchedChoice.label[language];
|
||||
if (label) {
|
||||
matched.push(label);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's the "Other" label itself
|
||||
if (ans === lastChoiceLabel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// It's a free-text "other" value
|
||||
if (hasOther) {
|
||||
freeTextOtherCount++;
|
||||
if (freeTextOtherCount > 1) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti); // Only one free-text "other" value allowed
|
||||
}
|
||||
others.push(ans);
|
||||
} else {
|
||||
// No "other" option and doesn't match any choice
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
matched,
|
||||
others,
|
||||
};
|
||||
};
|
||||
|
||||
export const validateNPS = (answer: string): TValidationResult => {
|
||||
const answerNumber = parseNumber(answer);
|
||||
if (answerNumber === null || answerNumber < 0 || answerNumber > 10) {
|
||||
return invalid(TSurveyElementTypeEnum.NPS);
|
||||
}
|
||||
return { isValid: true, type: TSurveyElementTypeEnum.NPS };
|
||||
};
|
||||
|
||||
export const validateConsent = (element: TSurveyConsentElement, answer: string): TValidationResult => {
|
||||
if (element.type !== TSurveyElementTypeEnum.Consent) {
|
||||
return invalid(TSurveyElementTypeEnum.Consent);
|
||||
}
|
||||
if (element.required && answer === "dismissed") {
|
||||
return invalid(TSurveyElementTypeEnum.Consent);
|
||||
}
|
||||
if (answer !== "accepted" && answer !== "dismissed") {
|
||||
return invalid(TSurveyElementTypeEnum.Consent);
|
||||
}
|
||||
return { isValid: true, type: TSurveyElementTypeEnum.Consent };
|
||||
};
|
||||
|
||||
export const validateRating = (element: TSurveyRatingElement, answer: string): TValidationResult => {
|
||||
if (element.type !== TSurveyElementTypeEnum.Rating) {
|
||||
return invalid(TSurveyElementTypeEnum.Rating);
|
||||
}
|
||||
const answerNumber = parseNumber(answer);
|
||||
if (answerNumber === null || answerNumber < 1 || answerNumber > (element.range ?? 5)) {
|
||||
return invalid(TSurveyElementTypeEnum.Rating);
|
||||
}
|
||||
return { isValid: true, type: TSurveyElementTypeEnum.Rating };
|
||||
};
|
||||
|
||||
export const validatePictureSelection = (
|
||||
element: TSurveyPictureSelectionElement,
|
||||
answer: string
|
||||
): TValidationResult => {
|
||||
if (element.type !== TSurveyElementTypeEnum.PictureSelection) {
|
||||
return invalid(TSurveyElementTypeEnum.PictureSelection);
|
||||
}
|
||||
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
|
||||
return invalid(TSurveyElementTypeEnum.PictureSelection);
|
||||
}
|
||||
|
||||
const answerChoices = parseCommaSeparated(answer);
|
||||
const selectedIds: string[] = [];
|
||||
|
||||
// Validate all indices and collect selected IDs
|
||||
for (const ans of answerChoices) {
|
||||
const num = parseNumber(ans);
|
||||
if (num === null || num < 1 || num > element.choices.length) {
|
||||
return invalid(TSurveyElementTypeEnum.PictureSelection);
|
||||
}
|
||||
const index = num - 1;
|
||||
const choice = element.choices[index];
|
||||
if (choice?.id) {
|
||||
selectedIds.push(choice.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply allowMulti constraint
|
||||
const finalIds = element.allowMulti ? selectedIds : selectedIds.slice(0, 1);
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
selectedIds: finalIds,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Main validation dispatcher
|
||||
* Routes to appropriate validator based on element type
|
||||
* Returns validation result with match data for transformers
|
||||
*/
|
||||
export const validateElement = (
|
||||
element: TSurveyElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): TValidationResult => {
|
||||
// Empty required fields are invalid
|
||||
if (element.required && (!answer || answer === "")) {
|
||||
return invalid(element.type);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (element.type) {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
return validateOpenText();
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
return validateMultipleChoiceSingle(element, answer, language);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
return validateMultipleChoiceMulti(element, answer, language);
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
return validateNPS(answer);
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
return validateConsent(element, answer);
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
return validateRating(element, answer);
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return validatePictureSelection(element, answer);
|
||||
default:
|
||||
return invalid();
|
||||
}
|
||||
} catch {
|
||||
return invalid(element.type);
|
||||
}
|
||||
};
|
||||
@@ -1,230 +1,2 @@
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurveyCTAElement,
|
||||
TSurveyConsentElement,
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyRatingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
|
||||
export const getPrefillValue = (
|
||||
survey: TSurvey,
|
||||
searchParams: URLSearchParams,
|
||||
languageId: string
|
||||
): TResponseData | undefined => {
|
||||
const prefillAnswer: TResponseData = {};
|
||||
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const questionIdxMap = questions.reduce(
|
||||
(acc, question, idx) => {
|
||||
acc[question.id] = idx;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
searchParams.forEach((value, key) => {
|
||||
if (FORBIDDEN_IDS.includes(key)) return;
|
||||
const questionId = key;
|
||||
const questionIdx = questionIdxMap[questionId];
|
||||
const question = questions[questionIdx];
|
||||
const answer = value;
|
||||
if (question) {
|
||||
if (checkValidity(question, answer, languageId)) {
|
||||
prefillAnswer[questionId] = transformAnswer(question, answer, languageId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(prefillAnswer).length > 0 ? prefillAnswer : undefined;
|
||||
};
|
||||
|
||||
const validateOpenText = (): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateMultipleChoiceSingle = (
|
||||
question: TSurveyMultipleChoiceElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) return false;
|
||||
const choices = question.choices;
|
||||
const hasOther = choices[choices.length - 1].id === "other";
|
||||
|
||||
if (!hasOther) {
|
||||
return choices.some((choice) => choice.label[language] === answer);
|
||||
}
|
||||
|
||||
const matchesAnyChoice = choices.some((choice) => choice.label[language] === answer);
|
||||
|
||||
if (matchesAnyChoice) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const trimmedAnswer = answer.trim();
|
||||
return trimmedAnswer !== "";
|
||||
};
|
||||
|
||||
const validateMultipleChoiceMulti = (question: TSurveyElement, answer: string, language: string): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) return false;
|
||||
const choices = (
|
||||
question as TSurveyElement & { choices: Array<{ id: string; label: Record<string, string> }> }
|
||||
).choices;
|
||||
const hasOther = choices[choices.length - 1].id === "other";
|
||||
const lastChoiceLabel = hasOther ? choices[choices.length - 1].label[language] : undefined;
|
||||
|
||||
const answerChoices = answer
|
||||
.split(",")
|
||||
.map((ans) => ans.trim())
|
||||
.filter((ans) => ans !== "");
|
||||
|
||||
if (answerChoices.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasOther) {
|
||||
return answerChoices.every((ans: string) => choices.some((choice) => choice.label[language] === ans));
|
||||
}
|
||||
|
||||
let freeTextOtherCount = 0;
|
||||
for (const ans of answerChoices) {
|
||||
const matchesChoice = choices.some((choice) => choice.label[language] === ans);
|
||||
|
||||
if (matchesChoice) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ans === lastChoiceLabel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
freeTextOtherCount++;
|
||||
if (freeTextOtherCount > 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateNPS = (answer: string): boolean => {
|
||||
try {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
return !isNaN(answerNumber) && answerNumber >= 0 && answerNumber <= 10;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validateCTA = (question: TSurveyCTAElement, answer: string): boolean => {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
return answer === "clicked" || answer === "dismissed";
|
||||
};
|
||||
|
||||
const validateConsent = (question: TSurveyConsentElement, answer: string): boolean => {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
return answer === "accepted" || answer === "dismissed";
|
||||
};
|
||||
|
||||
const validateRating = (question: TSurveyRatingElement, answer: string): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.Rating) return false;
|
||||
const ratingQuestion = question;
|
||||
try {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
return answerNumber >= 1 && answerNumber <= (ratingQuestion.range ?? 5);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validatePictureSelection = (answer: string): boolean => {
|
||||
const answerChoices = answer.split(",");
|
||||
return answerChoices.every((ans: string) => !isNaN(Number(ans)));
|
||||
};
|
||||
|
||||
const checkValidity = (question: TSurveyElement, answer: string, language: string): boolean => {
|
||||
if (question.required && (!answer || answer === "")) return false;
|
||||
|
||||
const validators: Partial<
|
||||
Record<TSurveyElementTypeEnum, (q: TSurveyElement, a: string, l: string) => boolean>
|
||||
> = {
|
||||
[TSurveyElementTypeEnum.OpenText]: () => validateOpenText(),
|
||||
[TSurveyElementTypeEnum.MultipleChoiceSingle]: (q, a, l) =>
|
||||
validateMultipleChoiceSingle(q as TSurveyMultipleChoiceElement, a, l),
|
||||
[TSurveyElementTypeEnum.MultipleChoiceMulti]: (q, a, l) => validateMultipleChoiceMulti(q, a, l),
|
||||
[TSurveyElementTypeEnum.NPS]: (_, a) => validateNPS(a),
|
||||
[TSurveyElementTypeEnum.CTA]: (q, a) => validateCTA(q as TSurveyCTAElement, a),
|
||||
[TSurveyElementTypeEnum.Consent]: (q, a) => validateConsent(q as TSurveyConsentElement, a),
|
||||
[TSurveyElementTypeEnum.Rating]: (q, a) => validateRating(q as TSurveyRatingElement, a),
|
||||
[TSurveyElementTypeEnum.PictureSelection]: (_, a) => validatePictureSelection(a),
|
||||
};
|
||||
|
||||
const validator = validators[question.type];
|
||||
if (!validator) return false;
|
||||
|
||||
try {
|
||||
return validator(question, answer, language);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const transformAnswer = (
|
||||
question: TSurveyElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): string | number | string[] => {
|
||||
switch (question.type) {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle: {
|
||||
return answer;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
case TSurveyElementTypeEnum.CTA: {
|
||||
if (answer === "dismissed") return "";
|
||||
return answer;
|
||||
}
|
||||
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
case TSurveyElementTypeEnum.NPS: {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
return Number(JSON.parse(cleanedAnswer));
|
||||
}
|
||||
|
||||
case TSurveyElementTypeEnum.PictureSelection: {
|
||||
const answerChoicesIdx = answer.split(",");
|
||||
const answerArr: string[] = [];
|
||||
|
||||
answerChoicesIdx.forEach((ansIdx) => {
|
||||
const choice = question.choices[Number(ansIdx) - 1];
|
||||
if (choice) answerArr.push(choice.id);
|
||||
});
|
||||
|
||||
if (question.allowMulti) return answerArr;
|
||||
return answerArr.slice(0, 1);
|
||||
}
|
||||
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
||||
let ansArr = answer.split(",");
|
||||
const hasOthers = question.choices[question.choices.length - 1].id === "other";
|
||||
if (!hasOthers) return ansArr;
|
||||
|
||||
// answer can be "a,b,c,d" and options can be a,c,others so we are filtering out the options that are not in the options list and sending these non-existing values as a single string(representing others) like "a", "c", "b,d"
|
||||
const options = question.choices.map((o) => o.label[language]);
|
||||
const others = ansArr.filter((a: string) => !options.includes(a));
|
||||
if (others.length > 0) ansArr = ansArr.filter((a: string) => options.includes(a));
|
||||
if (others.length > 0) ansArr.push(others.join(","));
|
||||
return ansArr;
|
||||
}
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
// Prefilling logic has been moved to @/modules/survey/link/lib/prefill
|
||||
// This file is kept for any future utility functions
|
||||
|
||||
@@ -91,18 +91,6 @@ Adds 'I love Formbricks' as the answer to the open text question:
|
||||
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?openText_question_id=I%20love%20Formbricks
|
||||
```
|
||||
|
||||
### CTA Question
|
||||
|
||||
Accepts only 'dismissed' as answer option. Due to the risk of domain abuse, this value cannot be set to 'clicked' via prefilling:
|
||||
|
||||
```txt CTA Question
|
||||
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?cta_question_id=dismissed
|
||||
```
|
||||
|
||||
<Note>
|
||||
Due to the risk of domain abuse, this value cannot be set to 'clicked' via prefilling.
|
||||
</Note>
|
||||
|
||||
### Consent Question
|
||||
|
||||
Adds 'accepted' as the answer to the Consent question. Alternatively, you can set it to 'dismissed' to skip the question.
|
||||
@@ -115,11 +103,53 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?consent_question_id=accep
|
||||
|
||||
Adds index of the selected image(s) as the answer to the Picture Selection question. The index starts from 1
|
||||
|
||||
```txt Picture Selection Question.
|
||||
```txt Picture Selection Question
|
||||
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?pictureSelection_question_id=1%2C2%2C3
|
||||
```
|
||||
|
||||
<Note>All other question types, you currently cannot prefill via the URL.</Note>
|
||||
|
||||
## Using Option IDs for Choice-Based Questions
|
||||
|
||||
All choice-based question types (Single Select, Multi Select, Picture Selection) now support prefilling with **option IDs** in addition to option labels. This is the recommended approach as it's more reliable and doesn't break when you update option text.
|
||||
|
||||
### Benefits of Using Option IDs
|
||||
|
||||
- **Stable:** Option IDs don't change when you edit the option label text
|
||||
- **Language-independent:** Works across all survey languages without modification
|
||||
- **Reliable:** No issues with special characters or URL encoding of complex text
|
||||
|
||||
### How to Find Option IDs
|
||||
|
||||
1. Open your survey in the Survey Editor
|
||||
2. Click on the question you want to prefill
|
||||
3. Open the **Advanced Settings** at the bottom of the question card
|
||||
4. Each option shows its unique ID that you can copy
|
||||
|
||||
### Examples with Option IDs
|
||||
|
||||
**Single Select:**
|
||||
|
||||
```sh Single Select with Option ID
|
||||
https://app.formbricks.com/s/surveyId?questionId=option-abc123
|
||||
```
|
||||
|
||||
**Multi Select:**
|
||||
|
||||
```sh Multi Select with Option IDs
|
||||
https://app.formbricks.com/s/surveyId?questionId=option-1,option-2,option-3
|
||||
```
|
||||
|
||||
**Mixed IDs and Labels:**
|
||||
|
||||
You can even mix option IDs and labels in the same URL (though using IDs consistently is recommended):
|
||||
|
||||
```sh Mixed Approach
|
||||
https://app.formbricks.com/s/surveyId?questionId=option-abc,Some%20Label,option-xyz
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
All existing URLs using option labels continue to work. The system tries to match by option ID first, then falls back to matching by label text if no ID match is found.
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -127,13 +157,12 @@ Make sure that the answer in the URL matches the expected type for the questions
|
||||
|
||||
The URL validation works as follows:
|
||||
|
||||
- For Rating or NPS questions, the response is parsed as a number and verified if it's accepted by the schema.
|
||||
- For CTA type questions, the valid values are "clicked" (main CTA) and "dismissed" (skip CTA).
|
||||
- For Consent type questions, the valid values are "accepted" (consent given) and "dismissed" (consent not given).
|
||||
- For Picture Selection type questions, the response is parsed as an array of numbers and verified if it's accepted by the schema.
|
||||
- All other question types are strings.
|
||||
- **Rating or NPS questions:** The response is parsed as a number and verified to be within the valid range.
|
||||
- **Consent type questions:** Valid values are "accepted" (consent given) and "dismissed" (consent not given).
|
||||
- **Picture Selection questions:** The response is parsed as comma-separated numbers (1-based indices) and verified against available choices.
|
||||
- **Single/Multi Select questions:** Values can be either option IDs or exact label text. The system tries to match by option ID first, then falls back to label matching.
|
||||
- **Open Text questions:** Any string value is accepted.
|
||||
|
||||
<Note>
|
||||
If an answer is invalid, the prefilling will be ignored and the question is
|
||||
presented as if not prefilled.
|
||||
If an answer is invalid or doesn't match any option, the prefilling will be ignored and the question is presented as if not prefilled.
|
||||
</Note>
|
||||
|
||||
@@ -67,22 +67,11 @@ export function MultipleChoiceSingleElement({
|
||||
const noneOption = useMemo(() => element.choices.find((choice) => choice.id === "none"), [element.choices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
const prefillAnswer = new URLSearchParams(window.location.search).get(element.id);
|
||||
if (
|
||||
prefillAnswer &&
|
||||
otherOption &&
|
||||
prefillAnswer === getLocalizedValue(otherOption.label, languageCode)
|
||||
) {
|
||||
setOtherSelected(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if "other" option is selected based on the value
|
||||
const isOtherSelected =
|
||||
value !== undefined && !elementChoices.some((choice) => choice?.label[languageCode] === value);
|
||||
setOtherSelected(isOtherSelected);
|
||||
}, [languageCode, otherOption, element.id, elementChoices, value]);
|
||||
}, [languageCode, elementChoices, value]);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to the bottom of choices container and focus on 'otherSpecify' input when 'otherSelected' is true
|
||||
|
||||
@@ -73,7 +73,8 @@ export function BlockConditional({
|
||||
if (elementId !== currentElementId) {
|
||||
setCurrentElementId(elementId);
|
||||
}
|
||||
onChange(responseData);
|
||||
// Merge with existing block data to preserve other element values
|
||||
onChange({ ...value, ...responseData });
|
||||
};
|
||||
|
||||
// Handler to collect TTC values synchronously (called from element form submissions)
|
||||
@@ -81,32 +82,43 @@ export function BlockConditional({
|
||||
ttcCollectorRef.current[elementId] = elementTtc;
|
||||
};
|
||||
|
||||
// Handle skipPrefilled at block level
|
||||
// Handle prefilling at block level (both skipPrefilled and regular prefilling)
|
||||
useEffect(() => {
|
||||
if (skipPrefilled && prefilledResponseData) {
|
||||
// Check if ALL elements in this block have prefilled values
|
||||
const allElementsPrefilled = block.elements.every(
|
||||
(element) => prefilledResponseData[element.id] !== undefined
|
||||
);
|
||||
if (prefilledResponseData) {
|
||||
// Collect all prefilled values for elements in this block
|
||||
const prefilledData: TResponseData = {};
|
||||
let hasAnyPrefilled = false;
|
||||
|
||||
if (allElementsPrefilled) {
|
||||
// Auto-populate all prefilled values
|
||||
const prefilledData: TResponseData = {};
|
||||
const prefilledTtc: TResponseTtc = {};
|
||||
|
||||
block.elements.forEach((element) => {
|
||||
block.elements.forEach((element) => {
|
||||
if (prefilledResponseData[element.id] !== undefined) {
|
||||
prefilledData[element.id] = prefilledResponseData[element.id];
|
||||
prefilledTtc[element.id] = 0; // 0 TTC for prefilled/skipped questions
|
||||
});
|
||||
hasAnyPrefilled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Update state with prefilled data
|
||||
if (hasAnyPrefilled) {
|
||||
// Apply all prefilled values in one atomic operation
|
||||
onChange(prefilledData);
|
||||
setTtc({ ...ttc, ...prefilledTtc });
|
||||
|
||||
// Auto-submit the entire block (skip to next)
|
||||
setTimeout(() => {
|
||||
onSubmit(prefilledData, prefilledTtc);
|
||||
}, 0);
|
||||
// If skipPrefilled and ALL elements are prefilled, auto-submit
|
||||
if (skipPrefilled) {
|
||||
const allElementsPrefilled = block.elements.every(
|
||||
(element) => prefilledResponseData[element.id] !== undefined
|
||||
);
|
||||
|
||||
if (allElementsPrefilled) {
|
||||
const prefilledTtc: TResponseTtc = {};
|
||||
block.elements.forEach((element) => {
|
||||
prefilledTtc[element.id] = 0; // 0 TTC for prefilled/skipped questions
|
||||
});
|
||||
setTtc({ ...ttc, ...prefilledTtc });
|
||||
|
||||
// Auto-submit the entire block (skip to next)
|
||||
setTimeout(() => {
|
||||
onSubmit(prefilledData, prefilledTtc);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once when block mounts
|
||||
@@ -257,17 +269,13 @@ export function BlockConditional({
|
||||
element={element}
|
||||
value={value[element.id]}
|
||||
onChange={(responseData) => handleElementChange(element.id, responseData)}
|
||||
onBack={() => {}}
|
||||
onFileUpload={onFileUpload}
|
||||
languageCode={languageCode}
|
||||
prefilledElementValue={prefilledResponseData?.[element.id]}
|
||||
skipPrefilled={skipPrefilled}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
surveyId={surveyId}
|
||||
autoFocusEnabled={autoFocusEnabled && isFirstElement}
|
||||
currentElementId={currentElementId}
|
||||
isBackButtonHidden={true}
|
||||
onOpenExternalURL={onOpenExternalURL}
|
||||
dir={dir}
|
||||
formRef={(ref) => {
|
||||
|
||||
@@ -28,17 +28,13 @@ interface ElementConditionalProps {
|
||||
element: TSurveyElement;
|
||||
value: TResponseDataValue;
|
||||
onChange: (responseData: TResponseData) => void;
|
||||
onBack: () => void;
|
||||
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
|
||||
languageCode: string;
|
||||
prefilledElementValue?: TResponseDataValue;
|
||||
skipPrefilled?: boolean;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
surveyId: string;
|
||||
autoFocusEnabled: boolean;
|
||||
currentElementId: string;
|
||||
isBackButtonHidden: boolean;
|
||||
onOpenExternalURL?: (url: string) => void | Promise<void>;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
|
||||
@@ -50,8 +46,6 @@ export function ElementConditional({
|
||||
value,
|
||||
onChange,
|
||||
languageCode,
|
||||
prefilledElementValue,
|
||||
skipPrefilled,
|
||||
ttc,
|
||||
setTtc,
|
||||
surveyId,
|
||||
@@ -100,15 +94,6 @@ export function ElementConditional({
|
||||
.filter((id): id is TSurveyElementChoice["id"] => id !== undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (value === undefined && (prefilledElementValue || prefilledElementValue === "")) {
|
||||
if (!skipPrefilled) {
|
||||
onChange({ [element.id]: prefilledElementValue });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we want to run this only once when the element renders for the first time
|
||||
}, []);
|
||||
|
||||
const isRecognizedType = Object.values(TSurveyElementTypeEnum).includes(element.type);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
3127
pnpm-lock.yaml
generated
3127
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user