mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 11:59:54 -06:00
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:
@@ -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 question’s 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
153
packages/surveys/src/lib/prefill.test.ts
Normal file
153
packages/surveys/src/lib/prefill.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
127
packages/surveys/src/lib/prefill.ts
Normal file
127
packages/surveys/src/lib/prefill.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user