Compare commits

...

2 Commits

Author SHA1 Message Date
Johannes
f291de1835 fix: URL prefilling for multi-question blocks and option ID support (#1203)
- Add centralized URL parsing in prefill.ts module
- Support option ID prefilling (auto-detect with fallback to labels)
- Fix bug where only one value per block was prefilled
- Allow partial prefilling with skipPrefilled logic
- Support comma-separated values for multi-select and ranking questions
- Update documentation with examples for option IDs
- Add 11 unit tests with 100% code coverage
2025-12-01 17:37:32 +01:00
Johannes
5c4fd7cb0a fix tab issue 2025-12-01 16:59:10 +01:00
18 changed files with 371 additions and 83 deletions

View File

@@ -25,8 +25,16 @@ https://app.formbricks.com/s/clin3dxja02k8l80hpwmx4bjy?question_id_1=answer_1&qu
## How it works
1. To prefill survey questions, add query parameters to the survey URL using the format `questionId=answer`.
2. The answer must match the questions expected type to pass [validation](/xm-and-surveys/surveys/link-surveys/data-prefilling#validation).
3. The answer needs to be [URL encoded](https://www.urlencoder.org/) (if it contains spaces or special characters)
2. For choice-based questions, you can use either **option IDs** (recommended) or **option labels** (backward compatible).
3. The answer must match the question's expected type to pass [validation](/xm-and-surveys/surveys/link-surveys/data-prefilling#validation).
4. The answer needs to be [URL encoded](https://www.urlencoder.org/) (if it contains spaces or special characters)
### Option IDs vs Option Labels
- **Option IDs (Recommended):** Use the unique ID of each option. These don't change with translations and are more reliable. Example: `?country=choice-us`
- **Option Labels (Backward Compatible):** Use the exact text of the option. Example: `?country=United%20States`
If you provide a value that matches an option ID, it will be used. Otherwise, the system will try to match it as an option label.
### Skip prefilled questions
@@ -77,9 +85,15 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?single_select_question_id
### Multi Select Question (Checkbox)
Selects three options 'Sun, Palms and Beach' in the multi select question. The strings have to be identical to the options in your question:
Selects multiple options in the multi select question using comma-separated values. You can use either option IDs or labels:
```sh Multi-select Question
**Using Option IDs (Recommended):**
```sh Multi-select Question (by ID)
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?multi_select_question_id=sport%2Cmusic%2Ctravel
```
**Using Option Labels (Backward Compatible):**
```sh Multi-select Question (by Label)
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?multi_select_question_id=Sun%2CPalms%2CBeach
```
@@ -113,12 +127,22 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?consent_question_id=accep
### Picture Selection Question
Adds index of the selected image(s) as the answer to the Picture Selection question. The index starts from 1
Selects image options in the Picture Selection question using comma-separated option IDs:
```txt Picture Selection Question.
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?pictureSelection_question_id=1%2C2%2C3
```txt Picture Selection Question
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?pictureSelection_question_id=choice-1%2Cchoice-2%2Cchoice-3
```
### Ranking Question
Orders items in a Ranking question using comma-separated option IDs. The order matters and determines the rank:
```txt Ranking Question (order matters)
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?ranking_question_id=item-3%2Citem-1%2Citem-2
```
<Note>The order of comma-separated values determines the ranking order.</Note>
<Note>All other question types, you currently cannot prefill via the URL.</Note>
## Validation
@@ -130,10 +154,17 @@ 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.
- For Picture Selection, Multi-select, and Ranking questions, the response can be comma-separated option IDs or labels.
- For choice-based questions (Single-select, Multi-select, Ranking, Picture Selection), the system will first try to match as an option ID, then fallback to matching as an option label.
- All other question types are strings.
<Note>
If an answer is invalid, the prefilling will be ignored and the question is
presented as if not prefilled.
</Note>
## Finding Option IDs
Option IDs are unique identifiers for each choice option in your survey. You can find them in the **Advanced Settings** at the bottom of each question card in the Survey Editor. For choice-based questions (Single-select, Multi-select, Ranking, Picture Selection), the option ID is displayed next to each option.
You can update option IDs to any string you like **before you publish a survey.** After you publish a survey, you cannot change the IDs anymore.

View File

@@ -43,8 +43,6 @@ export function AddressElement({
return Array.isArray(value) ? value : ["", "", "", "", "", ""];
}, [value]);
const isCurrent = element.id === currentElementId;
const fields = useMemo(
() => [
{
@@ -166,7 +164,7 @@ export function AddressElement({
handleChange(field.id, e.currentTarget.value);
}}
ref={index === 0 ? addressRef : null}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
aria-label={field.label}
dir={!safeValue[index] ? dir : "auto"}
/>

View File

@@ -32,7 +32,6 @@ export function ConsentElement({
}: Readonly<ConsentElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
@@ -66,7 +65,7 @@ export function ConsentElement({
/>
<label
ref={consentRef}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
id={`${element.id}-label`}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input

View File

@@ -37,7 +37,6 @@ export function ContactInfoElement({
const isMediaAvailable = element.imageUrl || element.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const safeValue = useMemo(() => {
return Array.isArray(value) ? value : ["", "", "", "", ""];
}, [value]);
@@ -149,7 +148,7 @@ export function ContactInfoElement({
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
aria-label={field.label}
dir={!safeValue[index] ? dir : "auto"}
/>

View File

@@ -67,7 +67,7 @@ export function CTAElement({
<button
dir="auto"
type="button"
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
onClick={handleExternalButtonClick}
className="fb-text-heading focus:fb-ring-focus fb-flex fb-items-center fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
{getLocalizedValue(element.ctaButtonLabel, languageCode)}

View File

@@ -86,7 +86,6 @@ export function DateElement({
const [errorMessage, setErrorMessage] = useState("");
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value ? new Date(value) : undefined);
const [hideInvalid, setHideInvalid] = useState(!selectedDate);
@@ -161,7 +160,7 @@ export function DateElement({
onClick={() => {
setDatePickerOpen(true);
}}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
type="button"
onKeyDown={(e) => {
if (e.key === " ") setDatePickerOpen(true);

View File

@@ -31,7 +31,6 @@ export function MatrixElement({
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const rowShuffleIdx = useMemo(() => {
if (element.shuffleOption !== "none") {
return getShuffledRowIndices(element.rows.length, element.shuffleOption);
@@ -127,7 +126,7 @@ export function MatrixElement({
{element.columns.map((column, columnIndex) => (
<td
key={`column-${columnIndex.toString()}`}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
className={`fb-outline-brand fb-px-4 fb-py-2 fb-text-slate-800 ${columnIndex === element.columns.length - 1 ? "fb-rounded-r-custom" : ""}`}
onClick={() => {
handleSelect(

View File

@@ -57,7 +57,6 @@ export function MultipleChoiceMultiElement({
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const shuffledChoicesIds = useMemo(() => {
if (element.shuffleOption) {
return getShuffledChoicesIds(element.choices, element.shuffleOption);
@@ -212,7 +211,7 @@ export function MultipleChoiceMultiElement({
return (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
className={labelClassName}
onKeyDown={handleKeyDown(choice.id)}
autoFocus={idx === 0 && autoFocusEnabled}>
@@ -260,15 +259,12 @@ export function MultipleChoiceMultiElement({
: "Please specify";
return (
<label
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(otherOption.id)}>
<label tabIndex={0} className={labelClassName} onKeyDown={handleKeyDown(otherOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
tabIndex={isCurrent ? 0 : -1}
tabIndex={-1}
id={otherOption.id}
name={element.id}
value={otherLabel}
@@ -289,7 +285,7 @@ export function MultipleChoiceMultiElement({
id={`${otherOption.id}-specify`}
maxLength={250}
name={element.id}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
value={otherValue}
pattern=".*\S+.*"
onChange={(e) => setOtherValue(e.currentTarget.value)}
@@ -313,10 +309,7 @@ export function MultipleChoiceMultiElement({
);
return (
<label
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)}>
<label tabIndex={0} className={labelClassName} onKeyDown={handleKeyDown(noneOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"

View File

@@ -36,7 +36,6 @@ export function MultipleChoiceSingleElement({
const otherSpecify = useRef<HTMLInputElement | null>(null);
const choicesContainerRef = useRef<HTMLDivElement | null>(null);
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
const shuffledChoicesIds = useMemo(() => {
if (element.shuffleOption) {
return getShuffledChoicesIds(element.choices, element.shuffleOption);
@@ -68,22 +67,10 @@ 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;
}
}
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
@@ -158,7 +145,7 @@ export function MultipleChoiceSingleElement({
return (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
className={labelClassName}
onKeyDown={handleKeyDown(choice.id)}
autoFocus={idx === 0 && autoFocusEnabled}>
@@ -197,7 +184,7 @@ export function MultipleChoiceSingleElement({
: "Please specify";
return (
<label tabIndex={isCurrent ? 0 : -1} className={labelClassName} onKeyDown={handleOtherKeyDown}>
<label tabIndex={0} className={labelClassName} onKeyDown={handleOtherKeyDown}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
@@ -245,10 +232,7 @@ export function MultipleChoiceSingleElement({
);
return (
<label
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)}>
<label tabIndex={0} className={labelClassName} onKeyDown={handleKeyDown(noneOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}

View File

@@ -33,7 +33,6 @@ export function NPSElement({
const [startTime, setStartTime] = useState(performance.now());
const [hoveredNumber, setHoveredNumber] = useState(-1);
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const handleClick = (number: number) => {
@@ -74,7 +73,7 @@ export function NPSElement({
return (
<label
key={number}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
onMouseOver={() => {
setHoveredNumber(number);
}}

View File

@@ -169,7 +169,7 @@ export function OpenTextElement({
<input
ref={inputRef as RefObject<HTMLInputElement>}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
name={element.id}
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode)}
@@ -195,7 +195,7 @@ export function OpenTextElement({
rows={3}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={element.id}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
aria-label="textarea"
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode, true)}

View File

@@ -148,7 +148,7 @@ export function PictureSelectionElement({
<div className="fb-relative" key={choice.id}>
<button
type="button"
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => handleChange(choice.id)}
className={getButtonClassName(choice.id)}>

View File

@@ -159,7 +159,7 @@ export function RankingElement({
)}>
<button
autoFocus={idx === 0 && autoFocusEnabled}
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();

View File

@@ -46,7 +46,6 @@ export function RatingElement({
const [hoveredNumber, setHoveredNumber] = useState(0);
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const handleSelect = (number: number) => {
@@ -163,7 +162,7 @@ export function RatingElement({
const renderNumberScale = (number: number, totalLength: number) => {
return (
<label
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
onKeyDown={handleKeyDown(number)}
className={getNumberLabelClassName(number, totalLength)}>
{element.isColorCodingEnabled && (
@@ -180,7 +179,7 @@ export function RatingElement({
const renderStarScale = (number: number) => {
return (
<label
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
onKeyDown={handleKeyDown(number)}
className={getStarLabelClassName(number)}
onFocus={handleFocus(number)}
@@ -201,7 +200,7 @@ export function RatingElement({
const renderSmileyScale = (number: number, idx: number) => {
return (
<label
tabIndex={isCurrent ? 0 : -1}
tabIndex={0}
className={getSmileyLabelClassName(number)}
onKeyDown={handleKeyDown(number)}
onFocus={handleFocus(number)}

View File

@@ -81,32 +81,32 @@ export function BlockConditional({
ttcCollectorRef.current[elementId] = elementTtc;
};
// Handle skipPrefilled at block level
// Handle prefilled data at block level
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) {
// Populate ALL available prefilled values for this block
const prefilledData: TResponseData = {};
const prefilledTtc: TResponseTtc = {};
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
});
prefilledTtc[element.id] = 0; // 0 TTC for prefilled questions
}
});
// Update state with prefilled data
// ALWAYS populate what we have
if (Object.keys(prefilledData).length > 0) {
onChange(prefilledData);
setTtc({ ...ttc, ...prefilledTtc });
// Auto-submit the entire block (skip to next)
setTimeout(() => {
onSubmit(prefilledData, prefilledTtc);
}, 0);
// Only auto-skip if skipPrefilled=true AND all elements are filled
const allElementsPrefilled = Object.keys(prefilledData).length === block.elements.length;
if (skipPrefilled && allElementsPrefilled) {
setTimeout(() => {
onSubmit(prefilledData, prefilledTtc);
}, 0);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once when block mounts

View File

@@ -25,6 +25,7 @@ import { AutoCloseWrapper } from "@/components/wrappers/auto-close-wrapper";
import { StackedCardsContainer } from "@/components/wrappers/stacked-cards-container";
import { ApiClient } from "@/lib/api-client";
import { evaluateLogic, performActions } from "@/lib/logic";
import { parsePrefillFromURL } from "@/lib/prefill";
import { parseRecallInformation } from "@/lib/recall";
import { ResponseQueue } from "@/lib/response-queue";
import { SurveyState } from "@/lib/survey-state";
@@ -55,7 +56,6 @@ export function Survey({
onResponseCreated,
onOpenExternalURL,
isRedirectDisabled = false,
prefillResponseData,
skipPrefilled,
languageCode,
getSetIsError,
@@ -203,6 +203,14 @@ export function Survey({
return styling.cardArrangement?.appSurveys ?? "straight";
}, [localSurvey.type, styling.cardArrangement?.linkSurveys, styling.cardArrangement?.appSurveys]);
// Parse prefill data from URL
const effectivePrefillData = useMemo(() => {
if (typeof window !== "undefined") {
return parsePrefillFromURL(localSurvey, selectedLanguage);
}
return undefined;
}, [localSurvey, selectedLanguage]);
// Current block tracking (replaces currentQuestionIndex)
const currentBlockIndex = localSurvey.blocks.findIndex((b) => b.id === blockId);
const currentBlock = localSurvey.blocks[currentBlockIndex];
@@ -811,7 +819,7 @@ export function Survey({
onFileUpload={onFileUpload}
isFirstBlock={block.id === localSurvey.blocks[0]?.id}
skipPrefilled={skipPrefilled}
prefilledResponseData={offset === 0 ? prefillResponseData : undefined}
prefilledResponseData={offset === 0 ? effectivePrefillData : undefined}
isLastBlock={block.id === localSurvey.blocks[localSurvey.blocks.length - 1].id}
languageCode={selectedLanguage}
autoFocusEnabled={autoFocusEnabled}

View File

@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, test } from "vitest";
import type { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { parsePrefillFromURL } from "./prefill";
describe("parsePrefillFromURL", () => {
let mockSurvey: TJsEnvironmentStateSurvey;
beforeEach(() => {
// Reset URL search params
delete (window as any).location;
(window as any).location = { search: "" };
mockSurvey = {
id: "survey-1",
name: "Test Survey",
type: "link",
welcomeCard: { enabled: false },
endings: [],
variables: [],
questions: [],
hiddenFields: [],
blocks: [
{
id: "block-1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "What is your name?" },
},
{
id: "q2",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Choose one" },
choices: [
{ id: "choice-us", label: { default: "United States" } },
{ id: "choice-uk", label: { default: "United Kingdom" } },
{ id: "choice-ca", label: { default: "Canada" } },
],
},
{
id: "q3",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Choose multiple" },
choices: [
{ id: "sport", label: { default: "Sports" } },
{ id: "music", label: { default: "Music" } },
{ id: "travel", label: { default: "Travel" } },
],
},
],
},
],
} as any;
});
test("should parse simple text parameter", () => {
(window as any).location.search = "?q1=John";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q1: "John" });
});
test("should handle URL encoded values", () => {
(window as any).location.search = "?q1=John%20Doe";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q1: "John Doe" });
});
test("should resolve single choice by ID", () => {
(window as any).location.search = "?q2=choice-us";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q2: "United States" });
});
test("should fallback to label matching for single choice", () => {
(window as any).location.search = "?q2=United%20States";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q2: "United States" });
});
test("should resolve multiple choices by ID", () => {
(window as any).location.search = "?q3=sport,music,travel";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q3: ["Sports", "Music", "Travel"] });
});
test("should handle multiple parameters", () => {
(window as any).location.search = "?q1=John&q2=choice-uk&q3=sport,music";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({
q1: "John",
q2: "United Kingdom",
q3: ["Sports", "Music"],
});
});
test("should return undefined when no matching parameters", () => {
(window as any).location.search = "?unrelated=value";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toBeUndefined();
});
test("should ignore invalid choice values", () => {
(window as any).location.search = "?q2=invalid-choice&q1=John";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q1: "John" });
});
test("should handle empty string parameters", () => {
(window as any).location.search = "?q1=&q2=choice-us";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q2: "United States" });
});
test("should handle mixed valid and invalid values in multi-choice", () => {
(window as any).location.search = "?q3=sport,invalid,music";
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toEqual({ q3: ["Sports", "Music"] });
});
test("should return undefined if window is not defined", () => {
const originalWindow = global.window;
// @ts-expect-error - testing undefined window
delete global.window;
const result = parsePrefillFromURL(mockSurvey, "default");
expect(result).toBeUndefined();
global.window = originalWindow;
});
});

View File

@@ -0,0 +1,127 @@
import type { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import type { TResponseData } from "@formbricks/types/responses";
import {
TSurveyElement,
TSurveyElementChoice,
TSurveyElementTypeEnum,
TSurveyPictureChoice,
} from "@formbricks/types/surveys/elements";
import { getLocalizedValue } from "@/lib/i18n";
/**
* Parse URL query parameters and return prefill data for the survey
* Supports option IDs and labels for choice-based questions
* Multi-value fields use comma-separated syntax
*/
export function parsePrefillFromURL(
survey: TJsEnvironmentStateSurvey,
languageCode: string
): TResponseData | undefined {
if (typeof window === "undefined") {
return undefined;
}
const searchParams = new URLSearchParams(window.location.search);
const prefillData: TResponseData = {};
// Get all elements from all blocks
const allElements: TSurveyElement[] = [];
survey.blocks.forEach((block) => {
allElements.push(...block.elements);
});
// For each element, check if URL has a matching parameter
allElements.forEach((element) => {
const urlValue = searchParams.get(element.id);
if (urlValue !== null && urlValue !== "") {
// Resolve the value based on element type
const resolvedValue = resolveChoiceValue(element, urlValue, languageCode);
if (resolvedValue !== undefined) {
prefillData[element.id] = resolvedValue;
}
}
});
// Return undefined if no prefill data found
return Object.keys(prefillData).length > 0 ? prefillData : undefined;
}
/**
* Resolve a URL parameter value to the correct format for an element
* For choice-based questions, tries to match option ID first, then label
* Handles comma-separated values for multi-value fields
*/
function resolveChoiceValue(
element: TSurveyElement,
value: string,
languageCode: string
): string | string[] | undefined {
// Handle choice-based questions
if (
element.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
element.type === TSurveyElementTypeEnum.Ranking ||
element.type === TSurveyElementTypeEnum.PictureSelection
) {
// Check if this is a multi-value field
const isMultiValue =
element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
element.type === TSurveyElementTypeEnum.Ranking;
if (isMultiValue) {
// Split by comma and resolve each value
const values = value.split(",").map((v) => v.trim());
const resolvedValues: string[] = [];
for (const v of values) {
const resolved = matchChoice(element.choices, v, languageCode);
if (resolved !== undefined) {
resolvedValues.push(resolved);
}
}
return resolvedValues.length > 0 ? resolvedValues : undefined;
} else {
// Single choice - return as string
return matchChoice(element.choices, value, languageCode);
}
}
// For non-choice elements, return value as-is
// (text, number, date, etc.)
return value || undefined;
}
/**
* Match a value against choices (either by ID or label)
* First tries exact ID match, then tries label match for backward compatibility
*/
function matchChoice(
choices: (TSurveyElementChoice | TSurveyPictureChoice)[],
value: string,
languageCode: string
): string | undefined {
// 1. Try exact ID match
const byId = choices.find((choice) => choice.id === value);
if (byId) {
// For regular choices, return the localized label
if ("label" in byId) {
return getLocalizedValue(byId.label, languageCode);
}
// For picture choices, return the ID (they don't have labels)
return byId.id;
}
// 2. Try label match (backward compatibility) - only for regular choices
const byLabel = choices.find(
(choice) => "label" in choice && getLocalizedValue(choice.label, languageCode) === value
);
if (byLabel) {
return value;
}
// No match found
return undefined;
}