Add MultipleChoice Multi-Select Question Type (#238)

Add MultipleChoice Multi-Select Question Type

---------

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Moritz Rengert
2023-04-19 13:38:23 +02:00
committed by GitHub
parent 72c6ea6f39
commit d15d062581
24 changed files with 730 additions and 166 deletions
@@ -52,7 +52,9 @@ export default function ResponseFeed({ person, sortByDate, environmentId }) {
<div key={question.id}>
<p className="text-sm text-slate-500">{question.headline}</p>
<p className="my-1 text-lg font-semibold text-slate-700">
{response.data[question.id]}
{response.data[question.id] instanceof Array
? response.data[question.id].join(", ")
: response.data[question.id]}
</p>
</div>
))}
@@ -24,19 +24,18 @@ export default function PreviewSurvey({
brandColor,
}: PreviewSurveyProps) {
const [isModalOpen, setIsModalOpen] = useState(true);
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null);
const [progress, setProgress] = useState(0); // [0, 1]
useEffect(() => {
if (currentQuestion && localSurvey) {
setProgress(calculateProgress(currentQuestion, localSurvey));
if (activeQuestionId && localSurvey) {
setProgress(calculateProgress(localSurvey));
}
function calculateProgress(currentQuestion, survey) {
const elementIdx = survey.questions.findIndex((e) => e.id === currentQuestion.id);
function calculateProgress(survey) {
const elementIdx = survey.questions.findIndex((e) => e.id === activeQuestionId);
return elementIdx / survey.questions.length;
}
}, [currentQuestion, localSurvey]);
}, [activeQuestionId, localSurvey]);
useEffect(() => {
// close modal if there are no questions left
@@ -44,7 +43,6 @@ export default function PreviewSurvey({
if (activeQuestionId === "thank-you-card") {
setIsModalOpen(false);
setTimeout(() => {
setCurrentQuestion(questions[0]);
setActiveQuestionId(questions[0].id);
setIsModalOpen(true);
}, 500);
@@ -52,43 +50,25 @@ export default function PreviewSurvey({
}
}, [activeQuestionId, localSurvey, questions, setActiveQuestionId]);
useEffect(() => {
const currentIndex = questions.findIndex((q) => q.id === currentQuestion?.id);
if (currentIndex < questions.length && currentIndex >= 0 && !localSurvey) return;
if (activeQuestionId) {
if (currentQuestion && currentQuestion.id === activeQuestionId) {
setCurrentQuestion(questions.find((q) => q.id === activeQuestionId) || null);
return;
}
if (activeQuestionId === "thank-you-card") return;
setIsModalOpen(false);
setTimeout(() => {
setCurrentQuestion(questions.find((q) => q.id === activeQuestionId) || null);
setIsModalOpen(true);
}, 300);
} else {
if (questions && questions.length > 0) {
setCurrentQuestion(questions[0]);
}
}
}, [activeQuestionId, currentQuestion, localSurvey, questions]);
const gotoNextQuestion = () => {
if (currentQuestion) {
const currentIndex = questions.findIndex((q) => q.id === currentQuestion.id);
if (currentIndex < questions.length - 1) {
setCurrentQuestion(questions[currentIndex + 1]);
setActiveQuestionId(questions[currentIndex + 1].id);
const currentIndex = questions.findIndex((q) => q.id === activeQuestionId);
if (currentIndex < questions.length - 1) {
setActiveQuestionId(questions[currentIndex + 1].id);
} else {
if (localSurvey?.thankYouCard?.enabled) {
setActiveQuestionId("thank-you-card");
} else {
setIsModalOpen(false);
setTimeout(() => {
setActiveQuestionId(questions[0].id);
setIsModalOpen(true);
}, 500);
if (localSurvey?.thankYouCard?.enabled) {
setActiveQuestionId("thank-you-card");
setProgress(1);
} else {
setIsModalOpen(false);
setTimeout(() => {
setCurrentQuestion(questions[0]);
setActiveQuestionId(questions[0].id);
setIsModalOpen(true);
}, 500);
@@ -100,18 +80,15 @@ export default function PreviewSurvey({
const resetPreview = () => {
setIsModalOpen(false);
setTimeout(() => {
setCurrentQuestion(questions[0]);
setActiveQuestionId(questions[0].id);
setIsModalOpen(true);
}, 500);
};
if (!currentQuestion) {
if (!activeQuestionId) {
return null;
}
const lastQuestion = questions.length > 0 && currentQuestion.id === questions[questions.length - 1].id;
return (
<>
{localSurvey?.type === "link" ? (
@@ -131,12 +108,18 @@ export default function PreviewSurvey({
subheader={localSurvey?.thankYouCard?.subheader || ""}
/>
) : (
<QuestionConditional
currentQuestion={currentQuestion}
brandColor={brandColor}
lastQuestion={lastQuestion}
onSubmit={gotoNextQuestion}
/>
questions.map(
(question, idx) =>
activeQuestionId === question.id && (
<QuestionConditional
key={question.id}
question={question}
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
/>
)
)
)}
</ContentWrapper>
</div>
@@ -155,12 +138,18 @@ export default function PreviewSurvey({
subheader={localSurvey?.thankYouCard?.subheader || ""}
/>
) : (
<QuestionConditional
currentQuestion={currentQuestion}
brandColor={brandColor}
lastQuestion={lastQuestion}
onSubmit={gotoNextQuestion}
/>
questions.map(
(question, idx) =>
activeQuestionId === question.id && (
<QuestionConditional
key={question.id}
question={question}
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
/>
)
)
)}
</Modal>
)}
@@ -0,0 +1,111 @@
import { Button } from "@formbricks/ui";
import { Input } from "@formbricks/ui";
import { Label } from "@formbricks/ui";
import type { MultipleChoiceMultiQuestion } from "@formbricks/types/questions";
import { createId } from "@paralleldrive/cuid2";
import { TrashIcon } from "@heroicons/react/24/solid";
interface OpenQuestionFormProps {
question: MultipleChoiceMultiQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
}
export default function MultipleChoiceMultiForm({
question,
questionIdx,
updateQuestion,
lastQuestion,
}: OpenQuestionFormProps) {
const updateChoice = (choiceIdx: number, updatedAttributes: any) => {
const newChoices = !question.choices
? []
: question.choices.map((choice, idx) => {
if (idx === choiceIdx) {
return { ...choice, ...updatedAttributes };
}
return choice;
});
updateQuestion(questionIdx, { choices: newChoices });
};
const addChoice = () => {
const newChoices = !question.choices ? [] : question.choices;
newChoices.push({ id: createId(), label: "" });
updateQuestion(questionIdx, { choices: newChoices });
};
const deleteChoice = (choiceIdx: number) => {
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
updateQuestion(questionIdx, { choices: newChoices });
};
return (
<form>
<div className="mt-3">
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
id="headline"
name="headline"
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
/>
</div>
</div>
<div className="mt-3">
<Label htmlFor="subheader">Description</Label>
<div className="mt-2">
<Input
id="subheader"
name="subheader"
value={question.subheader}
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
/>
</div>
</div>
<div className="mt-3">
<Label htmlFor="choices">Choices</Label>
<div className="mt-2 space-y-2" id="choices">
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<div key={choiceIdx} className="inline-flex w-full items-center">
<Input
id={choice.id}
name={choice.id}
value={choice.label}
placeholder={`Choice ${choiceIdx + 1}`}
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
/>
{question.choices && question.choices.length > 2 && (
<TrashIcon
className="ml-2 h-4 w-4 text-slate-400"
onClick={() => deleteChoice(choiceIdx)}
/>
)}
</div>
))}
<Button variant="secondary" type="button" onClick={() => addChoice()}>
Add Choice
</Button>
</div>
</div>
<div className="mt-3">
<Label htmlFor="buttonLabel">Button Label</Label>
<div className="mt-2">
<Input
id="buttonLabel"
name="buttonLabel"
value={question.buttonLabel}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
/>
</div>
</div>
</form>
);
}
@@ -10,6 +10,7 @@ import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
import { Draggable } from "react-beautiful-dnd";
import MultipleChoiceSingleForm from "./MultipleChoiceSingleForm";
import MultipleChoiceMultiForm from "./MultipleChoiceMultiForm";
import OpenQuestionForm from "./OpenQuestionForm";
import QuestionDropdown from "./QuestionDropdown";
import UpdateQuestionId from "./UpdateQuestionId";
@@ -112,6 +113,13 @@ export default function QuestionCard({
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
/>
) : question.type === "multipleChoiceMulti" ? (
<MultipleChoiceMultiForm
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
/>
) : null}
<div className="mt-4 border-t border-slate-200">
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-3">
@@ -36,7 +36,6 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
}
return { ...response, responses: updatedResponse };
});
return updatedResponses;
}
return [];
@@ -13,7 +13,7 @@ interface OpenTextSummaryProps {
responses: {
id: string;
question: string;
answer: string;
answer: string | any[];
}[];
};
environmentId: string;
@@ -53,14 +53,16 @@ export default function SingleResponse({ data, environmentId }: OpenTextSummaryP
</div>
</div>
<div className="space-y-6 rounded-b-lg bg-white p-6">
{data.responses.map((response) => {
return (
<div key={response.id}>
<p className="text-sm text-slate-500">{response.question}</p>
{data.responses.map((response, idx) => (
<div key={`${response.id}-${idx}`}>
<p className="text-sm text-slate-500">{response.question}</p>
{typeof response.answer === "string" ? (
<p className="my-1 font-semibold text-slate-700">{response.answer}</p>
</div>
);
})}
) : (
<p className="my-1 font-semibold text-slate-700">{response.answer.join(", ")}</p>
)}
</div>
))}
</div>
</div>
);
@@ -14,6 +14,8 @@ interface ChoiceResult {
}
export default function MultipleChoiceSummary({ questionSummary }: MultipleChoiceSummaryProps) {
const isSingleChoice = questionSummary.question.type === "multipleChoiceSingle";
const results: ChoiceResult[] = useMemo(() => {
if (!("choices" in questionSummary.question)) return [];
// build a dictionary of choices
@@ -27,9 +29,16 @@ export default function MultipleChoiceSummary({ questionSummary }: MultipleChoic
}
// count the responses
for (const response of questionSummary.responses) {
// only add responses that are in the choices
if (response.value in resultsDict) {
// if single choice, only add responses that are in the choices
if (isSingleChoice && response.value in resultsDict) {
resultsDict[response.value].count += 1;
} else {
// if multi choice add all responses
for (const choice of response.value) {
if (choice in resultsDict) {
resultsDict[choice].count += 1;
}
}
}
}
// add the percentage
@@ -59,7 +68,11 @@ export default function MultipleChoiceSummary({ questionSummary }: MultipleChoic
<h3 className="pb-1 text-xl font-semibold text-slate-900">{questionSummary.question.headline}</h3>
</div>
<div className="flex space-x-2 font-semibold text-slate-600">
<div className="rounded-lg bg-slate-100 p-2 text-sm">Multiple-Choice Single Select Question</div>
<div className="rounded-lg bg-slate-100 p-2 text-sm">
{isSingleChoice
? "Multiple-Choice Single Select Question"
: "Multiple-Choice Multi Select Question"}
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2 text-sm">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{totalResponses} responses
@@ -61,7 +61,10 @@ export default function SummaryList({ environmentId, surveyId }) {
/>
);
}
if (questionSummary.question.type === "multipleChoiceSingle") {
if (
questionSummary.question.type === "multipleChoiceSingle" ||
questionSummary.question.type === "multipleChoiceMulti"
) {
return (
<MultipleChoiceSummary
key={questionSummary.question.id}
+5 -13
View File
@@ -1,7 +1,5 @@
"use client";
import MultipleChoiceSingleQuestion from "@/components/preview/MultipleChoiceSingleQuestion";
import OpenTextQuestion from "@/components/preview/OpenTextQuestion";
import Progress from "@/components/preview/Progress";
import ThankYouCard from "@/components/preview/ThankYouCard";
import ContentWrapper from "@/components/shared/ContentWrapper";
@@ -12,6 +10,7 @@ import type { Question } from "@formbricks/types/questions";
import type { Survey } from "@formbricks/types/surveys";
import { Confetti } from "@formbricks/ui";
import { useEffect, useState } from "react";
import QuestionConditional from "@/components/preview/QuestionConditional";
import { createDisplay, markDisplayResponded } from "@formbricks/lib/clientDisplay/display";
type EnhancedSurvey = Survey & {
@@ -124,21 +123,14 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) {
brandColor={survey.brandColor}
/>
</div>
) : currentQuestion.type === "openText" ? (
<OpenTextQuestion
) : (
<QuestionConditional
question={currentQuestion}
onSubmit={submitResponse}
lastQuestion={lastQuestion}
brandColor={survey.brandColor}
/>
) : currentQuestion.type === "multipleChoiceSingle" ? (
<MultipleChoiceSingleQuestion
question={currentQuestion}
onSubmit={submitResponse}
lastQuestion={lastQuestion}
brandColor={survey.brandColor}
onSubmit={submitResponse}
/>
) : null}
)}
</ContentWrapper>
</div>
<div className="top-0 z-10 w-full border-b bg-white">
@@ -0,0 +1,105 @@
import { useState, useEffect } from "react";
import { cn } from "@formbricks/lib/cn";
import type { MultipleChoiceMultiQuestion } from "@formbricks/types/questions";
import Headline from "./Headline";
import Subheader from "./Subheader";
interface MultipleChoiceMultiProps {
question: MultipleChoiceMultiQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
}
export default function MultipleChoiceMultiQuestion({
question,
onSubmit,
lastQuestion,
brandColor,
}: MultipleChoiceMultiProps) {
const [selectedChoices, setSelectedChoices] = useState<string[]>([]);
const [isAtLeastOneChecked, setIsAtLeastOneChecked] = useState(false);
useEffect(() => {
setIsAtLeastOneChecked(selectedChoices.length > 0);
}, [selectedChoices]);
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (question.required && selectedChoices.length <= 0) {
return;
}
const data = {
[question.id]: selectedChoices,
};
onSubmit(data);
setSelectedChoices([]); // reset value
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
<div className="mt-4">
<fieldset>
<legend className="sr-only">Choices</legend>
<div className="relative space-y-2 rounded-md bg-white">
{question.choices &&
question.choices.map((choice) => (
<label
key={choice.id}
className={cn(
selectedChoices.includes(choice.label)
? "z-10 border-slate-400 bg-slate-50"
: "border-gray-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 hover:bg-slate-50 focus:outline-none"
)}>
<span className="flex items-center text-sm">
<input
type="checkbox"
id={choice.id}
name={question.id}
value={choice.label}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
checked={selectedChoices.includes(choice.label)}
onChange={(e) => {
if (e.currentTarget.checked) {
setSelectedChoices([...selectedChoices, e.currentTarget.value]);
} else {
setSelectedChoices(
selectedChoices.filter((label) => label !== e.currentTarget.value)
);
}
}}
style={{ borderColor: brandColor, color: brandColor }}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}
</span>
</span>
</label>
))}
</div>
</fieldset>
</div>
<input
type="text"
className="clip-[rect(0,0,0,0)] absolute m-[-1px] h-1 w-1 overflow-hidden whitespace-nowrap border-0 p-0 text-transparent caret-transparent focus:border-transparent focus:ring-0"
required={question.required}
value={isAtLeastOneChecked ? "checked" : ""}
onChange={() => {}}
/>
<div className="mt-4 flex w-full justify-between">
<div></div>
<button
type="submit"
className="flex items-center rounded-md border border-transparent px-3 py-3 text-base font-medium leading-4 text-white shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
style={{ backgroundColor: brandColor }}>
{question.buttonLabel || (lastQuestion ? "Finish" : "Next")}
</button>
</div>
</form>
);
}
@@ -26,9 +26,8 @@ export default function MultipleChoiceSingleQuestion({
[question.id]: e.currentTarget[question.id].value,
};
e.currentTarget[question.id].value = "";
onSubmit(data);
// reset form
setSelectedChoice(null); // reset form
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -55,6 +54,7 @@ export default function MultipleChoiceSingleQuestion({
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
checked={selectedChoice === choice.label}
style={{ borderColor: brandColor, color: brandColor }}
required={question.required && idx === 0}
/>
@@ -26,9 +26,8 @@ export default function OpenTextQuestion({
const data = {
[question.id]: value,
};
setValue("");
setValue(""); // reset value
onSubmit(data);
// reset form
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -1,30 +1,38 @@
import type { Question } from "@formbricks/types/questions";
import OpenTextQuestion from "./OpenTextQuestion";
import MultipleChoiceSingleQuestion from "./MultipleChoiceSingleQuestion";
import MultipleChoiceMultiQuestion from "./MultipleChoiceMultiQuestion";
interface QuestionConditionalProps {
currentQuestion: Question;
question: Question;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
}
export default function QuestionConditional({
currentQuestion,
question,
onSubmit,
lastQuestion,
brandColor,
}: QuestionConditionalProps) {
return currentQuestion.type === "openText" ? (
return question.type === "openText" ? (
<OpenTextQuestion
question={currentQuestion}
question={question}
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
/>
) : currentQuestion.type === "multipleChoiceSingle" ? (
) : question.type === "multipleChoiceSingle" ? (
<MultipleChoiceSingleQuestion
question={currentQuestion}
question={question}
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
/>
) : question.type === "multipleChoiceMulti" ? (
<MultipleChoiceMultiQuestion
question={question}
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
@@ -25,12 +25,14 @@ export default function ThankYouCard({ headline, subheader, brandColor }: ThankY
/>
</svg>
</div>
<span className="mb-[10px] inline-block h-1 w-16 rounded-[100%] bg-slate-300"></span>
<div>
<Headline headline={headline} questionId="thankYouCard" />
<Subheader subheader={subheader} questionId="thankYouCard" />
</div>
{/* <span
className="mb-[10px] mt-[35px] inline-block h-[2px] w-4/5 rounded-full opacity-25"
style={{ backgroundColor: brandColor }}></span>
+12
View File
@@ -31,6 +31,18 @@ export const questionTypes: QuestionType[] = [
],
},
},
{
id: "multipleChoiceMulti",
label: "Multiple Choice Multi-Select",
description: "Number of choices from a list of options (checkboxes)",
icon: ListBulletIcon,
defaults: {
choices: [
{ id: createId(), label: "" },
{ id: createId(), label: "" },
],
},
},
];
export const universalQuestionDefaults = {
+1 -1
View File
@@ -27,7 +27,7 @@
"eslint-config-next": "^13.3.0",
"jsonwebtoken": "^9.0.0",
"lucide-react": "^0.161.0",
"next": "13.3.0",
"next": "13.2.4",
"next-auth": "^4.22.0",
"nodemailer": "^6.9.1",
"platform": "^1.3.6",