From f4a367d2de6290f632288c9f3586d4086ce274ab Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Thu, 22 Aug 2024 15:13:39 +0530 Subject: [PATCH] feat: survey variables (#3013) Co-authored-by: Matti Nannt --- .../edit/components/HiddenFieldsCard.tsx | 31 ++- .../edit/components/QuestionsView.tsx | 24 +- .../edit/components/SurveyVariablesCard.tsx | 79 ++++++ .../components/SurveyVariablesCardItem.tsx | 224 ++++++++++++++++++ .../surveys/lib/minimalSurvey.ts | 1 + .../lib/notificationResponse.ts | 8 +- packages/database/json-types.ts | 2 + .../migration.sql | 2 + packages/database/schema.prisma | 3 + packages/database/zod-utils.ts | 1 + packages/lib/styling/constants.ts | 1 + packages/lib/survey/service.ts | 1 + .../lib/survey/tests/__mock__/survey.mock.ts | 1 + packages/lib/utils/recall.ts | 48 +++- .../src/components/general/EndingCard.tsx | 15 +- .../surveys/src/components/general/Survey.tsx | 3 +- .../src/components/general/WelcomeCard.tsx | 16 +- packages/surveys/src/lib/recall.ts | 25 +- packages/types/surveys/types.ts | 55 ++++- packages/ui/Input/index.tsx | 2 +- packages/ui/PreviewSurvey/index.tsx | 8 +- .../components/RecallItemSelect.tsx | 50 +++- .../components/SingleResponseCardBody.tsx | 2 + 23 files changed, 554 insertions(+), 48 deletions(-) create mode 100644 apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard.tsx create mode 100644 apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCardItem.tsx create mode 100644 packages/database/migrations/20240813094711_added_variables_to_survey_model/migration.sql diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx index c4472acf0b..b21fafaba9 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx @@ -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 = ({ { - 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} diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx index 3212b17b72..446984c12d 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx @@ -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} /> + {/* */} + 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 ( +
+
+

🪣

+
+ + +
+
+
+

Variables

+
+
+
+
+ +
+ {localSurvey.variables.length > 0 ? ( + localSurvey.variables.map((variable) => ( + + )) + ) : ( +

No variables yet. Add the first one below.

+ )} +
+ + +
+
+
+ ); +}; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCardItem.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCardItem.tsx new file mode 100644 index 0000000000..63820f46fa --- /dev/null +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCardItem.tsx @@ -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>; + mode: "create" | "edit"; +} + +export const SurveyVariablesCardItem = ({ + variable, + localSurvey, + setLocalSurvey, + mode, +}: SurveyVariablesCardItemProps) => { + const form = useForm({ + 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 ( +
+ +
{ + if (mode === "create") { + createSurveyVariable(data); + } else { + editSurveyVariable(data); + } + })}> + {mode === "create" && } + +
+ { + // 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 }) => ( + + + + + + )} + /> + + ( + + )} + /> + +

=

+ + ( + + + { + field.onChange(variableType === "number" ? Number(e.target.value) : e.target.value); + }} + placeholder="Initial value" + type={variableType === "number" ? "number" : "text"} + /> + + + )} + /> + + {mode === "create" && ( + + )} + + {mode === "edit" && variable && ( + + )} +
+ + {isNameError &&

{errors.name?.message}

} +
+
+
+ ); +}; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/lib/minimalSurvey.ts b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/lib/minimalSurvey.ts index 428eab5de9..4e4920f56e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/lib/minimalSurvey.ts +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/lib/minimalSurvey.ts @@ -37,4 +37,5 @@ export const minimalSurvey: TSurvey = { languages: [], showLanguageSwitch: false, isVerifyEmailEnabled: false, + variables: [], }; diff --git a/apps/web/app/api/cron/weekly-summary/lib/notificationResponse.ts b/apps/web/app/api/cron/weekly-summary/lib/notificationResponse.ts index 80c6f67fd1..9841b81955 100644 --- a/apps/web/app/api/cron/weekly-summary/lib/notificationResponse.ts +++ b/apps/web/app/api/cron/weekly-summary/lib/notificationResponse.ts @@ -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, diff --git a/packages/database/json-types.ts b/packages/database/json-types.ts index 16e95507f2..cd5cf51b04 100644 --- a/packages/database/json-types.ts +++ b/packages/database/json-types.ts @@ -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; diff --git a/packages/database/migrations/20240813094711_added_variables_to_survey_model/migration.sql b/packages/database/migrations/20240813094711_added_variables_to_survey_model/migration.sql new file mode 100644 index 0000000000..3a0b4bd5e5 --- /dev/null +++ b/packages/database/migrations/20240813094711_added_variables_to_survey_model/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Survey" ADD COLUMN "variables" JSONB NOT NULL DEFAULT '[]'; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 32d544a3cd..2920c6a53c 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -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? diff --git a/packages/database/zod-utils.ts b/packages/database/zod-utils.ts index 21a869b445..376c8d3e74 100644 --- a/packages/database/zod-utils.ts +++ b/packages/database/zod-utils.ts @@ -15,6 +15,7 @@ export { ZSurveyWelcomeCard, ZSurveyQuestions, ZSurveyHiddenFields, + ZSurveyVariables, ZSurveyClosedMessage, ZSurveyProductOverwrites, ZSurveyStyling, diff --git a/packages/lib/styling/constants.ts b/packages/lib/styling/constants.ts index 74ead8b6d2..61c3767ff2 100644 --- a/packages/lib/styling/constants.ts +++ b/packages/lib/styling/constants.ts @@ -104,6 +104,7 @@ export const PREVIEW_SURVEY = { enabled: true, fieldIds: [], }, + variables: [], displayOption: "displayOnce", recontactDays: null, displayLimit: null, diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index 77cbc3dba6..8bd8a7185a 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -66,6 +66,7 @@ export const selectSurvey = { questions: true, endings: true, hiddenFields: true, + variables: true, displayOption: true, recontactDays: true, displayLimit: true, diff --git a/packages/lib/survey/tests/__mock__/survey.mock.ts b/packages/lib/survey/tests/__mock__/survey.mock.ts index f8852e9026..6e4ec19e17 100644 --- a/packages/lib/survey/tests/__mock__/survey.mock.ts +++ b/packages/lib/survey/tests/__mock__/survey.mock.ts @@ -277,6 +277,7 @@ export const updateSurveyInput: TSurvey = { segment: null, languages: [], showLanguageSwitch: null, + variables: [], ...commonMockProperties, ...baseSurveyProperties, }; diff --git a/packages/lib/utils/recall.ts b/packages/lib/utils/recall.ts index daecc7840f..65423e68d6 100644 --- a/packages/lib/utils/recall.ts +++ b/packages/lib/utils/recall.ts @@ -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 = ( +const getRecallItemLabel = ( recallItemId: string, survey: T, languageCode: string, @@ -75,11 +75,14 @@ const getRecallItemLabel = ( 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 = ( +export const recallToHeadline = ( 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 = ( +export const replaceHeadlineRecall = ( 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); diff --git a/packages/surveys/src/components/general/EndingCard.tsx b/packages/surveys/src/components/general/EndingCard.tsx index a52f775102..086a08594f 100644 --- a/packages/surveys/src/components/general/EndingCard.tsx +++ b/packages/surveys/src/components/general/EndingCard.tsx @@ -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 = ({ q.id === questionId); } }, [questionId, survey, history]); + const contentRef = useRef(null); const showProgressBar = !styling.hideProgressBar; const getShowSurveyCloseButton = (offset: number) => { @@ -297,7 +298,7 @@ export const Survey = ({ string; + replaceRecallInfo: (text: string, responseData: TResponseData, variables: TSurveyVariables) => string; isCurrent: boolean; responseData: TResponseData; } @@ -142,11 +142,19 @@ export const WelcomeCard = ({ )} diff --git a/packages/surveys/src/lib/recall.ts b/packages/surveys/src/lib/recall.ts index 5782b367d6..621be79f12 100644 --- a/packages/surveys/src/lib/recall.ts +++ b/packages/surveys/src/lib/recall.ts @@ -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; diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index 48671fea35..f2b7686c06 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -142,6 +142,36 @@ export const ZSurveyHiddenFields = z.object({ export type TSurveyHiddenFields = z.infer; +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; +export type TSurveyVariables = z.infer; + 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; 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; diff --git a/packages/ui/Input/index.tsx b/packages/ui/Input/index.tsx index 6c2abcb975..cf391afc5e 100644 --- a/packages/ui/Input/index.tsx +++ b/packages/ui/Input/index.tsx @@ -16,7 +16,7 @@ const Input = React.forwardRef(({ 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} diff --git a/packages/ui/PreviewSurvey/index.tsx b/packages/ui/PreviewSurvey/index.tsx index 8ef4872039..c563d7814d 100644 --- a/packages/ui/PreviewSurvey/index.tsx +++ b/packages/ui/PreviewSurvey/index.tsx @@ -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); }, diff --git a/packages/ui/QuestionFormInput/components/RecallItemSelect.tsx b/packages/ui/QuestionFormInput/components/RecallItemSelect.tsx index 1903d549e8..685ded56dd 100644 --- a/packages/ui/QuestionFormInput/components/RecallItemSelect.tsx +++ b/packages/ui/QuestionFormInput/components/RecallItemSelect.tsx @@ -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 = ({ />
{filteredRecallItems.map((recallItem, index) => { - const IconComponent = getQuestionIcon(recallItem); + const IconComponent = getRecallItemIcon(recallItem); return ( {survey.welcomeCard.enabled && ( @@ -161,6 +162,7 @@ export const SingleResponseCardBody = ({ getLocalizedValue(question.headline, "default"), {}, response.data, + survey.variables, true ) )}