mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-03 12:49:57 -05:00
Compare commits
8 Commits
feature/up
...
fix-dark-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5267fff6b2 | ||
|
|
a6d592307c | ||
|
|
354ec1b887 | ||
|
|
d5bc70846e | ||
|
|
87c584add8 | ||
|
|
49e0b8fc0f | ||
|
|
5035e3db9d | ||
|
|
b7f4097508 |
2
apps/demo/next-env.d.ts
vendored
2
apps/demo/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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: {} });
|
||||
|
||||
@@ -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 ?? ""] = {
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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();
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
19
packages/surveys/src/components/general/Input.tsx
Normal file
19
packages/surveys/src/components/general/Input.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(
|
||||
@@ -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 {
|
||||
|
||||
75
packages/ui/components/QuestionToggleTable/index.tsx
Normal file
75
packages/ui/components/QuestionToggleTable/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user