feat: survey variables (#3013)

Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2024-08-22 15:13:39 +05:30
committed by GitHub
parent afe042ecfc
commit f4a367d2de
23 changed files with 554 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,4 +37,5 @@ export const minimalSurvey: TSurvey = {
languages: [],
showLanguageSwitch: false,
isVerifyEmailEnabled: false,
variables: [],
};

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "variables" JSONB NOT NULL DEFAULT '[]';

View File

@@ -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?

View File

@@ -15,6 +15,7 @@ export {
ZSurveyWelcomeCard,
ZSurveyQuestions,
ZSurveyHiddenFields,
ZSurveyVariables,
ZSurveyClosedMessage,
ZSurveyProductOverwrites,
ZSurveyStyling,

View File

@@ -104,6 +104,7 @@ export const PREVIEW_SURVEY = {
enabled: true,
fieldIds: [],
},
variables: [],
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,

View File

@@ -66,6 +66,7 @@ export const selectSurvey = {
questions: true,
endings: true,
hiddenFields: true,
variables: true,
displayOption: true,
recontactDays: true,
displayLimit: true,

View File

@@ -277,6 +277,7 @@ export const updateSurveyInput: TSurvey = {
segment: null,
languages: [],
showLanguageSwitch: null,
variables: [],
...commonMockProperties,
...baseSurveyProperties,
};

View File

@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
},

View File

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

View File

@@ -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
)
)}