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
@@ -25,8 +25,16 @@ https://app.formbricks.com/s/clin3dxja02k8l80hpwmx4bjy?question_id_1=answer_1&qu
## How it works ## How it works
1. To prefill survey questions, add query parameters to the survey URL using the format `questionId=answer`. 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). 2. For choice-based questions, you can use either **option IDs** (recommended) or **option labels** (backward compatible).
3. The answer needs to be [URL encoded](https://www.urlencoder.org/) (if it contains spaces or special characters) 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 ### Skip prefilled questions
@@ -77,9 +85,15 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?single_select_question_id
### Multi Select Question (Checkbox) ### 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 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 ### 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. ```txt Picture Selection Question
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?pictureSelection_question_id=1%2C2%2C3 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> <Note>All other question types, you currently cannot prefill via the URL.</Note>
## Validation ## 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 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 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 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. - All other question types are strings.
<Note> <Note>
If an answer is invalid, the prefilling will be ignored and the question is If an answer is invalid, the prefilling will be ignored and the question is
presented as if not prefilled. presented as if not prefilled.
</Note> </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.
@@ -43,8 +43,6 @@ export function AddressElement({
return Array.isArray(value) ? value : ["", "", "", "", "", ""]; return Array.isArray(value) ? value : ["", "", "", "", "", ""];
}, [value]); }, [value]);
const isCurrent = element.id === currentElementId;
const fields = useMemo( const fields = useMemo(
() => [ () => [
{ {
@@ -166,7 +164,7 @@ export function AddressElement({
handleChange(field.id, e.currentTarget.value); handleChange(field.id, e.currentTarget.value);
}} }}
ref={index === 0 ? addressRef : null} ref={index === 0 ? addressRef : null}
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
aria-label={field.label} aria-label={field.label}
dir={!safeValue[index] ? dir : "auto"} dir={!safeValue[index] ? dir : "auto"}
/> />
@@ -32,7 +32,6 @@ export function ConsentElement({
}: Readonly<ConsentElementProps>) { }: Readonly<ConsentElementProps>) {
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl; const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId); useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
@@ -66,7 +65,7 @@ export function ConsentElement({
/> />
<label <label
ref={consentRef} ref={consentRef}
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
id={`${element.id}-label`} id={`${element.id}-label`}
onKeyDown={(e) => { onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input // Accessibility: if spacebar was pressed pass this down to the input
@@ -37,7 +37,6 @@ export function ContactInfoElement({
const isMediaAvailable = element.imageUrl || element.videoUrl; const isMediaAvailable = element.imageUrl || element.videoUrl;
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId); useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const safeValue = useMemo(() => { const safeValue = useMemo(() => {
return Array.isArray(value) ? value : ["", "", "", "", ""]; return Array.isArray(value) ? value : ["", "", "", "", ""];
}, [value]); }, [value]);
@@ -149,7 +148,7 @@ export function ContactInfoElement({
onChange={(e) => { onChange={(e) => {
handleChange(field.id, e.currentTarget.value); handleChange(field.id, e.currentTarget.value);
}} }}
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
aria-label={field.label} aria-label={field.label}
dir={!safeValue[index] ? dir : "auto"} dir={!safeValue[index] ? dir : "auto"}
/> />
@@ -67,7 +67,7 @@ export function CTAElement({
<button <button
dir="auto" dir="auto"
type="button" type="button"
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
onClick={handleExternalButtonClick} 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"> 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)} {getLocalizedValue(element.ctaButtonLabel, languageCode)}
@@ -86,7 +86,6 @@ export function DateElement({
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isMediaAvailable = element.imageUrl || element.videoUrl; const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId); useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const [datePickerOpen, setDatePickerOpen] = useState(false); const [datePickerOpen, setDatePickerOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value ? new Date(value) : undefined); const [selectedDate, setSelectedDate] = useState<Date | undefined>(value ? new Date(value) : undefined);
const [hideInvalid, setHideInvalid] = useState(!selectedDate); const [hideInvalid, setHideInvalid] = useState(!selectedDate);
@@ -161,7 +160,7 @@ export function DateElement({
onClick={() => { onClick={() => {
setDatePickerOpen(true); setDatePickerOpen(true);
}} }}
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
type="button" type="button"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === " ") setDatePickerOpen(true); if (e.key === " ") setDatePickerOpen(true);
@@ -31,7 +31,6 @@ export function MatrixElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl; const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId); useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const rowShuffleIdx = useMemo(() => { const rowShuffleIdx = useMemo(() => {
if (element.shuffleOption !== "none") { if (element.shuffleOption !== "none") {
return getShuffledRowIndices(element.rows.length, element.shuffleOption); return getShuffledRowIndices(element.rows.length, element.shuffleOption);
@@ -127,7 +126,7 @@ export function MatrixElement({
{element.columns.map((column, columnIndex) => ( {element.columns.map((column, columnIndex) => (
<td <td
key={`column-${columnIndex.toString()}`} 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" : ""}`} className={`fb-outline-brand fb-px-4 fb-py-2 fb-text-slate-800 ${columnIndex === element.columns.length - 1 ? "fb-rounded-r-custom" : ""}`}
onClick={() => { onClick={() => {
handleSelect( handleSelect(
@@ -57,7 +57,6 @@ export function MultipleChoiceMultiElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl; const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId); useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const shuffledChoicesIds = useMemo(() => { const shuffledChoicesIds = useMemo(() => {
if (element.shuffleOption) { if (element.shuffleOption) {
return getShuffledChoicesIds(element.choices, element.shuffleOption); return getShuffledChoicesIds(element.choices, element.shuffleOption);
@@ -212,7 +211,7 @@ export function MultipleChoiceMultiElement({
return ( return (
<label <label
key={choice.id} key={choice.id}
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
className={labelClassName} className={labelClassName}
onKeyDown={handleKeyDown(choice.id)} onKeyDown={handleKeyDown(choice.id)}
autoFocus={idx === 0 && autoFocusEnabled}> autoFocus={idx === 0 && autoFocusEnabled}>
@@ -260,15 +259,12 @@ export function MultipleChoiceMultiElement({
: "Please specify"; : "Please specify";
return ( return (
<label <label tabIndex={0} className={labelClassName} onKeyDown={handleKeyDown(otherOption.id)}>
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(otherOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm"> <span className="fb-flex fb-items-center fb-text-sm">
<input <input
type="checkbox" type="checkbox"
dir={dir} dir={dir}
tabIndex={isCurrent ? 0 : -1} tabIndex={-1}
id={otherOption.id} id={otherOption.id}
name={element.id} name={element.id}
value={otherLabel} value={otherLabel}
@@ -289,7 +285,7 @@ export function MultipleChoiceMultiElement({
id={`${otherOption.id}-specify`} id={`${otherOption.id}-specify`}
maxLength={250} maxLength={250}
name={element.id} name={element.id}
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
value={otherValue} value={otherValue}
pattern=".*\S+.*" pattern=".*\S+.*"
onChange={(e) => setOtherValue(e.currentTarget.value)} onChange={(e) => setOtherValue(e.currentTarget.value)}
@@ -313,10 +309,7 @@ export function MultipleChoiceMultiElement({
); );
return ( return (
<label <label tabIndex={0} className={labelClassName} onKeyDown={handleKeyDown(noneOption.id)}>
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm"> <span className="fb-flex fb-items-center fb-text-sm">
<input <input
type="checkbox" type="checkbox"
@@ -36,7 +36,6 @@ export function MultipleChoiceSingleElement({
const otherSpecify = useRef<HTMLInputElement | null>(null); const otherSpecify = useRef<HTMLInputElement | null>(null);
const choicesContainerRef = useRef<HTMLDivElement | null>(null); const choicesContainerRef = useRef<HTMLDivElement | null>(null);
const isMediaAvailable = element.imageUrl || element.videoUrl; const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
const shuffledChoicesIds = useMemo(() => { const shuffledChoicesIds = useMemo(() => {
if (element.shuffleOption) { if (element.shuffleOption) {
return getShuffledChoicesIds(element.choices, 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]); const noneOption = useMemo(() => element.choices.find((choice) => choice.id === "none"), [element.choices]);
useEffect(() => { 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 = const isOtherSelected =
value !== undefined && !elementChoices.some((choice) => choice?.label[languageCode] === value); value !== undefined && !elementChoices.some((choice) => choice?.label[languageCode] === value);
setOtherSelected(isOtherSelected); setOtherSelected(isOtherSelected);
}, [languageCode, otherOption, element.id, elementChoices, value]); }, [languageCode, elementChoices, value]);
useEffect(() => { useEffect(() => {
// Scroll to the bottom of choices container and focus on 'otherSpecify' input when 'otherSelected' is true // Scroll to the bottom of choices container and focus on 'otherSpecify' input when 'otherSelected' is true
@@ -158,7 +145,7 @@ export function MultipleChoiceSingleElement({
return ( return (
<label <label
key={choice.id} key={choice.id}
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
className={labelClassName} className={labelClassName}
onKeyDown={handleKeyDown(choice.id)} onKeyDown={handleKeyDown(choice.id)}
autoFocus={idx === 0 && autoFocusEnabled}> autoFocus={idx === 0 && autoFocusEnabled}>
@@ -197,7 +184,7 @@ export function MultipleChoiceSingleElement({
: "Please specify"; : "Please specify";
return ( 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"> <span className="fb-flex fb-items-center fb-text-sm">
<input <input
tabIndex={-1} tabIndex={-1}
@@ -245,10 +232,7 @@ export function MultipleChoiceSingleElement({
); );
return ( return (
<label <label tabIndex={0} className={labelClassName} onKeyDown={handleKeyDown(noneOption.id)}>
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm"> <span className="fb-flex fb-items-center fb-text-sm">
<input <input
tabIndex={-1} tabIndex={-1}
@@ -33,7 +33,6 @@ export function NPSElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const [hoveredNumber, setHoveredNumber] = useState(-1); const [hoveredNumber, setHoveredNumber] = useState(-1);
const isMediaAvailable = element.imageUrl || element.videoUrl; const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId); useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const handleClick = (number: number) => { const handleClick = (number: number) => {
@@ -74,7 +73,7 @@ export function NPSElement({
return ( return (
<label <label
key={number} key={number}
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
onMouseOver={() => { onMouseOver={() => {
setHoveredNumber(number); setHoveredNumber(number);
}} }}
@@ -169,7 +169,7 @@ export function OpenTextElement({
<input <input
ref={inputRef as RefObject<HTMLInputElement>} ref={inputRef as RefObject<HTMLInputElement>}
autoFocus={isCurrent ? autoFocusEnabled : undefined} autoFocus={isCurrent ? autoFocusEnabled : undefined}
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
name={element.id} name={element.id}
id={element.id} id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode)} placeholder={getLocalizedValue(element.placeholder, languageCode)}
@@ -195,7 +195,7 @@ export function OpenTextElement({
rows={3} rows={3}
autoFocus={isCurrent ? autoFocusEnabled : undefined} autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={element.id} name={element.id}
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
aria-label="textarea" aria-label="textarea"
id={element.id} id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode, true)} placeholder={getLocalizedValue(element.placeholder, languageCode, true)}
@@ -148,7 +148,7 @@ export function PictureSelectionElement({
<div className="fb-relative" key={choice.id}> <div className="fb-relative" key={choice.id}>
<button <button
type="button" type="button"
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onClick={() => handleChange(choice.id)} onClick={() => handleChange(choice.id)}
className={getButtonClassName(choice.id)}> className={getButtonClassName(choice.id)}>
@@ -159,7 +159,7 @@ export function RankingElement({
)}> )}>
<button <button
autoFocus={idx === 0 && autoFocusEnabled} autoFocus={idx === 0 && autoFocusEnabled}
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === " ") { if (e.key === " ") {
e.preventDefault(); e.preventDefault();
@@ -46,7 +46,6 @@ export function RatingElement({
const [hoveredNumber, setHoveredNumber] = useState(0); const [hoveredNumber, setHoveredNumber] = useState(0);
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl; const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId); useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const handleSelect = (number: number) => { const handleSelect = (number: number) => {
@@ -163,7 +162,7 @@ export function RatingElement({
const renderNumberScale = (number: number, totalLength: number) => { const renderNumberScale = (number: number, totalLength: number) => {
return ( return (
<label <label
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
onKeyDown={handleKeyDown(number)} onKeyDown={handleKeyDown(number)}
className={getNumberLabelClassName(number, totalLength)}> className={getNumberLabelClassName(number, totalLength)}>
{element.isColorCodingEnabled && ( {element.isColorCodingEnabled && (
@@ -180,7 +179,7 @@ export function RatingElement({
const renderStarScale = (number: number) => { const renderStarScale = (number: number) => {
return ( return (
<label <label
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
onKeyDown={handleKeyDown(number)} onKeyDown={handleKeyDown(number)}
className={getStarLabelClassName(number)} className={getStarLabelClassName(number)}
onFocus={handleFocus(number)} onFocus={handleFocus(number)}
@@ -201,7 +200,7 @@ export function RatingElement({
const renderSmileyScale = (number: number, idx: number) => { const renderSmileyScale = (number: number, idx: number) => {
return ( return (
<label <label
tabIndex={isCurrent ? 0 : -1} tabIndex={0}
className={getSmileyLabelClassName(number)} className={getSmileyLabelClassName(number)}
onKeyDown={handleKeyDown(number)} onKeyDown={handleKeyDown(number)}
onFocus={handleFocus(number)} onFocus={handleFocus(number)}
@@ -81,32 +81,32 @@ export function BlockConditional({
ttcCollectorRef.current[elementId] = elementTtc; ttcCollectorRef.current[elementId] = elementTtc;
}; };
// Handle skipPrefilled at block level // Handle prefilled data at block level
useEffect(() => { useEffect(() => {
if (skipPrefilled && prefilledResponseData) { if (prefilledResponseData) {
// Check if ALL elements in this block have prefilled values // Populate ALL available prefilled values for this block
const allElementsPrefilled = block.elements.every( const prefilledData: TResponseData = {};
(element) => prefilledResponseData[element.id] !== undefined const prefilledTtc: TResponseTtc = {};
);
if (allElementsPrefilled) { block.elements.forEach((element) => {
// Auto-populate all prefilled values if (prefilledResponseData[element.id] !== undefined) {
const prefilledData: TResponseData = {};
const prefilledTtc: TResponseTtc = {};
block.elements.forEach((element) => {
prefilledData[element.id] = prefilledResponseData[element.id]; 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); onChange(prefilledData);
setTtc({ ...ttc, ...prefilledTtc }); setTtc({ ...ttc, ...prefilledTtc });
// Auto-submit the entire block (skip to next) // Only auto-skip if skipPrefilled=true AND all elements are filled
setTimeout(() => { const allElementsPrefilled = Object.keys(prefilledData).length === block.elements.length;
onSubmit(prefilledData, prefilledTtc); if (skipPrefilled && allElementsPrefilled) {
}, 0); setTimeout(() => {
onSubmit(prefilledData, prefilledTtc);
}, 0);
}
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once when block mounts // 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 { StackedCardsContainer } from "@/components/wrappers/stacked-cards-container";
import { ApiClient } from "@/lib/api-client"; import { ApiClient } from "@/lib/api-client";
import { evaluateLogic, performActions } from "@/lib/logic"; import { evaluateLogic, performActions } from "@/lib/logic";
import { parsePrefillFromURL } from "@/lib/prefill";
import { parseRecallInformation } from "@/lib/recall"; import { parseRecallInformation } from "@/lib/recall";
import { ResponseQueue } from "@/lib/response-queue"; import { ResponseQueue } from "@/lib/response-queue";
import { SurveyState } from "@/lib/survey-state"; import { SurveyState } from "@/lib/survey-state";
@@ -55,7 +56,6 @@ export function Survey({
onResponseCreated, onResponseCreated,
onOpenExternalURL, onOpenExternalURL,
isRedirectDisabled = false, isRedirectDisabled = false,
prefillResponseData,
skipPrefilled, skipPrefilled,
languageCode, languageCode,
getSetIsError, getSetIsError,
@@ -203,6 +203,14 @@ export function Survey({
return styling.cardArrangement?.appSurveys ?? "straight"; return styling.cardArrangement?.appSurveys ?? "straight";
}, [localSurvey.type, styling.cardArrangement?.linkSurveys, styling.cardArrangement?.appSurveys]); }, [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) // Current block tracking (replaces currentQuestionIndex)
const currentBlockIndex = localSurvey.blocks.findIndex((b) => b.id === blockId); const currentBlockIndex = localSurvey.blocks.findIndex((b) => b.id === blockId);
const currentBlock = localSurvey.blocks[currentBlockIndex]; const currentBlock = localSurvey.blocks[currentBlockIndex];
@@ -811,7 +819,7 @@ export function Survey({
onFileUpload={onFileUpload} onFileUpload={onFileUpload}
isFirstBlock={block.id === localSurvey.blocks[0]?.id} isFirstBlock={block.id === localSurvey.blocks[0]?.id}
skipPrefilled={skipPrefilled} skipPrefilled={skipPrefilled}
prefilledResponseData={offset === 0 ? prefillResponseData : undefined} prefilledResponseData={offset === 0 ? effectivePrefillData : undefined}
isLastBlock={block.id === localSurvey.blocks[localSurvey.blocks.length - 1].id} isLastBlock={block.id === localSurvey.blocks[localSurvey.blocks.length - 1].id}
languageCode={selectedLanguage} languageCode={selectedLanguage}
autoFocusEnabled={autoFocusEnabled} autoFocusEnabled={autoFocusEnabled}
+153
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;
});
});
+127
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;
}