Compare commits

...

8 Commits

Author SHA1 Message Date
Piyush Gupta
5267fff6b2 fix: flex alignment 2024-10-01 19:34:44 +05:30
Aashish Anand
a6d592307c Merge branch 'main' into fix-dark-mode-3209 2024-10-01 18:07:27 +05:30
Harsh Singh
354ec1b887 fix: remove scrollbar in rich text editor (#3236)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2024-10-01 12:25:16 +00:00
Aashish Anand
d5bc70846e Merge branch 'main' into fix-dark-mode-3209 2024-10-01 17:52:35 +05:30
Dhruwang Jariwala
87c584add8 fix: validations in integrations (#3238) 2024-10-01 11:56:36 +00:00
anandaashish74711
49e0b8fc0f Fix dark mode in demo apps #3209 2024-10-01 17:05:56 +05:30
Anshuman Pandey
5035e3db9d feat: adds contact info question type (#3176)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2024-10-01 11:11:45 +00:00
Matti Nannt
b7f4097508 chore: improve pipeline performance (#3184)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
2024-09-30 14:32:48 +00:00
37 changed files with 1306 additions and 410 deletions

View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -60,7 +60,7 @@ const AppPage = ({}) => {
}, []);
return (
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="min-h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex flex-col items-center gap-2 sm:flex-row">
<SurveySwitch value="app" formbricks={formbricks} />
@@ -117,7 +117,7 @@ const AppPage = ({}) => {
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app
</h3>

View File

@@ -56,8 +56,8 @@ const AppPage = ({}) => {
});
return (
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col items-center justify-between md:flex-row">
<div className="min-h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex flex-col items-center gap-2 sm:flex-row">
<SurveySwitch value="website" formbricks={formbricks} />
<div>
@@ -113,7 +113,7 @@ const AppPage = ({}) => {
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app
</h3>

View File

@@ -1,12 +1,13 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useEffect } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys/types";
import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle";
import { Button } from "@formbricks/ui/components/Button";
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
import { QuestionToggleTable } from "@formbricks/ui/components/QuestionToggleTable";
interface AddressQuestionFormProps {
localSurvey: TSurvey;
@@ -32,6 +33,64 @@ export const AddressQuestionForm = ({
}: AddressQuestionFormProps): JSX.Element => {
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
const fields = [
{
id: "addressLine1",
label: "Address Line 1",
...question.addressLine1,
},
{
id: "addressLine2",
label: "Address Line 2",
...question.addressLine2,
},
{
id: "city",
label: "City",
...question.city,
},
{
id: "state",
label: "State",
...question.state,
},
{
id: "zip",
label: "Zip",
...question.zip,
},
{
id: "country",
label: "Country",
...question.country,
},
];
useEffect(() => {
const allFieldsAreOptional = [
question.addressLine1,
question.addressLine2,
question.city,
question.state,
question.zip,
question.country,
]
.filter((field) => field.show)
.every((field) => !field.required);
if (allFieldsAreOptional) {
updateQuestion(questionIdx, { required: false });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
question.addressLine1,
question.addressLine2,
question.city,
question.state,
question.zip,
question.country,
]);
return (
<form>
<QuestionFormInput
@@ -81,73 +140,30 @@ export const AddressQuestionForm = ({
Add Description
</Button>
)}
<div className="mt-2 font-medium">Settings</div>
<AdvancedOptionToggle
isChecked={question.isAddressLine1Required}
onToggle={() =>
<QuestionToggleTable
type="address"
fields={fields}
onShowToggle={(field, show) => {
updateQuestion(questionIdx, {
isAddressLine1Required: !question.isAddressLine1Required,
required: true,
})
}
htmlId="isAddressRequired"
title="Required: Address Line 1"
description=""
childBorder
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={question.isAddressLine2Required}
onToggle={() =>
[field.id]: {
show,
required: field.required,
},
// when show changes, and the field is required, the question should be required
...(show && field.required && { required: true }),
});
}}
onRequiredToggle={(field, required) => {
updateQuestion(questionIdx, {
isAddressLine2Required: !question.isAddressLine2Required,
[field.id]: {
show: field.show,
required,
},
required: true,
})
}
htmlId="isAddressLine2Required"
title="Required: Address Line 2"
description=""
childBorder
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={question.isCityRequired}
onToggle={() =>
updateQuestion(questionIdx, { isCityRequired: !question.isCityRequired, required: true })
}
htmlId="isCityRequired"
title="Required: City / Town"
description=""
childBorder
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={question.isStateRequired}
onToggle={() =>
updateQuestion(questionIdx, { isStateRequired: !question.isStateRequired, required: true })
}
htmlId="isStateRequired"
title="Required: State / Region"
description=""
childBorder
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={question.isZipRequired}
onToggle={() =>
updateQuestion(questionIdx, { isZipRequired: !question.isZipRequired, required: true })
}
htmlId="isZipRequired"
title="Required: ZIP / Post Code"
description=""
childBorder
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={question.isCountryRequired}
onToggle={() =>
updateQuestion(questionIdx, { isCountryRequired: !question.isCountryRequired, required: true })
}
htmlId="iscountryRequired"
title="Required: Country"
description=""
childBorder
customContainerClass="p-0 mt-4"></AdvancedOptionToggle>
});
}}
/>
</div>
</form>
);

View File

@@ -0,0 +1,157 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useEffect } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/components/Button";
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
import { QuestionToggleTable } from "@formbricks/ui/components/QuestionToggleTable";
interface ContactInfoQuestionFormProps {
localSurvey: TSurvey;
question: TSurveyContactInfoQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyContactInfoQuestion>) => void;
lastQuestion: boolean;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
attributeClasses: TAttributeClass[];
}
export const ContactInfoQuestionForm = ({
question,
questionIdx,
updateQuestion,
isInvalid,
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
}: ContactInfoQuestionFormProps): JSX.Element => {
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
const fields = [
{
id: "firstName",
label: "First Name",
...question.firstName,
},
{
id: "lastName",
label: "Last Name",
...question.lastName,
},
{
id: "email",
label: "Email",
...question.email,
},
{
id: "phone",
label: "Phone",
...question.phone,
},
{
id: "company",
label: "Company",
...question.company,
},
];
useEffect(() => {
const allFieldsAreOptional = [
question.firstName,
question.lastName,
question.email,
question.phone,
question.company,
]
.filter((field) => field.show)
.every((field) => !field.required);
if (allFieldsAreOptional) {
updateQuestion(questionIdx, { required: false });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [question.firstName, question.lastName, question.email, question.phone, question.company]);
return (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
label={"Question*"}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
<div>
{question.subheader !== undefined && (
<div className="inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
label={"Description"}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
attributeClasses={attributeClasses}
/>
</div>
</div>
)}
{question.subheader === undefined && (
<Button
size="sm"
variant="minimal"
className="mt-4"
type="button"
onClick={() => {
updateQuestion(questionIdx, {
subheader: createI18nString("", surveyLanguageCodes),
});
}}>
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>
)}
<QuestionToggleTable
type="contact"
fields={fields}
onShowToggle={(field, show) => {
updateQuestion(questionIdx, {
[field.id]: {
show,
required: field.required,
},
// when show changes, and the field is required, the question should be required
...(show && field.required && { required: true }),
});
}}
onRequiredToggle={(field, required) => {
updateQuestion(questionIdx, {
[field.id]: {
show: field.show,
required,
},
required: true,
});
}}
/>
</div>
</form>
);
};

View File

@@ -1,5 +1,6 @@
"use client";
import { ContactInfoQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm";
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
import { useSortable } from "@dnd-kit/sortable";
@@ -94,16 +95,25 @@ export const QuestionCard = ({
};
const getIsRequiredToggleDisabled = (): boolean => {
if (question.type === "address") {
if (question.type === TSurveyQuestionTypeEnum.Address) {
return [
question.isAddressLine1Required,
question.isAddressLine2Required,
question.isCityRequired,
question.isCountryRequired,
question.isStateRequired,
question.isZipRequired,
].some((condition) => condition === true);
question.addressLine1,
question.addressLine2,
question.city,
question.state,
question.zip,
question.country,
]
.filter((field) => field.show)
.some((condition) => condition.required === true);
}
if (question.type === TSurveyQuestionTypeEnum.ContactInfo) {
return [question.firstName, question.lastName, question.email, question.phone, question.company]
.filter((field) => field.show)
.some((condition) => condition.required === true);
}
return false;
};
@@ -383,6 +393,18 @@ export const QuestionCard = ({
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? (
<ContactInfoQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
/>
) : null}
<div className="mt-4">
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">

View File

@@ -132,101 +132,97 @@ export const SurveyEditor = ({
}
return (
<>
<div className="flex h-full w-full flex-col">
<SurveyMenuBar
setLocalSurvey={setLocalSurvey}
localSurvey={localSurvey}
survey={survey}
environment={environment}
activeId={activeView}
setActiveId={setActiveView}
setInvalidQuestions={setInvalidQuestions}
product={localProduct}
responseCount={responseCount}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isCxMode={isCxMode}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main
className="relative z-0 w-1/2 flex-1 overflow-y-auto bg-slate-50 focus:outline-none"
ref={surveyEditorRef}>
<QuestionsAudienceTabs
activeId={activeView}
setActiveId={setActiveView}
isCxMode={isCxMode}
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
/>
<div className="flex h-full w-full flex-col">
<SurveyMenuBar
setLocalSurvey={setLocalSurvey}
localSurvey={localSurvey}
survey={survey}
environment={environment}
activeId={activeView}
setActiveId={setActiveView}
setInvalidQuestions={setInvalidQuestions}
product={localProduct}
responseCount={responseCount}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isCxMode={isCxMode}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main
className="relative z-0 w-1/2 flex-1 overflow-y-auto bg-slate-50 focus:outline-none"
ref={surveyEditorRef}>
<QuestionsAudienceTabs
activeId={activeView}
setActiveId={setActiveView}
isCxMode={isCxMode}
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
/>
{activeView === "questions" && (
<QuestionsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
product={localProduct}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
selectedLanguageCode={selectedLanguageCode ? selectedLanguageCode : "default"}
setSelectedLanguageCode={setSelectedLanguageCode}
isMultiLanguageAllowed={isMultiLanguageAllowed}
isFormbricksCloud={isFormbricksCloud}
attributeClasses={attributeClasses}
plan={plan}
isCxMode={isCxMode}
/>
)}
{activeView === "styling" && product.styling.allowStyleOverwrite && (
<StylingView
colors={colors}
environment={environment}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
product={localProduct}
styling={styling ?? null}
setStyling={setStyling}
localStylingChanges={localStylingChanges}
setLocalStylingChanges={setLocalStylingChanges}
isUnsplashConfigured={isUnsplashConfigured}
isCxMode={isCxMode}
/>
)}
{activeView === "settings" && (
<SettingsView
environment={environment}
organizationId={organizationId}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
responseCount={responseCount}
membershipRole={membershipRole}
isUserTargetingAllowed={isUserTargetingAllowed}
isFormbricksCloud={isFormbricksCloud}
product={localProduct}
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 shadow-inner md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
questionId={activeQuestionId}
{activeView === "questions" && (
<QuestionsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
product={localProduct}
environment={environment}
previewType={
localSurvey.type === "app" || localSurvey.type === "website" ? "modal" : "fullwidth"
}
languageCode={selectedLanguageCode}
onFileUpload={async (file) => file.name}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
selectedLanguageCode={selectedLanguageCode ? selectedLanguageCode : "default"}
setSelectedLanguageCode={setSelectedLanguageCode}
isMultiLanguageAllowed={isMultiLanguageAllowed}
isFormbricksCloud={isFormbricksCloud}
attributeClasses={attributeClasses}
plan={plan}
isCxMode={isCxMode}
/>
</aside>
</div>
)}
{activeView === "styling" && product.styling.allowStyleOverwrite && (
<StylingView
colors={colors}
environment={environment}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
product={localProduct}
styling={styling ?? null}
setStyling={setStyling}
localStylingChanges={localStylingChanges}
setLocalStylingChanges={setLocalStylingChanges}
isUnsplashConfigured={isUnsplashConfigured}
isCxMode={isCxMode}
/>
)}
{activeView === "settings" && (
<SettingsView
environment={environment}
organizationId={organizationId}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
segments={segments}
responseCount={responseCount}
membershipRole={membershipRole}
isUserTargetingAllowed={isUserTargetingAllowed}
isFormbricksCloud={isFormbricksCloud}
product={localProduct}
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 shadow-inner md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
questionId={activeQuestionId}
product={localProduct}
environment={environment}
previewType={localSurvey.type === "app" || localSurvey.type === "website" ? "modal" : "fullwidth"}
languageCode={selectedLanguageCode}
onFileUpload={async (file) => file.name}
/>
</aside>
</div>
</>
</div>
);
};

View File

@@ -232,7 +232,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map(
(question) => (

View File

@@ -30,6 +30,16 @@ const formatAddressData = (responseValue: TResponseDataValue): Record<string, st
: {};
};
const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
return Array.isArray(responseValue)
? responseValue.reduce((acc, curr, index) => {
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
return acc;
}, {})
: {};
};
const extractResponseData = (response: TResponse, survey: TSurvey): Record<string, any> => {
let responseData: Record<string, any> = {};
@@ -44,6 +54,9 @@ const extractResponseData = (response: TResponse, survey: TSurvey): Record<strin
case "address":
responseData = { ...responseData, ...formatAddressData(responseValue) };
break;
case "contactInfo":
responseData = { ...responseData, ...formatContactInfoData(responseValue) };
break;
default:
responseData[question.id] = responseValue;
}

View File

@@ -35,6 +35,23 @@ const getAddressFieldLabel = (field: string) => {
}
};
const getContactInfoFieldLabel = (field: string) => {
switch (field) {
case "firstName":
return "First Name";
case "lastName":
return "Last Name";
case "email":
return "Email";
case "phone":
return "Phone";
case "company":
return "Company";
default:
break;
}
};
const getQuestionColumnsData = (
question: TSurveyQuestion,
survey: TSurvey,
@@ -88,6 +105,30 @@ const getQuestionColumnsData = (
};
});
case "contactInfo":
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
return contactInfoFields.map((contactInfoField) => {
return {
accessorKey: contactInfoField,
header: () => {
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["contactInfo"]}</span>
<span className="truncate">{getContactInfoFieldLabel(contactInfoField)}</span>
</div>
</div>
);
},
cell: ({ row }) => {
const responseValue = row.original.responseData[contactInfoField];
if (typeof responseValue === "string") {
return <p className="text-slate-900">{responseValue}</p>;
}
},
};
});
default:
return [
{

View File

@@ -3,7 +3,7 @@ import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { timeSince } from "@formbricks/lib/time";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types";
import { AddressResponse } from "@formbricks/ui/components/AddressResponse";
import { ArrayResponse } from "@formbricks/ui/components/ArrayResponse";
import { PersonAvatar } from "@formbricks/ui/components/Avatars";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
@@ -27,7 +27,7 @@ export const AddressSummary = ({
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="">
<div>
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">User</div>
<div className="col-span-2 pl-4 md:pl-6">Response</div>
@@ -60,11 +60,9 @@ export const AddressSummary = ({
</div>
)}
</div>
{
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
<AddressResponse value={response.value} />
</div>
}
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
<ArrayResponse value={response.value} />
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString())}

View File

@@ -0,0 +1,77 @@
import Link from "next/link";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { timeSince } from "@formbricks/lib/time";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
import { ArrayResponse } from "@formbricks/ui/components/ArrayResponse";
import { PersonAvatar } from "@formbricks/ui/components/Avatars";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface ContactInfoSummaryProps {
questionSummary: TSurveyQuestionSummaryContactInfo;
environmentId: string;
survey: TSurvey;
attributeClasses: TAttributeClass[];
}
export const ContactInfoSummary = ({
questionSummary,
environmentId,
survey,
attributeClasses,
}: ContactInfoSummaryProps) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
/>
<div>
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">User</div>
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
<div className="max-h-[62vh] w-full overflow-y-auto">
{questionSummary.samples.map((response) => {
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person, response.personAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
<ArrayResponse value={response.value} />
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString())}
</div>
</div>
);
})}
</div>
</div>
</div>
);
};

View File

@@ -8,6 +8,7 @@ import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/survey
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
import { CalSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
import { ConsentSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
import { ContactInfoSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary";
import { DateQuestionSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary";
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary";
import { HiddenFieldsSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
@@ -278,6 +279,17 @@ export const SummaryList = ({
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.ContactInfo) {
return (
<ContactInfoSummary
key={questionSummary.question.id}
questionSummary={questionSummary}
environmentId={environment.id}
survey={survey}
attributeClasses={attributeClasses}
/>
);
}
return null;
})

View File

@@ -1,28 +1,30 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { Prisma } from "@prisma/client";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { sendResponseFinishedEmail } from "@formbricks/email";
import { cache } from "@formbricks/lib/cache";
import { CRON_SECRET } from "@formbricks/lib/constants";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { convertDatesInObject } from "@formbricks/lib/time";
import { ZPipelineInput } from "@formbricks/types/pipelines";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { webhookCache } from "@formbricks/lib/webhook/cache";
import { TPipelineTrigger, ZPipelineInput } from "@formbricks/types/pipelines";
import { TWebhook } from "@formbricks/types/webhooks";
import { handleIntegrations } from "./lib/handleIntegrations";
export const POST = async (request: Request) => {
// check authentication with x-api-key header and CRON_SECRET env variable
// Check authentication
if (headers().get("x-api-key") !== CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
const jsonInput = await request.json();
const convertedJsonInput = convertDatesInObject(jsonInput);
convertDatesInObject(jsonInput);
const inputValidation = ZPipelineInput.safeParse(jsonInput);
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
if (!inputValidation.success) {
console.error(inputValidation.error);
@@ -34,52 +36,69 @@ export const POST = async (request: Request) => {
}
const { environmentId, surveyId, event, response } = inputValidation.data;
const product = await getProductByEnvironmentId(environmentId);
if (!product) return;
// get all webhooks of this environment where event in triggers
const webhooks = await prisma.webhook.findMany({
where: {
environmentId,
triggers: {
has: event,
},
OR: [
{
surveyIds: {
has: surveyId,
},
// Fetch webhooks
const getWebhooksForPipeline = cache(
async (environmentId: string, event: TPipelineTrigger, surveyId: string) => {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId,
triggers: { has: event },
OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }],
},
{
surveyIds: {
isEmpty: true,
},
},
],
},
});
// send request to all webhooks
await Promise.all(
webhooks.map(async (webhook) => {
await fetch(webhook.url, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
webhookId: webhook.id,
event,
data: response,
}),
});
return webhooks;
},
[`getWebhooksForPipeline-${environmentId}-${event}-${surveyId}`],
{
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
}
);
const webhooks: TWebhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
// Prepare webhook and email promises
// Fetch with timeout of 5 seconds to prevent hanging
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
return Promise.race([
fetch(url, options),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
const webhookPromises = webhooks.map((webhook) =>
fetchWithTimeout(webhook.url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
webhookId: webhook.id,
event,
data: response,
}),
}).catch((error) => {
console.error(`Webhook call to ${webhook.url} failed:`, error);
})
);
if (event === "responseFinished") {
// check for email notifications
// get all users that have a membership of this environment's organization
const users = await prisma.user.findMany({
// Fetch integrations, survey, and responseCount in parallel
const [integrations, survey, responseCount] = await Promise.all([
getIntegrations(environmentId),
getSurvey(surveyId),
getResponseCountBySurveyId(surveyId),
]);
if (!survey) {
console.error(`Survey with id ${surveyId} not found`);
return new Response("Survey not found", { status: 404 });
}
if (integrations.length > 0) {
await handleIntegrations(integrations, inputValidation.data, survey);
}
// Fetch users with notifications in a single query
// TODO: add cache for this query. Not possible at the moment since we can't get the membership cache by environmentId
const usersWithNotifications = await prisma.user.findMany({
where: {
memberships: {
some: {
@@ -87,74 +106,48 @@ export const POST = async (request: Request) => {
products: {
some: {
environments: {
some: {
id: environmentId,
},
some: { id: environmentId },
},
},
},
},
},
},
notificationSettings: {
path: ["alert", surveyId],
not: Prisma.JsonNull,
},
},
select: { email: true },
});
const [integrations, surveyData] = await Promise.all([
getIntegrations(environmentId),
getSurvey(surveyId),
]);
const survey = surveyData ?? undefined;
const emailPromises = usersWithNotifications.map((user) =>
sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount).catch((error) => {
console.error(`Failed to send email to ${user.email}:`, error);
})
);
if (integrations.length > 0 && survey) {
await handleIntegrations(integrations, inputValidation.data, survey);
// Update survey status if necessary
if (survey.autoComplete && responseCount === survey.autoComplete) {
survey.status = "completed";
await updateSurvey(survey);
}
// filter all users that have email notifications enabled for this survey
const usersWithNotifications = users.filter((user) => {
const notificationSettings: TUserNotificationSettings | null = user.notificationSettings;
if (notificationSettings?.alert && notificationSettings.alert[surveyId]) {
return true;
// Await webhook and email promises with allSettled to prevent early rejection
const results = await Promise.allSettled([...webhookPromises, ...emailPromises]);
results.forEach((result) => {
if (result.status === "rejected") {
console.error("Promise rejected:", result.reason);
}
return false;
});
// Exclude current response
const responseCount = await getResponseCountBySurveyId(surveyId);
if (usersWithNotifications.length > 0) {
if (!survey) {
console.error(`Pipeline: Survey with id ${surveyId} not found`);
return new Response("Survey not found", {
status: 404,
});
} else {
// Await webhook promises if no emails are sent (with allSettled to prevent early rejection)
const results = await Promise.allSettled(webhookPromises);
results.forEach((result) => {
if (result.status === "rejected") {
console.error("Promise rejected:", result.reason);
}
// send email to all users
await Promise.all(
usersWithNotifications.map(async (user) => {
await sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount);
})
);
}
const updateSurveyStatus = async (surveyId: string) => {
// Get the survey instance by surveyId
const survey = await getSurvey(surveyId);
if (survey?.autoComplete) {
// Get the number of responses to a survey
const responseCount = await prisma.response.count({
where: {
surveyId: surveyId,
},
});
if (responseCount === survey.autoComplete) {
const updatedSurvey = { ...survey };
updatedSurvey.status = "completed";
await updateSurvey(updatedSurvey);
}
}
};
await updateSurveyStatus(surveyId);
});
}
return Response.json({ data: {} });

View File

@@ -32,6 +32,7 @@ const conditionOptions = {
consent: ["is"],
matrix: [""],
address: ["is"],
contactInfo: ["is"],
ranking: ["is"],
};
const filterOptions = {
@@ -42,6 +43,7 @@ const filterOptions = {
tags: ["Applied", "Not applied"],
consent: ["Accepted", "Dismissed"],
address: ["Filled out", "Skipped"],
contactInfo: ["Filled out", "Skipped"],
ranking: ["Filled out", "Skipped"],
};
@@ -273,10 +275,11 @@ export const getFormattedFilters = (
if (!filters.data) filters.data = {};
switch (questionType.questionType) {
case TSurveyQuestionTypeEnum.OpenText:
case TSurveyQuestionTypeEnum.Address: {
case TSurveyQuestionTypeEnum.Address:
case TSurveyQuestionTypeEnum.ContactInfo: {
if (filterType.filterComboBoxValue === "Filled out") {
filters.data[questionType.id ?? ""] = {
op: "submitted",
op: "filledOut",
};
} else if (filterType.filterComboBoxValue === "Skipped") {
filters.data[questionType.id ?? ""] = {

View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -188,6 +188,12 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await page.getByPlaceholder(surveys.createAndSubmit.address.placeholder).fill("This is my Address");
await page.locator("#questionCard-10").getByRole("button", { name: "Next" }).click();
// Contact Info Question
await expect(page.getByText(surveys.createAndSubmit.contactInfo.question)).toBeVisible();
await expect(page.getByPlaceholder(surveys.createAndSubmit.contactInfo.placeholder)).toBeVisible();
await page.getByPlaceholder(surveys.createAndSubmit.contactInfo.placeholder).fill("John Doe");
await page.locator("#questionCard-11").getByRole("button", { name: "Next" }).click();
// Ranking Question
await expect(page.getByText(surveys.createAndSubmit.ranking.question)).toBeVisible();
for (let i = 0; i < surveys.createAndSubmit.ranking.choices.length; i++) {
@@ -705,6 +711,10 @@ test.describe("Testing Survey with advanced logic", async () => {
await test.step("Verify Survey Response", async () => {
await page.goBack();
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
await page.waitForLoadState("networkidle");
await page.waitForTimeout(5000);
await page.getByRole("button", { name: "Close" }).click();
await page.getByRole("link").filter({ hasText: "Responses" }).click();
await expect(page.getByRole("table")).toBeVisible();

View File

@@ -286,6 +286,24 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.click();
await page.getByRole("button", { name: "Address" }).click();
await page.getByLabel("Question*").fill(params.address.question);
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "Zip" }).getByRole("cell").nth(2).click();
await page.getByRole("row", { name: "Country" }).getByRole("switch").nth(1).click();
// Fill Contact Info Question
await page
.locator("div")
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Contact Info" }).click();
await page.getByLabel("Question*").fill(params.contactInfo.question);
await page.getByRole("row", { name: "Last Name" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "Email" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "Phone" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "Company" }).getByRole("switch").nth(1).click();
// Fill Ranking question
await page
@@ -502,6 +520,11 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.click();
await page.getByRole("button", { name: "Address" }).click();
await page.getByLabel("Question*").fill(params.address.question);
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "Zip" }).getByRole("cell").nth(2).click();
await page.getByRole("row", { name: "Country" }).getByRole("switch").nth(1).click();
// Adding logic
// Open Text Question

View File

@@ -157,6 +157,10 @@ export const surveys = {
question: "Where do you live?",
placeholder: "Address Line 1",
},
contactInfo: {
question: "Contact Info",
placeholder: "First Name",
},
ranking: {
question: "What is most important for you in life?",
choices: ["Work", "Money", "Travel", "Family", "Friends"],

View File

@@ -0,0 +1,122 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
import {
type TSurveyAddressQuestion,
type TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
async function runMigration(): Promise<void> {
const startTime = Date.now();
console.log("Starting data migration...");
await prisma.$transaction(
async (transactionPrisma) => {
const surveysWithAddressQuestion = await transactionPrisma.survey.findMany({
where: {
questions: {
array_contains: [{ type: "address" }],
},
},
});
console.log(`Found ${surveysWithAddressQuestion.length.toString()} surveys with address questions`);
const updationPromises = [];
for (const survey of surveysWithAddressQuestion) {
const updatedQuestions = survey.questions.map((question: TSurveyQuestion) => {
if (question.type === TSurveyQuestionTypeEnum.Address) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- addressLine1 is not defined for unmigrated surveys
if (question.addressLine1 !== undefined) {
return null;
}
const {
isAddressLine1Required,
isAddressLine2Required,
isCityRequired,
isStateRequired,
isZipRequired,
isCountryRequired,
...rest
} = question as TSurveyAddressQuestion & {
isAddressLine1Required: boolean;
isAddressLine2Required: boolean;
isCityRequired: boolean;
isStateRequired: boolean;
isZipRequired: boolean;
isCountryRequired: boolean;
};
return {
...rest,
addressLine1: { show: true, required: isAddressLine1Required },
addressLine2: { show: true, required: isAddressLine2Required },
city: { show: true, required: isCityRequired },
state: { show: true, required: isStateRequired },
zip: { show: true, required: isZipRequired },
country: { show: true, required: isCountryRequired },
};
}
return question;
});
const isUpdationNotRequired = updatedQuestions.some(
(question: TSurveyQuestion | null) => question === null
);
if (!isUpdationNotRequired) {
updationPromises.push(
transactionPrisma.survey.update({
where: {
id: survey.id,
},
data: {
questions: updatedQuestions.filter((question: TSurveyQuestion | null) => question !== null),
},
})
);
}
}
if (updationPromises.length === 0) {
console.log("No surveys require migration... Exiting");
return;
}
await Promise.all(updationPromises);
console.log("Total surveys updated: ", updationPromises.length.toString());
},
{
timeout: TRANSACTION_TIMEOUT,
}
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();

View File

@@ -49,6 +49,7 @@
"data-migration:remove-dismissed-value-inconsistency": "ts-node ./data-migrations/20240807120500_cta_consent_dismissed_inconsistency/data-migration.ts",
"data-migration:v2.5": "pnpm data-migration:remove-dismissed-value-inconsistency",
"data-migration:add-display-id-to-response": "ts-node ./data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts",
"data-migration:address-question": "ts-node ./data-migrations/20240924123456_migrate_address_question/data-migration.ts",
"data-migration:advanced-logic": "ts-node ./data-migrations/20240828122408_advanced_logic_editor/data-migration.ts"
},
"dependencies": {

View File

@@ -436,6 +436,7 @@ export function PreviewEmailTemplate({
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Address:
case TSurveyQuestionTypeEnum.ContactInfo:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
@@ -444,14 +445,7 @@ export function PreviewEmailTemplate({
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
{[
"Address Line 1",
"Address Line 2",
"City / Town",
"State / Region",
"ZIP / Post Code",
"Country",
].map((label) => (
{["First Name", "Last Name", "Email", "Phone", "Company"].map((label) => (
<Section
className="border-input-border-color bg-input-color rounded-custom mt-4 block h-10 w-full border border-solid py-2 pl-2 text-slate-400"
key={label}>
@@ -461,6 +455,7 @@ export function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.FileUpload:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>

View File

@@ -7,7 +7,7 @@
e.parentNode.insertBefore(t, e),
setTimeout(function () {
formbricks.init({
environmentId: "cm14wcs5m0005b3aezc4a6ejf",
environmentId: "cm1qbbvo8000c5ij3dt7qmyn6",
userId: "RANDOM_USER_ID",
apiHost: "http://localhost:3000",
});

View File

@@ -11,6 +11,19 @@ import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
import { integrationCache } from "./cache";
const transformIntegration = (integration: TIntegration): TIntegration => {
return {
...integration,
config: {
...integration.config,
data: integration.config.data.map((data) => ({
...data,
createdAt: new Date(data.createdAt),
})),
},
};
};
export const createOrUpdateIntegration = async (
environmentId: string,
integrationData: TIntegrationInput
@@ -75,7 +88,9 @@ export const getIntegrations = reactCache(
{
tags: [integrationCache.tag.byEnvironmentId(environmentId)],
}
)()
)().then((cachedIntegration) => {
return cachedIntegration.map((integration) => transformIntegration(integration));
})
);
export const getIntegration = reactCache(
@@ -130,7 +145,11 @@ export const getIntegrationByType = reactCache(
{
tags: [integrationCache.tag.byEnvironmentIdAndType(environmentId, type)],
}
)()
)().then((cachedIntegration) => {
if (cachedIntegration) {
return transformIntegration(cachedIntegration);
} else return null;
})
);
export const deleteIntegration = async (integrationId: string): Promise<TIntegration> => {

View File

@@ -12,6 +12,7 @@ import {
} from "@formbricks/types/responses";
import {
TSurvey,
TSurveyContactInfoQuestion,
TSurveyLanguage,
TSurveyMultipleChoiceQuestion,
TSurveyQuestion,
@@ -212,6 +213,14 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
},
});
break;
case "filledOut":
data.push({
data: {
path: [key],
not: [],
},
});
break;
case "skipped":
data.push({
OR: [
@@ -385,7 +394,6 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
}
break;
case "uploaded":
data.push({
data: {
@@ -1271,7 +1279,8 @@ export const getQuestionWiseSummary = (
});
break;
}
case TSurveyQuestionTypeEnum.Address: {
case TSurveyQuestionTypeEnum.Address:
case TSurveyQuestionTypeEnum.ContactInfo: {
let values: TSurveyQuestionSummaryAddress["samples"] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
@@ -1287,8 +1296,8 @@ export const getQuestionWiseSummary = (
});
summary.push({
type: question.type,
question,
type: question.type as TSurveyQuestionTypeEnum.ContactInfo,
question: question as TSurveyContactInfoQuestion,
responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT),
});

View File

@@ -117,12 +117,27 @@ export const getTodaysDateTimeFormatted = (seperator: string) => {
return [formattedDate, formattedTime].join(seperator);
};
export const convertDatesInObject = (obj: any) => {
for (let key in obj) {
if ((key === "createdAt" || key === "updatedAt") && !isNaN(Date.parse(obj[key]))) {
obj[key] = new Date(obj[key]);
export const convertDatesInObject = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") {
return obj; // Return if obj is not an object
}
if (Array.isArray(obj)) {
// Handle arrays by mapping each element through the function
return obj.map((item) => convertDatesInObject(item)) as unknown as T;
}
const newObj: any = {};
for (const key in obj) {
if (
(key === "createdAt" || key === "updatedAt") &&
typeof obj[key] === "string" &&
!isNaN(Date.parse(obj[key] as unknown as string))
) {
newObj[key] = new Date(obj[key] as unknown as string);
} else if (typeof obj[key] === "object" && obj[key] !== null) {
convertDatesInObject(obj[key]);
newObj[key] = convertDatesInObject(obj[key]);
} else {
newObj[key] = obj[key];
}
}
return newObj;
};

View File

@@ -3,6 +3,7 @@ import {
ArrowUpFromLineIcon,
CalendarDaysIcon,
CheckIcon,
ContactIcon,
FileDigitIcon,
FileType2Icon,
Grid3X3Icon,
@@ -23,6 +24,7 @@ import {
TSurveyCTAQuestion,
TSurveyCalQuestion,
TSurveyConsentQuestion,
TSurveyContactInfoQuestion,
TSurveyDateQuestion,
TSurveyFileUploadQuestion,
TSurveyMatrixQuestion,
@@ -221,14 +223,28 @@ export const questionTypes: TQuestion[] = [
icon: HomeIcon,
preset: {
headline: { default: "Where do you live?" },
isAddressLine1Required: false,
isAddressLine2Required: false,
isCityRequired: false,
isStateRequired: false,
isZipRequired: false,
isCountryRequired: false,
addressLine1: { show: true, required: true },
addressLine2: { show: true, required: true },
city: { show: true, required: true },
state: { show: true, required: true },
zip: { show: true, required: true },
country: { show: true, required: true },
} as Partial<TSurveyAddressQuestion>,
},
{
id: QuestionId.ContactInfo,
label: "Contact Info",
description: "Allow respondents to provide their contact info",
icon: ContactIcon,
preset: {
headline: { default: "Contact Info" },
firstName: { show: true, required: true },
lastName: { show: true, required: true },
email: { show: true, required: true },
phone: { show: true, required: true },
company: { show: true, required: true },
} as Partial<TSurveyContactInfoQuestion>,
},
];
export const CXQuestionTypes = questionTypes.filter((questionType) => {

View File

@@ -0,0 +1,19 @@
import { cn } from "@/lib/utils";
import { HTMLAttributes } from "preact/compat";
export interface InputProps extends HTMLAttributes<HTMLInputElement> {
className?: string;
}
export const Input = ({ className, ...props }: InputProps) => {
return (
<input
{...props}
className={cn(
"focus:fb-border-brand fb-bg-input-bg fb-flex fb-w-full fb-border fb-border-border fb-rounded-custom fb-px-3 fb-py-2 fb-text-sm fb-text-subheading placeholder:fb-text-placeholder focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50 dark:fb-border-slate-500 dark:fb-text-slate-300",
className ?? ""
)}
dir="auto"
/>
);
};

View File

@@ -2,6 +2,7 @@ import { AddressQuestion } from "@/components/questions/AddressQuestion";
import { CTAQuestion } from "@/components/questions/CTAQuestion";
import { CalQuestion } from "@/components/questions/CalQuestion";
import { ConsentQuestion } from "@/components/questions/ConsentQuestion";
import { ContactInfoQuestion } from "@/components/questions/ContactInfoQuestion";
import { DateQuestion } from "@/components/questions/DateQuestion";
import { FileUploadQuestion } from "@/components/questions/FileUploadQuestion";
import { MatrixQuestion } from "@/components/questions/MatrixQuestion";
@@ -280,7 +281,6 @@ export const QuestionConditional = ({
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.Ranking ? (
@@ -298,5 +298,19 @@ export const QuestionConditional = ({
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
/>
) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? (
<ContactInfoQuestion
question={question}
value={Array.isArray(value) ? value : undefined}
onChange={onChange}
onSubmit={onSubmit}
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
currentQuestionId={currentQuestionId}
/>
) : null;
};

View File

@@ -1,11 +1,12 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { Input } from "@/components/general/Input";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useMemo, useRef, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyAddressQuestion } from "@formbricks/types/surveys/types";
@@ -18,11 +19,9 @@ interface AddressQuestionProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
autoFocus?: boolean;
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentQuestionId: string;
}
@@ -37,11 +36,9 @@ export const AddressQuestion = ({
languageCode,
ttc,
setTtc,
autoFocusEnabled,
currentQuestionId,
}: AddressQuestionProps) => {
const [startTime, setStartTime] = useState(performance.now());
const [hasFilled, setHasFilled] = useState(false);
const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
@@ -50,85 +47,62 @@ export const AddressQuestion = ({
return Array.isArray(value) ? value : ["", "", "", "", "", ""];
}, [value]);
const handleInputChange = (inputValue: string, index: number) => {
const updatedValue = [...safeValue];
updatedValue[index] = inputValue.trimStart();
onChange({ [question.id]: updatedValue });
const fields = [
{
id: "addressLine1",
placeholder: "Address Line 1",
...question.addressLine1,
},
{
id: "addressLine2",
placeholder: "Address Line 2",
...question.addressLine2,
},
{
id: "city",
placeholder: "City",
...question.city,
},
{
id: "state",
placeholder: "State",
...question.state,
},
{
id: "zip",
placeholder: "Zip",
...question.zip,
},
{
id: "country",
placeholder: "Country",
...question.country,
},
];
const handleChange = (fieldId: string, fieldValue: string) => {
const newValue = fields.map((field) => {
if (field.id === fieldId) {
return fieldValue;
}
const existingValue = safeValue?.[fields.findIndex((f) => f.id === field.id)] || "";
return field.show ? existingValue : "";
});
onChange({ [question.id]: newValue });
};
const handleSubmit = (e: Event) => {
e.preventDefault();
const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtc);
const containsAllEmptyStrings = value?.length === 6 && value.every((item) => item.trim() === "");
const containsAllEmptyStrings = safeValue?.length === 6 && safeValue.every((item) => item.trim() === "");
if (containsAllEmptyStrings) {
onSubmit({ [question.id]: [] }, updatedTtc);
} else {
onSubmit({ [question.id]: value ?? [] }, updatedTtc);
onSubmit({ [question.id]: safeValue ?? [] }, updatedTtc);
}
};
useEffect(() => {
const filled = safeValue.some((val) => val.trim().length > 0);
setHasFilled(filled);
}, [value, safeValue]);
const inputConfig = [
{
name: "address-line1",
placeholder: "Address Line 1",
required: question.required
? hasFilled
? question.isAddressLine1Required
: true
: hasFilled
? question.isAddressLine1Required
: false,
},
{
name: "address-line2",
placeholder: "Address Line 2",
required: question.required
? question.isAddressLine2Required
: hasFilled
? question.isAddressLine2Required
: false,
},
{
name: "address-level2",
placeholder: "City / Town",
required: question.required ? question.isCityRequired : hasFilled ? question.isCityRequired : false,
},
{
name: "address-level1",
placeholder: "State / Region",
required: question.required ? question.isStateRequired : hasFilled ? question.isStateRequired : false,
},
{
name: "postal-code",
placeholder: "ZIP / Post Code",
required: question.required ? question.isZipRequired : hasFilled ? question.isZipRequired : false,
},
{
name: "country-name",
placeholder: "Country",
required: question.required
? question.isCountryRequired
: hasFilled
? question.isCountryRequired
: false,
},
];
const addressTextRef = useCallback(
(currentElement: HTMLInputElement | null) => {
if (question.id && currentElement && autoFocusEnabled) {
currentElement.focus();
}
},
[question.id, autoFocusEnabled]
);
return (
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
<ScrollableContainer>
@@ -143,24 +117,39 @@ export const AddressQuestion = ({
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4 fb-space-y-2">
{inputConfig.map(({ name, placeholder, required }, index) => (
<input
ref={index === 0 ? addressTextRef : null}
dir="auto"
key={index}
name={name}
autoComplete={name}
id={`${question.id}-${index}`}
placeholder={placeholder}
tabIndex={index + 1}
required={required}
value={safeValue[index] || ""}
onInput={(e) => handleInputChange(e.currentTarget.value, index)}
autoFocus={autoFocusEnabled && index === 0}
className="fb-border-border focus:fb-border-brand placeholder:fb-text-placeholder fb-text-subheading fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm sm:fb-text-sm"
/>
))}
<div className={`fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full`}>
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {
return true;
}
// if all fields are optional and the question is required, then the fields should be required
if (
fields.filter((field) => field.show).every((field) => !field.required) &&
question.required
) {
return true;
}
return false;
};
return (
field.show && (
<Input
key={field.id}
placeholder={isFieldRequired() ? `${field.placeholder}*` : field.placeholder}
required={isFieldRequired()}
value={safeValue?.[index] || ""}
className="fb-py-3"
type={field.id === "email" ? "email" : "text"}
onChange={(e) => handleChange(field.id, e?.currentTarget?.value ?? "")}
/>
)
);
})}
</div>
</div>
</ScrollableContainer>

View File

@@ -0,0 +1,182 @@
import { BackButton } from "@/components/buttons/BackButton";
import { SubmitButton } from "@/components/buttons/SubmitButton";
import { Headline } from "@/components/general/Headline";
import { Input } from "@/components/general/Input";
import { QuestionMedia } from "@/components/general/QuestionMedia";
import { Subheader } from "@/components/general/Subheader";
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useMemo, useRef, useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types";
interface ContactInfoQuestionProps {
question: TSurveyContactInfoQuestion;
value?: string[];
onChange: (responseData: TResponseData) => void;
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
autoFocus?: boolean;
languageCode: string;
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
currentQuestionId: string;
}
export const ContactInfoQuestion = ({
question,
value,
onChange,
onSubmit,
onBack,
isFirstQuestion,
isLastQuestion,
languageCode,
ttc,
setTtc,
currentQuestionId,
}: ContactInfoQuestionProps) => {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
const safeValue = useMemo(() => {
return Array.isArray(value) ? value : ["", "", "", "", ""];
}, [value]);
const fields = [
{
id: "firstName",
placeholder: "First Name",
...question.firstName,
},
{
id: "lastName",
placeholder: "Last Name",
...question.lastName,
},
{
id: "email",
placeholder: "Email",
...question.email,
},
{
id: "phone",
placeholder: "Phone",
...question.phone,
},
{
id: "company",
placeholder: "Company",
...question.company,
},
];
const handleChange = (fieldId: string, fieldValue: string) => {
const newValue = fields.map((field) => {
if (field.id === fieldId) {
return fieldValue;
}
const existingValue = safeValue?.[fields.findIndex((f) => f.id === field.id)] || "";
return field.show ? existingValue : "";
});
onChange({ [question.id]: newValue });
};
const handleSubmit = (e: Event) => {
e.preventDefault();
const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtc);
const containsAllEmptyStrings = safeValue?.length === 5 && safeValue.every((item) => item.trim() === "");
if (containsAllEmptyStrings) {
onSubmit({ [question.id]: [] }, updatedTtc);
} else {
onSubmit({ [question.id]: safeValue ?? [] }, updatedTtc);
}
};
return (
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
<ScrollableContainer>
<div>
{isMediaAvailable && <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className={`fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full`}>
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {
return true;
}
// if all fields are optional and the question is required, then the fields should be required
if (
fields.filter((field) => field.show).every((field) => !field.required) &&
question.required
) {
return true;
}
return false;
};
let inputType = "text";
if (field.id === "email") {
inputType = "email";
} else if (field.id === "phone") {
inputType = "number";
}
return (
field.show && (
<Input
key={field.id}
placeholder={isFieldRequired() ? `${field.placeholder}*` : field.placeholder}
required={isFieldRequired()}
value={safeValue?.[index] || ""}
className="fb-py-3"
type={inputType}
onChange={(e) => handleChange(field.id, e?.currentTarget?.value ?? "")}
/>
)
);
})}
</div>
</div>
</ScrollableContainer>
<div className="fb-flex fb-w-full fb-justify-between fb-px-6 fb-py-4">
{!isFirstQuestion && (
<BackButton
tabIndex={8}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
onBack();
}}
/>
)}
<div></div>
<SubmitButton
tabIndex={7}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
onClick={() => {}}
/>
</div>
</form>
);
};

View File

@@ -134,6 +134,10 @@ const ZResponseFilterCriteriaMatrix = z.object({
value: z.record(z.string(), z.string()),
});
const ZResponseFilterCriteriaFilledOut = z.object({
op: z.literal("filledOut"),
});
export const ZResponseFilterCriteria = z.object({
finished: z.boolean().optional(),
createdAt: z
@@ -171,6 +175,7 @@ export const ZResponseFilterCriteria = z.object({
ZResponseFilterCriteriaDataNotUploaded,
ZResponseFilterCriteriaDataBooked,
ZResponseFilterCriteriaMatrix,
ZResponseFilterCriteriaFilledOut,
])
)
.optional(),

View File

@@ -67,6 +67,7 @@ export enum TSurveyQuestionTypeEnum {
Matrix = "matrix",
Address = "address",
Ranking = "ranking",
ContactInfo = "contactInfo",
}
export const ZSurveyQuestionId = z.string().superRefine((id, ctx) => {
@@ -576,17 +577,33 @@ export const ZSurveyMatrixQuestion = ZSurveyQuestionBase.extend({
export type TSurveyMatrixQuestion = z.infer<typeof ZSurveyMatrixQuestion>;
const ZSurveyShowRequiredToggle = z.object({
show: z.boolean(),
required: z.boolean(),
});
export const ZSurveyAddressQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionTypeEnum.Address),
isAddressLine1Required: z.boolean().default(false),
isAddressLine2Required: z.boolean().default(false),
isCityRequired: z.boolean().default(false),
isStateRequired: z.boolean().default(false),
isZipRequired: z.boolean().default(false),
isCountryRequired: z.boolean().default(false),
addressLine1: ZSurveyShowRequiredToggle,
addressLine2: ZSurveyShowRequiredToggle,
city: ZSurveyShowRequiredToggle,
state: ZSurveyShowRequiredToggle,
zip: ZSurveyShowRequiredToggle,
country: ZSurveyShowRequiredToggle,
});
export type TSurveyAddressQuestion = z.infer<typeof ZSurveyAddressQuestion>;
export const ZSurveyContactInfoQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionTypeEnum.ContactInfo),
firstName: ZSurveyShowRequiredToggle,
lastName: ZSurveyShowRequiredToggle,
email: ZSurveyShowRequiredToggle,
phone: ZSurveyShowRequiredToggle,
company: ZSurveyShowRequiredToggle,
});
export type TSurveyContactInfoQuestion = z.infer<typeof ZSurveyContactInfoQuestion>;
export const ZSurveyRankingQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionTypeEnum.Ranking),
choices: z
@@ -612,6 +629,7 @@ export const ZSurveyQuestion = z.union([
ZSurveyMatrixQuestion,
ZSurveyAddressQuestion,
ZSurveyRankingQuestion,
ZSurveyContactInfoQuestion,
]);
export type TSurveyQuestion = z.infer<typeof ZSurveyQuestion>;
@@ -635,6 +653,7 @@ export const ZSurveyQuestionType = z.enum([
TSurveyQuestionTypeEnum.Rating,
TSurveyQuestionTypeEnum.Cal,
TSurveyQuestionTypeEnum.Ranking,
TSurveyQuestionTypeEnum.ContactInfo,
]);
export type TSurveyQuestionType = z.infer<typeof ZSurveyQuestionType>;
@@ -1030,6 +1049,32 @@ export const ZSurvey = z
}
}
if (question.type === TSurveyQuestionTypeEnum.ContactInfo) {
const { company, email, firstName, lastName, phone } = question;
const fields = [company, email, firstName, lastName, phone];
if (fields.every((field) => !field.show)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one field must be shown in the Contact Info question",
path: ["questions", questionIndex],
});
}
}
if (question.type === TSurveyQuestionTypeEnum.Address) {
const { addressLine1, addressLine2, city, state, zip, country } = question;
const fields = [addressLine1, addressLine2, city, state, zip, country];
if (fields.every((field) => !field.show)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one field must be shown in the Address question",
path: ["questions", questionIndex],
});
}
}
if (question.logic) {
const logicIssues = validateLogic(survey, questionIndex, question.logic);
@@ -2290,11 +2335,32 @@ export const ZSurveyQuestionSummaryAddress = z.object({
export type TSurveyQuestionSummaryAddress = z.infer<typeof ZSurveyQuestionSummaryAddress>;
export const ZSurveyQuestionSummaryContactInfo = z.object({
type: z.literal("contactInfo"),
question: ZSurveyContactInfoQuestion,
responseCount: z.number(),
samples: z.array(
z.object({
id: z.string(),
updatedAt: z.date(),
value: z.array(z.string()),
person: z
.object({
id: ZId,
userId: z.string(),
})
.nullable(),
personAttributes: ZAttributes.nullable(),
})
),
});
export type TSurveyQuestionSummaryContactInfo = z.infer<typeof ZSurveyQuestionSummaryContactInfo>;
export const ZSurveyQuestionSummaryRanking = z.object({
type: z.literal("ranking"),
question: ZSurveyRankingQuestion,
responseCount: z.number(),
choices: z.array(
z.object({
value: z.string(),
@@ -2333,6 +2399,7 @@ export const ZSurveyQuestionSummary = z.union([
ZSurveyQuestionSummaryMatrix,
ZSurveyQuestionSummaryAddress,
ZSurveyQuestionSummaryRanking,
ZSurveyQuestionSummaryContactInfo,
]);
export type TSurveyQuestionSummary = z.infer<typeof ZSurveyQuestionSummary>;

View File

@@ -1,8 +1,8 @@
interface AddressResponseProps {
interface ArrayResponseProps {
value: string[];
}
export const AddressResponse = ({ value }: AddressResponseProps) => {
export const ArrayResponse = ({ value }: ArrayResponseProps) => {
return (
<div className="my-1 font-normal text-slate-700" dir="auto">
{value.map(

View File

@@ -32,10 +32,11 @@
position: relative;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
overflow: scroll;
overflow: auto;
resize: vertical;
height: auto;
min-height: 100px;
max-height: 150px;
}
.editor-input {

View File

@@ -0,0 +1,75 @@
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { Switch } from "../Switch";
interface QuestionToggleTableProps {
type: "address" | "contact";
fields: {
required: boolean;
show: boolean;
id: string;
label: string;
}[];
onShowToggle: (
field: {
id: string;
required: boolean;
show: boolean;
},
show: boolean
) => void;
onRequiredToggle: (
field: {
id: string;
show: boolean;
required: boolean;
},
required: boolean
) => void;
}
export const QuestionToggleTable = ({
type,
fields,
onShowToggle,
onRequiredToggle,
}: QuestionToggleTableProps) => {
return (
<table className="mt-4 w-1/2 table-fixed">
<thead>
<tr className="text-left text-slate-800">
<th className="w-1/2 text-sm font-semibold">{capitalizeFirstLetter(type)} Fields</th>
<th className="w-1/4 text-sm font-semibold">Show</th>
<th className="w-1/4 text-sm font-semibold">Required</th>
</tr>
</thead>
<tbody>
{fields.map((field) => (
<tr className="text-slate-900">
<td className="py-2 text-sm">{field.label}</td>
<td className="py-">
<Switch
checked={field.show}
onCheckedChange={(show) => {
onShowToggle(field, show);
}}
disabled={
// if all the other fields are hidden, this should be disabled
fields.filter((currentField) => currentField.id !== field.id).every((field) => !field.show)
}
/>
</td>
<td className="py-2">
<Switch
checked={field.required}
onCheckedChange={(required) => {
onRequiredToggle(field, required);
}}
disabled={!field.show}
/>
</td>
</tr>
))}
</tbody>
</table>
);
};

View File

@@ -13,7 +13,7 @@ import {
TSurveyQuestionTypeEnum,
TSurveyRatingQuestion,
} from "@formbricks/types/surveys/types";
import { AddressResponse } from "../../AddressResponse";
import { ArrayResponse } from "../../ArrayResponse";
import { FileUploadResponse } from "../../FileUploadResponse";
import { PictureSelectionResponse } from "../../PictureSelectionResponse";
import { RankingRespone } from "../../RankingResponse";
@@ -109,10 +109,12 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
}
break;
case TSurveyQuestionTypeEnum.Address:
case TSurveyQuestionTypeEnum.ContactInfo:
if (Array.isArray(responseData)) {
return <AddressResponse value={responseData} />;
return <ArrayResponse value={responseData} />;
}
break;
case TSurveyQuestionTypeEnum.Cal:
if (typeof responseData === "string" || typeof responseData === "number") {
return (