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
This commit is contained in:
Johannes
2025-12-01 17:37:32 +01:00
parent 5c4fd7cb0a
commit f291de1835
6 changed files with 349 additions and 42 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

@@ -67,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

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;
}