mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 16:30:21 -06:00
Compare commits
2 Commits
feat/proje
...
fix/data-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f291de1835 | ||
|
|
5c4fd7cb0a |
@@ -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.
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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