mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
feat: survey variables (#3013)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyHiddenFields } from "@formbricks/types/surveys/types";
|
||||
import { validateId } from "@formbricks/types/surveys/validation";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
@@ -36,9 +37,26 @@ export const HiddenFieldsCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const updateSurvey = (data: TSurveyHiddenFields) => {
|
||||
const updateSurvey = (data: TSurveyHiddenFields, currentFieldId?: string) => {
|
||||
const questions = [...localSurvey.questions];
|
||||
|
||||
// Remove recall info from question headlines
|
||||
if (currentFieldId) {
|
||||
questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
if (headline.includes(`recall:${currentFieldId}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
question.headline[languageCode] = headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
questions,
|
||||
hiddenFields: {
|
||||
...localSurvey.hiddenFields,
|
||||
...data,
|
||||
@@ -93,10 +111,13 @@ export const HiddenFieldsCard = ({
|
||||
<Tag
|
||||
key={fieldId}
|
||||
onDelete={() => {
|
||||
updateSurvey({
|
||||
enabled: true,
|
||||
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
|
||||
});
|
||||
updateSurvey(
|
||||
{
|
||||
enabled: true,
|
||||
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
|
||||
},
|
||||
fieldId
|
||||
);
|
||||
}}
|
||||
tagId={fieldId}
|
||||
tagName={fieldId}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import React, { SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
@@ -209,15 +209,14 @@ export const QuestionsView = ({
|
||||
const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id;
|
||||
let updatedSurvey: TSurvey = { ...localSurvey };
|
||||
|
||||
// check if we are recalling from this question
|
||||
// check if we are recalling from this question for every language
|
||||
updatedSurvey.questions.forEach((question) => {
|
||||
if (question.headline[selectedLanguageCode].includes(`recall:${questionId}`)) {
|
||||
const recallInfo = extractRecallInfo(getLocalizedValue(question.headline, selectedLanguageCode));
|
||||
if (recallInfo) {
|
||||
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replace(
|
||||
recallInfo,
|
||||
""
|
||||
);
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
if (headline.includes(`recall:${questionId}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
question.headline[languageCode] = headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -434,6 +433,13 @@ export const QuestionsView = ({
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
|
||||
{/* <SurveyVariablesCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
/> */}
|
||||
|
||||
<MultiLanguageCard
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { SurveyVariablesCardItem } from "./SurveyVariablesCardItem";
|
||||
|
||||
interface SurveyVariablesCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const variablesCardId = `fb-variables-${Date.now()}`;
|
||||
|
||||
export const SurveyVariablesCard = ({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
activeQuestionId,
|
||||
setActiveQuestionId,
|
||||
}: SurveyVariablesCardProps) => {
|
||||
const open = activeQuestionId === variablesCardId;
|
||||
|
||||
const setOpenState = (state: boolean) => {
|
||||
if (state) {
|
||||
setActiveQuestionId(variablesCardId);
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<p>🪣</p>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={setOpenState}
|
||||
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="flex cursor-pointer justify-between p-4 hover:bg-slate-50">
|
||||
<div>
|
||||
<div className="inline-flex">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Variables</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
{localSurvey.variables.length > 0 ? (
|
||||
localSurvey.variables.map((variable) => (
|
||||
<SurveyVariablesCardItem
|
||||
key={variable.id}
|
||||
mode="edit"
|
||||
variable={variable}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="mt-2 text-sm italic text-slate-500">No variables yet. Add the first one below.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SurveyVariablesCardItem mode="create" localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormField, FormItem, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
|
||||
interface SurveyVariablesCardItemProps {
|
||||
variable?: TSurveyVariable;
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
mode: "create" | "edit";
|
||||
}
|
||||
|
||||
export const SurveyVariablesCardItem = ({
|
||||
variable,
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
mode,
|
||||
}: SurveyVariablesCardItemProps) => {
|
||||
const form = useForm<TSurveyVariable>({
|
||||
defaultValues: variable ?? {
|
||||
id: createId(),
|
||||
name: "",
|
||||
type: "number",
|
||||
value: 0,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const { errors } = form.formState;
|
||||
const isNameError = !!errors.name?.message;
|
||||
const variableType = form.watch("type");
|
||||
|
||||
const editSurveyVariable = useCallback(
|
||||
(data: TSurveyVariable) => {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const updatedVariables = prevSurvey.variables.map((v) => (v.id === data.id ? data : v));
|
||||
return { ...prevSurvey, variables: updatedVariables };
|
||||
});
|
||||
},
|
||||
[setLocalSurvey]
|
||||
);
|
||||
|
||||
const createSurveyVariable = (data: TSurveyVariable) => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
variables: [...localSurvey.variables, data],
|
||||
});
|
||||
|
||||
form.reset({
|
||||
id: createId(),
|
||||
name: "",
|
||||
type: "number",
|
||||
value: 0,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "create") {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = form.watch(() => form.handleSubmit(editSurveyVariable)());
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, mode, editSurveyVariable]);
|
||||
|
||||
const onVaribleDelete = (variable: TSurveyVariable) => {
|
||||
const questions = [...localSurvey.questions];
|
||||
|
||||
// find if this variable is used in any question's recall and remove it for every language
|
||||
|
||||
questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
if (headline.includes(`recall:${variable.id}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
question.headline[languageCode] = headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const updatedVariables = prevSurvey.variables.filter((v) => v.id !== variable.id);
|
||||
return { ...prevSurvey, variables: updatedVariables, questions };
|
||||
});
|
||||
};
|
||||
|
||||
if (mode === "edit" && !variable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
className="mt-5"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
if (mode === "create") {
|
||||
createSurveyVariable(data);
|
||||
} else {
|
||||
editSurveyVariable(data);
|
||||
}
|
||||
})}>
|
||||
{mode === "create" && <Label htmlFor="headline">Add variable</Label>}
|
||||
|
||||
<div className="mt-2 flex w-full items-center gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
rules={{
|
||||
pattern: {
|
||||
value: /^[a-z0-9_]+$/,
|
||||
message: "Only lower case letters, numbers, and underscores are allowed.",
|
||||
},
|
||||
validate: (value) => {
|
||||
// if the variable name is already taken
|
||||
if (
|
||||
mode === "create" &&
|
||||
localSurvey.variables.find((variable) => variable.name === value)
|
||||
) {
|
||||
return "Variable name is already taken, please choose another.";
|
||||
}
|
||||
|
||||
if (mode === "edit" && variable && variable.name !== value) {
|
||||
if (localSurvey.variables.find((variable) => variable.name === value)) {
|
||||
return "Variable name is already taken, please choose another.";
|
||||
}
|
||||
}
|
||||
|
||||
// if it does not start with a letter
|
||||
if (!/^[a-z]/.test(value)) {
|
||||
return "Variable name must start with a letter.";
|
||||
}
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
isInvalid={isNameError}
|
||||
type="text"
|
||||
placeholder="Field name e.g, score, price"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue("value", value === "number" ? 0 : "");
|
||||
field.onChange(value);
|
||||
}}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue placeholder="Select type" className="text-sm" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"number"}>Number</SelectItem>
|
||||
<SelectItem value={"text"}>Text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<p className="text-slate-600">=</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(variableType === "number" ? Number(e.target.value) : e.target.value);
|
||||
}}
|
||||
placeholder="Initial value"
|
||||
type={variableType === "number" ? "number" : "text"}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{mode === "create" && (
|
||||
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
|
||||
Add variable
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{mode === "edit" && variable && (
|
||||
<Button
|
||||
variant="minimal"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => onVaribleDelete(variable)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isNameError && <p className="mt-1 text-sm text-red-500">{errors.name?.message}</p>}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -37,4 +37,5 @@ export const minimalSurvey: TSurvey = {
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
variables: [],
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { convertResponseValue } from "@formbricks/lib/responses";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TWeeklyEmailResponseData,
|
||||
TWeeklySummaryEnvironmentData,
|
||||
TWeeklySummaryNotificationDataSurvey,
|
||||
TWeeklySummaryNotificationResponse,
|
||||
@@ -23,7 +25,11 @@ export const getNotificationResponse = (
|
||||
const surveys: TWeeklySummaryNotificationDataSurvey[] = [];
|
||||
// iterate through the surveys and calculate the overall insights
|
||||
for (const survey of environment.surveys) {
|
||||
const parsedSurvey = replaceHeadlineRecall(survey, "default", environment.attributeClasses);
|
||||
const parsedSurvey = replaceHeadlineRecall(
|
||||
survey as unknown as TSurvey,
|
||||
"default",
|
||||
environment.attributeClasses
|
||||
) as TSurvey & { responses: TWeeklyEmailResponseData[] };
|
||||
const surveyData: TWeeklySummaryNotificationDataSurvey = {
|
||||
id: parsedSurvey.id,
|
||||
name: parsedSurvey.name,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type TSurveyQuestions,
|
||||
type TSurveySingleUse,
|
||||
type TSurveyStyling,
|
||||
type TSurveyVariables,
|
||||
type TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TUserNotificationSettings } from "@formbricks/types/user";
|
||||
@@ -34,6 +35,7 @@ declare global {
|
||||
export type SurveyQuestions = TSurveyQuestions;
|
||||
export type SurveyEnding = TSurveyEnding;
|
||||
export type SurveyHiddenFields = TSurveyHiddenFields;
|
||||
export type SurveyVariables = TSurveyVariables;
|
||||
export type SurveyProductOverwrites = TSurveyProductOverwrites;
|
||||
export type SurveyStyling = TSurveyStyling;
|
||||
export type SurveyClosedMessage = TSurveyClosedMessage;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "variables" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -278,6 +278,9 @@ model Survey {
|
||||
/// @zod.custom(imports.ZSurveyHiddenFields)
|
||||
/// [SurveyHiddenFields]
|
||||
hiddenFields Json @default("{\"enabled\": false}")
|
||||
/// @zod.custom(imports.ZSurveyVariables)
|
||||
/// [SurveyVariables]
|
||||
variables Json @default("[]")
|
||||
responses Response[]
|
||||
displayOption displayOptions @default(displayOnce)
|
||||
recontactDays Int?
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
ZSurveyWelcomeCard,
|
||||
ZSurveyQuestions,
|
||||
ZSurveyHiddenFields,
|
||||
ZSurveyVariables,
|
||||
ZSurveyClosedMessage,
|
||||
ZSurveyProductOverwrites,
|
||||
ZSurveyStyling,
|
||||
|
||||
@@ -104,6 +104,7 @@ export const PREVIEW_SURVEY = {
|
||||
enabled: true,
|
||||
fieldIds: [],
|
||||
},
|
||||
variables: [],
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
|
||||
@@ -66,6 +66,7 @@ export const selectSurvey = {
|
||||
questions: true,
|
||||
endings: true,
|
||||
hiddenFields: true,
|
||||
variables: true,
|
||||
displayOption: true,
|
||||
recontactDays: true,
|
||||
displayLimit: true,
|
||||
|
||||
@@ -277,6 +277,7 @@ export const updateSurveyInput: TSurvey = {
|
||||
segment: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: null,
|
||||
variables: [],
|
||||
...commonMockProperties,
|
||||
...baseSurveyProperties,
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionsObject,
|
||||
TSurveyRecallItem,
|
||||
TSurveyVariables,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "../i18n/utils";
|
||||
import { structuredClone } from "../pollyfills/structuredClone";
|
||||
@@ -60,7 +60,7 @@ export const findRecallInfoById = (text: string, id: string): string | null => {
|
||||
return match ? match[0] : null;
|
||||
};
|
||||
|
||||
const getRecallItemLabel = <T extends TSurveyQuestionsObject>(
|
||||
const getRecallItemLabel = <T extends TSurvey>(
|
||||
recallItemId: string,
|
||||
survey: T,
|
||||
languageCode: string,
|
||||
@@ -75,11 +75,14 @@ const getRecallItemLabel = <T extends TSurveyQuestionsObject>(
|
||||
const attributeClass = attributeClasses.find(
|
||||
(attributeClass) => attributeClass.name.replaceAll(" ", "nbsp") === recallItemId
|
||||
);
|
||||
return attributeClass?.name;
|
||||
if (attributeClass) return attributeClass?.name;
|
||||
|
||||
const variable = survey.variables?.find((variable) => variable.id === recallItemId);
|
||||
if (variable) return variable.name;
|
||||
};
|
||||
|
||||
// Converts recall information in a headline to a corresponding recall question headline, with or without a slash.
|
||||
export const recallToHeadline = <T extends TSurveyQuestionsObject>(
|
||||
export const recallToHeadline = <T extends TSurvey>(
|
||||
headline: TI18nString,
|
||||
survey: T,
|
||||
withSlash: boolean,
|
||||
@@ -149,7 +152,7 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T
|
||||
};
|
||||
|
||||
// Processes each question in a survey to ensure headlines are formatted correctly for recall and return the modified survey.
|
||||
export const replaceHeadlineRecall = <T extends TSurveyQuestionsObject>(
|
||||
export const replaceHeadlineRecall = <T extends TSurvey>(
|
||||
survey: T,
|
||||
language: string,
|
||||
attributeClasses: TAttributeClass[]
|
||||
@@ -181,15 +184,24 @@ export const getRecallItems = (
|
||||
ids.forEach((recallItemId) => {
|
||||
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
|
||||
const isSurveyQuestion = survey.questions.find((question) => question.id === recallItemId);
|
||||
const isVariable = survey.variables.find((variable) => variable.id === recallItemId);
|
||||
|
||||
const recallItemLabel = getRecallItemLabel(recallItemId, survey, languageCode, attributeClasses);
|
||||
|
||||
const getRecallItemType = () => {
|
||||
if (isHiddenField) return "hiddenField";
|
||||
if (isSurveyQuestion) return "question";
|
||||
if (isVariable) return "variable";
|
||||
return "attributeClass";
|
||||
};
|
||||
|
||||
if (recallItemLabel) {
|
||||
let recallItemLabelTemp = recallItemLabel;
|
||||
recallItemLabelTemp = replaceRecallInfoWithUnderline(recallItemLabelTemp);
|
||||
recallItems.push({
|
||||
id: recallItemId,
|
||||
label: recallItemLabelTemp,
|
||||
type: isHiddenField ? "hiddenField" : isSurveyQuestion ? "question" : "attributeClass",
|
||||
type: getRecallItemType(),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -228,6 +240,7 @@ export const parseRecallInfo = (
|
||||
text: string,
|
||||
attributes?: TAttributes,
|
||||
responseData?: TResponseData,
|
||||
variables?: TSurveyVariables,
|
||||
withSlash: boolean = false
|
||||
) => {
|
||||
let modifiedText = text;
|
||||
@@ -253,6 +266,29 @@ export const parseRecallInfo = (
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (variables && variables.length > 0) {
|
||||
variables.forEach((variable) => {
|
||||
const recallPattern = `#recall:`;
|
||||
while (modifiedText.includes(recallPattern)) {
|
||||
const recallInfo = extractRecallInfo(modifiedText, variable.id);
|
||||
if (!recallInfo) break; // Exit the loop if no recall info is found
|
||||
|
||||
const recallItemId = extractId(recallInfo);
|
||||
if (!recallItemId) continue; // Skip to the next iteration if no ID could be extracted
|
||||
|
||||
const fallback = extractFallbackValue(recallInfo).replaceAll("nbsp", " ");
|
||||
|
||||
let value = variable.value?.toString() || fallback;
|
||||
if (withSlash) {
|
||||
modifiedText = modifiedText.replace(recallInfo, "#/" + value + "\\#");
|
||||
} else {
|
||||
modifiedText = modifiedText.replace(recallInfo, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (responseData && questionIds.length > 0) {
|
||||
while (modifiedText.includes("recall:")) {
|
||||
const recallInfo = extractRecallInfo(modifiedText);
|
||||
|
||||
@@ -96,7 +96,11 @@ export const EndingCard = ({
|
||||
alignTextCenter={true}
|
||||
headline={
|
||||
endingCard.type === "endScreen"
|
||||
? replaceRecallInfo(getLocalizedValue(endingCard.headline, languageCode), responseData)
|
||||
? replaceRecallInfo(
|
||||
getLocalizedValue(endingCard.headline, languageCode),
|
||||
responseData,
|
||||
survey.variables
|
||||
)
|
||||
: "Respondants will not see this card"
|
||||
}
|
||||
questionId="EndingCard"
|
||||
@@ -104,7 +108,11 @@ export const EndingCard = ({
|
||||
<Subheader
|
||||
subheader={
|
||||
endingCard.type === "endScreen"
|
||||
? replaceRecallInfo(getLocalizedValue(endingCard.subheader, languageCode), responseData)
|
||||
? replaceRecallInfo(
|
||||
getLocalizedValue(endingCard.subheader, languageCode),
|
||||
responseData,
|
||||
survey.variables
|
||||
)
|
||||
: "They will be forwarded immediately"
|
||||
}
|
||||
questionId="EndingCard"
|
||||
@@ -114,7 +122,8 @@ export const EndingCard = ({
|
||||
<SubmitButton
|
||||
buttonLabel={replaceRecallInfo(
|
||||
getLocalizedValue(endingCard.buttonLabel, languageCode),
|
||||
responseData
|
||||
responseData,
|
||||
survey.variables
|
||||
)}
|
||||
isLastQuestion={false}
|
||||
focus={autoFocusEnabled}
|
||||
|
||||
@@ -81,6 +81,7 @@ export const Survey = ({
|
||||
return survey.questions.find((q) => q.id === questionId);
|
||||
}
|
||||
}, [questionId, survey, history]);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const showProgressBar = !styling.hideProgressBar;
|
||||
const getShowSurveyCloseButton = (offset: number) => {
|
||||
@@ -297,7 +298,7 @@ export const Survey = ({
|
||||
<QuestionConditional
|
||||
key={question.id}
|
||||
surveyId={survey.id}
|
||||
question={parseRecallInformation(question, selectedLanguage, responseData)}
|
||||
question={parseRecallInformation(question, selectedLanguage, responseData, survey.variables)}
|
||||
value={responseData[question.id]}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { calculateElementIdx } from "@/lib/utils";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import { TI18nString, TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString, TSurvey, TSurveyVariables } from "@formbricks/types/surveys/types";
|
||||
import { Headline } from "./Headline";
|
||||
import { HtmlBody } from "./HtmlBody";
|
||||
|
||||
@@ -18,7 +18,7 @@ interface WelcomeCardProps {
|
||||
languageCode: string;
|
||||
responseCount?: number;
|
||||
autoFocusEnabled: boolean;
|
||||
replaceRecallInfo: (text: string, responseData: TResponseData) => string;
|
||||
replaceRecallInfo: (text: string, responseData: TResponseData, variables: TSurveyVariables) => string;
|
||||
isCurrent: boolean;
|
||||
responseData: TResponseData;
|
||||
}
|
||||
@@ -142,11 +142,19 @@ export const WelcomeCard = ({
|
||||
)}
|
||||
|
||||
<Headline
|
||||
headline={replaceRecallInfo(getLocalizedValue(headline, languageCode), responseData)}
|
||||
headline={replaceRecallInfo(
|
||||
getLocalizedValue(headline, languageCode),
|
||||
responseData,
|
||||
survey.variables
|
||||
)}
|
||||
questionId="welcomeCard"
|
||||
/>
|
||||
<HtmlBody
|
||||
htmlString={replaceRecallInfo(getLocalizedValue(html, languageCode), responseData)}
|
||||
htmlString={replaceRecallInfo(
|
||||
getLocalizedValue(html, languageCode),
|
||||
responseData,
|
||||
survey.variables
|
||||
)}
|
||||
questionId="welcomeCard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,13 @@ import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { formatDateWithOrdinal, isValidDateString } from "@formbricks/lib/utils/datetime";
|
||||
import { extractFallbackValue, extractId, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestion, TSurveyVariables } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const replaceRecallInfo = (text: string, responseData: TResponseData): string => {
|
||||
export const replaceRecallInfo = (
|
||||
text: string,
|
||||
responseData: TResponseData,
|
||||
variables: TSurveyVariables
|
||||
): string => {
|
||||
let modifiedText = text;
|
||||
|
||||
while (modifiedText.includes("recall:")) {
|
||||
@@ -16,7 +20,13 @@ export const replaceRecallInfo = (text: string, responseData: TResponseData): st
|
||||
if (!recallItemId) return modifiedText; // Return the text if no ID could be extracted
|
||||
|
||||
const fallback = extractFallbackValue(recallInfo).replaceAll("nbsp", " ");
|
||||
let value = null;
|
||||
let value: string | null = null;
|
||||
|
||||
// Fetching value from variables based on recallItemId
|
||||
if (variables.length) {
|
||||
const variable = variables.find((variable) => variable.id === recallItemId);
|
||||
value = variable?.value?.toString() ?? fallback;
|
||||
}
|
||||
|
||||
// Fetching value from responseData or attributes based on recallItemId
|
||||
if (responseData[recallItemId]) {
|
||||
@@ -42,13 +52,15 @@ export const replaceRecallInfo = (text: string, responseData: TResponseData): st
|
||||
export const parseRecallInformation = (
|
||||
question: TSurveyQuestion,
|
||||
languageCode: string,
|
||||
responseData: TResponseData
|
||||
responseData: TResponseData,
|
||||
variables: TSurveyVariables
|
||||
) => {
|
||||
const modifiedQuestion = structuredClone(question);
|
||||
if (question.headline && question.headline[languageCode]?.includes("recall:")) {
|
||||
modifiedQuestion.headline[languageCode] = replaceRecallInfo(
|
||||
getLocalizedValue(modifiedQuestion.headline, languageCode),
|
||||
responseData
|
||||
responseData,
|
||||
variables
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -58,7 +70,8 @@ export const parseRecallInformation = (
|
||||
) {
|
||||
modifiedQuestion.subheader[languageCode] = replaceRecallInfo(
|
||||
getLocalizedValue(modifiedQuestion.subheader, languageCode),
|
||||
responseData
|
||||
responseData,
|
||||
variables
|
||||
);
|
||||
}
|
||||
return modifiedQuestion;
|
||||
|
||||
@@ -142,6 +142,36 @@ export const ZSurveyHiddenFields = z.object({
|
||||
|
||||
export type TSurveyHiddenFields = z.infer<typeof ZSurveyHiddenFields>;
|
||||
|
||||
export const ZSurveyVariable = z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({
|
||||
id: z.string().cuid2(),
|
||||
name: z.string(),
|
||||
type: z.literal("number"),
|
||||
value: z.number().default(0),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string().cuid2(),
|
||||
name: z.string(),
|
||||
type: z.literal("text"),
|
||||
value: z.string().default(""),
|
||||
}),
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
// variable name can only contain lowercase letters, numbers, and underscores
|
||||
if (!/^[a-z0-9_]+$/.test(data.name)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Variable name can only contain lowercase letters, numbers, and underscores",
|
||||
path: ["variables"],
|
||||
});
|
||||
}
|
||||
});
|
||||
export const ZSurveyVariables = z.array(ZSurveyVariable);
|
||||
|
||||
export type TSurveyVariable = z.infer<typeof ZSurveyVariable>;
|
||||
export type TSurveyVariables = z.infer<typeof ZSurveyVariables>;
|
||||
|
||||
export const ZSurveyProductOverwrites = z.object({
|
||||
brandColor: ZColor.nullish(),
|
||||
highlightBorderColor: ZColor.nullish(),
|
||||
@@ -603,6 +633,29 @@ export const ZSurvey = z
|
||||
}
|
||||
}),
|
||||
hiddenFields: ZSurveyHiddenFields,
|
||||
variables: ZSurveyVariables.superRefine((variables, ctx) => {
|
||||
// variable ids must be unique
|
||||
const variableIds = variables.map((v) => v.id);
|
||||
const uniqueVariableIds = new Set(variableIds);
|
||||
if (uniqueVariableIds.size !== variableIds.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Variable IDs must be unique",
|
||||
path: ["variables"],
|
||||
});
|
||||
}
|
||||
|
||||
// variable names must be unique
|
||||
const variableNames = variables.map((v) => v.name);
|
||||
const uniqueVariableNames = new Set(variableNames);
|
||||
if (uniqueVariableNames.size !== variableNames.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Variable names must be unique",
|
||||
path: ["variables"],
|
||||
});
|
||||
}
|
||||
}),
|
||||
delay: z.number(),
|
||||
autoComplete: z.number().min(1, { message: "Response limit must be greater than 0" }).nullable(),
|
||||
runOnDate: z.date().nullable(),
|
||||
@@ -1389,7 +1442,7 @@ export type TSurveySummary = z.infer<typeof ZSurveySummary>;
|
||||
export const ZSurveyRecallItem = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
type: z.enum(["question", "hiddenField", "attributeClass"]),
|
||||
type: z.enum(["question", "hiddenField", "attributeClass", "variable"]),
|
||||
});
|
||||
|
||||
export type TSurveyRecallItem = z.infer<typeof ZSurveyRecallItem>;
|
||||
|
||||
@@ -16,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, isInv
|
||||
className={cn(
|
||||
"focus:border-brand-dark flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300",
|
||||
className,
|
||||
isInvalid && "border-error focus:border-error border"
|
||||
isInvalid && "border border-red-500 focus:border-red-500"
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
@@ -145,7 +145,13 @@ export const PreviewSurvey = ({
|
||||
|
||||
const updateQuestionId = useCallback(
|
||||
(newQuestionId: string) => {
|
||||
if (!newQuestionId || newQuestionId === "hidden" || newQuestionId === "multiLanguage") return;
|
||||
if (
|
||||
!newQuestionId ||
|
||||
newQuestionId === "hidden" ||
|
||||
newQuestionId === "multiLanguage" ||
|
||||
newQuestionId.includes("fb-variables-")
|
||||
)
|
||||
return;
|
||||
if (newQuestionId === "start" && !survey.welcomeCard.enabled) return;
|
||||
setQuestionId(newQuestionId);
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@ import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu";
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
EyeOffIcon,
|
||||
FileDigitIcon,
|
||||
FileTextIcon,
|
||||
HomeIcon,
|
||||
ListIcon,
|
||||
MessageSquareTextIcon,
|
||||
@@ -98,6 +100,22 @@ export const RecallItemSelect = ({
|
||||
});
|
||||
}, [attributeClasses]);
|
||||
|
||||
const variableRecallItems = useMemo(() => {
|
||||
if (localSurvey.variables.length) {
|
||||
return localSurvey.variables
|
||||
.filter((variable) => !recallItemIds.includes(variable.id))
|
||||
.map((variable) => {
|
||||
return {
|
||||
id: variable.id,
|
||||
label: variable.name,
|
||||
type: "variable" as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [localSurvey.variables, recallItemIds]);
|
||||
|
||||
const surveyQuestionRecallItems = useMemo(() => {
|
||||
const isEndingCard = !localSurvey.questions.map((question) => question.id).includes(questionId);
|
||||
const idx = isEndingCard
|
||||
@@ -118,22 +136,31 @@ export const RecallItemSelect = ({
|
||||
}, [localSurvey.questions, questionId, recallItemIds]);
|
||||
|
||||
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
|
||||
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...attributeClassRecallItems].filter(
|
||||
(recallItems) => {
|
||||
if (searchValue.trim() === "") return true;
|
||||
else {
|
||||
return recallItems.label.toLowerCase().startsWith(searchValue.toLowerCase());
|
||||
}
|
||||
return [
|
||||
...surveyQuestionRecallItems,
|
||||
...hiddenFieldRecallItems,
|
||||
...attributeClassRecallItems,
|
||||
...variableRecallItems,
|
||||
].filter((recallItems) => {
|
||||
if (searchValue.trim() === "") return true;
|
||||
else {
|
||||
return recallItems.label.toLowerCase().startsWith(searchValue.toLowerCase());
|
||||
}
|
||||
);
|
||||
}, [surveyQuestionRecallItems, hiddenFieldRecallItems, attributeClassRecallItems, searchValue]);
|
||||
});
|
||||
}, [
|
||||
surveyQuestionRecallItems,
|
||||
hiddenFieldRecallItems,
|
||||
attributeClassRecallItems,
|
||||
variableRecallItems,
|
||||
searchValue,
|
||||
]);
|
||||
|
||||
// function to modify headline (recallInfo to corresponding headline)
|
||||
const getRecallLabel = (label: string): string => {
|
||||
return replaceRecallInfoWithUnderline(label);
|
||||
};
|
||||
|
||||
const getQuestionIcon = (recallItem: TSurveyRecallItem) => {
|
||||
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
|
||||
switch (recallItem.type) {
|
||||
case "question":
|
||||
const question = localSurvey.questions.find((question) => question.id === recallItem.id);
|
||||
@@ -144,6 +171,9 @@ export const RecallItemSelect = ({
|
||||
return EyeOffIcon;
|
||||
case "attributeClass":
|
||||
return TagIcon;
|
||||
case "variable":
|
||||
const variable = localSurvey.variables.find((variable) => variable.id === recallItem.id);
|
||||
return variable?.type === "number" ? FileDigitIcon : FileTextIcon;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,7 +200,7 @@ export const RecallItemSelect = ({
|
||||
/>
|
||||
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
|
||||
{filteredRecallItems.map((recallItem, index) => {
|
||||
const IconComponent = getQuestionIcon(recallItem);
|
||||
const IconComponent = getRecallItemIcon(recallItem);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
id={"recallItem-" + index}
|
||||
|
||||
@@ -126,6 +126,7 @@ export const SingleResponseCardBody = ({
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{survey.welcomeCard.enabled && (
|
||||
@@ -161,6 +162,7 @@ export const SingleResponseCardBody = ({
|
||||
getLocalizedValue(question.headline, "default"),
|
||||
{},
|
||||
response.data,
|
||||
survey.variables,
|
||||
true
|
||||
)
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user