Add Other Option to Multiple Choice Questions (#314)

* add other options to multiple choice question types

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
Moritz Rengert
2023-06-06 20:10:47 +02:00
committed by GitHub
parent d7fb29607a
commit 8a2beab5d1
8 changed files with 309 additions and 61 deletions

View File

@@ -3,6 +3,7 @@ import { Survey } from "@formbricks/types/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
interface OpenQuestionFormProps {
localSurvey: Survey;
@@ -31,11 +32,26 @@ export default function MultipleChoiceMultiForm({
};
const addChoice = () => {
const newChoices = !question.choices ? [] : question.choices;
let newChoices = !question.choices ? [] : question.choices;
const otherChoice = newChoices.find((choice) => choice.id === "other");
if (otherChoice) {
newChoices = newChoices.filter((choice) => choice.id !== "other");
}
newChoices.push({ id: createId(), label: "" });
if (otherChoice) {
newChoices.push(otherChoice);
}
updateQuestion(questionIdx, { choices: newChoices });
};
const addOther = () => {
if (question.choices.filter((c) => c.id === "other").length === 0) {
const newChoices = !question.choices ? [] : question.choices.filter((c) => c.id !== "other");
newChoices.push({ id: "other", label: "Other" });
updateQuestion(questionIdx, { choices: newChoices });
}
};
const deleteChoice = (choiceIdx: number) => {
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
@@ -90,7 +106,8 @@ export default function MultipleChoiceMultiForm({
id={choice.id}
name={choice.id}
value={choice.label}
placeholder={`Option ${choiceIdx + 1}`}
className={cn(choice.id === "other" && "border-dashed")}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
/>
{question.choices && question.choices.length > 2 && (
@@ -101,9 +118,19 @@ export default function MultipleChoiceMultiForm({
)}
</div>
))}
<Button variant="secondary" type="button" onClick={() => addChoice()}>
Add Option
</Button>
<div className="flex items-center space-x-2">
<Button variant="secondary" type="button" onClick={() => addChoice()}>
Add Option
</Button>
{question.choices.filter((c) => c.id === "other").length === 0 && (
<>
<p>or</p>
<Button size="sm" variant="minimal" type="button" onClick={() => addOther()}>
Add &quot;Other&quot; with specify
</Button>
</>
)}
</div>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { Survey } from "@formbricks/types/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
interface OpenQuestionFormProps {
localSurvey: Survey;
@@ -31,11 +32,26 @@ export default function MultipleChoiceSingleForm({
};
const addChoice = () => {
const newChoices = !question.choices ? [] : question.choices;
let newChoices = !question.choices ? [] : question.choices;
const otherChoice = newChoices.find((choice) => choice.id === "other");
if (otherChoice) {
newChoices = newChoices.filter((choice) => choice.id !== "other");
}
newChoices.push({ id: createId(), label: "" });
if (otherChoice) {
newChoices.push(otherChoice);
}
updateQuestion(questionIdx, { choices: newChoices });
};
const addOther = () => {
if (question.choices.filter((c) => c.id === "other").length === 0) {
const newChoices = !question.choices ? [] : question.choices.filter((c) => c.id !== "other");
newChoices.push({ id: "other", label: "Other" });
updateQuestion(questionIdx, { choices: newChoices });
}
};
const deleteChoice = (choiceIdx: number) => {
const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx);
@@ -90,7 +106,8 @@ export default function MultipleChoiceSingleForm({
id={choice.id}
name={choice.id}
value={choice.label}
placeholder={`Option ${choiceIdx + 1}`}
className={cn(choice.id === "other" && "border-dashed")}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
/>
{question.choices && question.choices.length > 2 && (
@@ -101,9 +118,19 @@ export default function MultipleChoiceSingleForm({
)}
</div>
))}
<Button variant="secondary" type="button" onClick={() => addChoice()}>
Add Option
</Button>
<div className="flex items-center space-x-2">
<Button variant="secondary" type="button" onClick={() => addChoice()}>
Add Option
</Button>
{question.choices.filter((c) => c.id === "other").length === 0 && (
<>
<p>or</p>
<Button size="sm" variant="minimal" type="button" onClick={() => addOther()}>
Add &quot;Other&quot; with specify
</Button>
</>
)}
</div>
</div>
</div>

View File

@@ -1,43 +1,93 @@
import { MultipleChoiceMultiQuestion, MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui";
import { PersonAvatar, ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import Link from "next/link";
import { truncate } from "@/lib/utils";
interface MultipleChoiceSummaryProps {
questionSummary: QuestionSummary<MultipleChoiceMultiQuestion | MultipleChoiceSingleQuestion>;
environmentId: string;
surveyType: string;
}
interface ChoiceResult {
id: string;
label: string;
count: number;
percentage?: number;
otherValues?: {
value: string;
person: {
id: string;
name?: string;
email?: string;
};
}[];
}
export default function MultipleChoiceSummary({ questionSummary }: MultipleChoiceSummaryProps) {
export default function MultipleChoiceSummary({
questionSummary,
environmentId,
surveyType,
}: MultipleChoiceSummaryProps) {
const isSingleChoice = questionSummary.question.type === "multipleChoiceSingle";
const results: ChoiceResult[] = useMemo(() => {
if (!("choices" in questionSummary.question)) return [];
console.log(questionSummary.responses);
// build a dictionary of choices
const resultsDict: { [key: string]: ChoiceResult } = {};
for (const choice of questionSummary.question.choices) {
resultsDict[choice.label] = {
count: 0,
id: choice.id,
label: choice.label,
count: 0,
percentage: 0,
otherValues: [],
};
}
function findEmail(person) {
const emailAttribute = person.attributes.find((attr) => attr.attributeClass.name === "email");
return emailAttribute ? emailAttribute.value : null;
}
const addOtherChoice = (response, value) => {
for (const key in resultsDict) {
if (resultsDict[key].id === "other" && value !== "") {
const email = response.person && findEmail(response.person);
const displayIdentifier = email || truncate(response.personId, 16);
resultsDict[key].otherValues?.push({
value,
person: {
id: response.personId,
email: displayIdentifier,
},
});
resultsDict[key].count += 1;
break;
}
}
};
// count the responses
for (const response of questionSummary.responses) {
// if single choice, only add responses that are in the choices
if (isSingleChoice && response.value in resultsDict) {
resultsDict[response.value].count += 1;
} else if (isSingleChoice) {
// if single choice and not in choices, add to other
addOtherChoice(response, response.value);
} else {
// if multi choice add all responses
for (const choice of response.value) {
if (choice in resultsDict) {
resultsDict[choice].count += 1;
} else {
// if multi choice and not in choices, add to other
addOtherChoice(response, choice);
}
}
}
@@ -49,8 +99,15 @@ export default function MultipleChoiceSummary({ questionSummary }: MultipleChoic
resultsDict[key].percentage = resultsDict[key].count / total;
}
}
// sort by count and transform to array
const results = Object.values(resultsDict).sort((a: any, b: any) => b.count - a.count);
const results = Object.values(resultsDict).sort((a: any, b: any) => {
if (a.id === "other") return 1; // Always put a after b if a's id is 'other'
if (b.id === "other") return -1; // Always put b after a if b's id is 'other'
// If neither id is 'other', compare counts
return b.count - a.count;
});
return results;
}, [questionSummary, isSingleChoice]);
@@ -103,6 +160,45 @@ export default function MultipleChoiceSummary({ questionSummary }: MultipleChoic
</p>
</div>
<ProgressBar barColor="bg-brand" progress={result.percentage} />
{result.otherValues.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6 ">Specified &quot;Other&quot; answers</div>
<div className="col-span-1 pl-6 ">{surveyType === "web" && "User"}</div>
</div>
{result.otherValues
.filter((otherValue) => otherValue !== "")
.map((otherValue, idx) => (
<div key={idx}>
{surveyType === "link" && (
<div
key={idx}
className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "web" && (
<Link
href={
otherValue.person.id
? `/environments/${environmentId}/people/${otherValue.person.id}`
: { pathname: null }
}
key={idx}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.person.id && <PersonAvatar personId={otherValue.person.id} />}
<span>{otherValue.person.email}</span>
</div>
</Link>
)}
</div>
))}
</div>
)}
</div>
))}
</div>

View File

@@ -90,6 +90,8 @@ export default function SummaryList({ environmentId, surveyId }) {
MultipleChoiceMultiQuestion | MultipleChoiceSingleQuestion
>
}
environmentId={environmentId}
surveyType={survey.type}
/>
);
}

View File

@@ -4,6 +4,7 @@ import type { MultipleChoiceMultiQuestion } from "@formbricks/types/questions";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "@/components/preview/SubmitButton";
import { Input } from "@/../../packages/ui";
interface MultipleChoiceMultiProps {
question: MultipleChoiceMultiQuestion;
@@ -20,16 +21,22 @@ export default function MultipleChoiceMultiQuestion({
}: MultipleChoiceMultiProps) {
const [selectedChoices, setSelectedChoices] = useState<string[]>([]);
const [isAtLeastOneChecked, setIsAtLeastOneChecked] = useState(false);
const [showOther, setShowOther] = useState(false);
const [otherSpecified, setOtherSpecified] = useState("");
useEffect(() => {
setIsAtLeastOneChecked(selectedChoices.length > 0);
}, [selectedChoices]);
setIsAtLeastOneChecked(selectedChoices.length > 0 || otherSpecified.length > 0);
}, [selectedChoices, otherSpecified]);
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
if (question.required && selectedChoices.length <= 0) {
return;
}
@@ -37,8 +44,12 @@ export default function MultipleChoiceMultiQuestion({
const data = {
[question.id]: selectedChoices,
};
onSubmit(data);
// console.log(data);
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -48,39 +59,64 @@ export default function MultipleChoiceMultiQuestion({
<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}
<>
<label
key={choice.id}
className={cn(
selectedChoices.includes(choice.label) || (choice.id === "other" && showOther)
? "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 flex-col text-sm">
<span className="flex items-center">
<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) || (choice.id === "other" && showOther)
}
onChange={(e) => {
if (choice.id === "other") {
setShowOther(e.currentTarget.checked);
return;
}
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>
{choice.id === "other" && showOther && (
<Input
type="text"
id={`${choice.id}-label`}
name={question.id}
className="mt-2 bg-white"
placeholder="Please specify"
onChange={(e) => setOtherSpecified(e.currentTarget.value)}
aria-labelledby={`${choice.id}-label`}
required={question.required}
autoFocus
/>
)}
</span>
</span>
</label>
</label>
</>
))}
</div>
</fieldset>

View File

@@ -4,6 +4,8 @@ import { useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "@/components/preview/SubmitButton";
import { Input } from "@/../../packages/ui";
import { useRef } from "react";
interface MultipleChoiceSingleProps {
question: MultipleChoiceSingleQuestion;
@@ -19,13 +21,18 @@ export default function MultipleChoiceSingleQuestion({
brandColor,
}: MultipleChoiceSingleProps) {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const otherSpecify = useRef<HTMLInputElement>(null);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const value = otherSpecify.current?.value || e.currentTarget[question.id].value;
const data = {
[question.id]: e.currentTarget[question.id].value,
[question.id]: value,
};
// console.log(data);
onSubmit(data);
setSelectedChoice(null); // reset form
@@ -52,10 +59,8 @@ export default function MultipleChoiceSingleQuestion({
value={choice.label}
className="h-4 w-4 border border-gray-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
checked={selectedChoice === choice.label}
onChange={() => setSelectedChoice(choice.id)}
checked={selectedChoice === choice.id}
style={{ borderColor: brandColor, color: brandColor }}
required={question.required && idx === 0}
/>
@@ -63,6 +68,17 @@ export default function MultipleChoiceSingleQuestion({
{choice.label}
</span>
</span>
{choice.id === "other" && selectedChoice === "other" && (
<Input
ref={otherSpecify}
id="other-specify"
name="other-specify"
placeholder="Please specify"
className="mt-3 bg-white"
required={question.required}
autoFocus
/>
)}
</label>
))}
</div>

View File

@@ -20,16 +20,25 @@ export default function MultipleChoiceMultiQuestion({
brandColor,
}: MultipleChoiceMultiProps) {
const [selectedChoices, setSelectedChoices] = useState<string[]>([]);
const [showOther, setShowOther] = useState(false);
const [otherSpecified, setOtherSpecified] = useState("");
const isAtLeastOneChecked = () => {
return selectedChoices.length > 0;
return selectedChoices.length > 0 || otherSpecified.length > 0;
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (!isAtLeastOneChecked() && question.required) return;
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
if (question.required && selectedChoices.length <= 0) {
return;
}
const data = {
[question.id]: selectedChoices,
@@ -37,6 +46,8 @@ export default function MultipleChoiceMultiQuestion({
onSubmit(data);
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -52,7 +63,7 @@ export default function MultipleChoiceMultiQuestion({
selectedChoices.includes(choice.label)
? "fb-z-10 fb-border-slate-400 fb-bg-slate-50"
: "fb-border-gray-200",
"fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-rounded-md fb-border fb-p-4 hover:fb-bg-slate-50 focus:fb-outline-none"
"fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-space-y-3 fb-rounded-md fb-border fb-p-4 hover:fb-bg-slate-50 focus:fb-outline-none"
)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
@@ -63,6 +74,12 @@ export default function MultipleChoiceMultiQuestion({
className="fb-h-4 fb-w-4 fb-border fb-border-slate-300 focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
if (choice.id === "other") {
setShowOther(e.currentTarget.checked);
return;
}
if (e.currentTarget.checked) {
setSelectedChoices([...selectedChoices, e.currentTarget.value]);
} else {
@@ -71,13 +88,25 @@ export default function MultipleChoiceMultiQuestion({
);
}
}}
checked={selectedChoices.includes(choice.label)}
checked={selectedChoices.includes(choice.label) || (choice.id === "other" && showOther)}
style={{ borderColor: brandColor, color: brandColor }}
/>
<span id={`${choice.id}-label`} className="fb-ml-3 fb-font-medium">
{choice.label}
</span>
</span>
{choice.id === "other" && showOther && (
<input
type="text"
id={`${choice.id}-label`}
name={question.id}
placeholder="Please specify"
className="fb-mt-3 fb-flex fb-h-10 fb-w-full fb-rounded-md fb-border fb-bg-white fb-border-slate-300 fb-bg-transparent fb-px-3 fb-py-2 fb-text-sm fb-text-slate-800 placeholder:fb-text-slate-400 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-slate-400 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50 dark:fb-border-slate-500 dark:fb-text-slate-300"
onChange={(e) => setOtherSpecified(e.currentTarget.value)}
aria-labelledby={`${choice.id}-label`}
required={question.required}
/>
)}
</label>
))}
</div>

View File

@@ -1,5 +1,5 @@
import { h } from "preact";
import { useState } from "preact/hooks";
import { useRef, useState } from "preact/hooks";
import { cn } from "../lib/utils";
import type { MultipleChoiceSingleQuestion } from "../../../types/questions";
import Headline from "./Headline";
@@ -20,13 +20,18 @@ export default function MultipleChoiceSingleQuestion({
brandColor,
}: MultipleChoiceSingleProps) {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const otherSpecify = useRef<HTMLInputElement>(null);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const value = otherSpecify.current?.value || e.currentTarget[question.id].value;
const data = {
[question.id]: e.currentTarget[question.id].value,
[question.id]: value,
};
onSubmit(data);
setSelectedChoice(null); // reset form
}}>
@@ -55,9 +60,9 @@ export default function MultipleChoiceSingleQuestion({
className="fb-h-4 fb-w-4 fb-border fb-border-slate-300 focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
setSelectedChoice(choice.id);
}}
checked={selectedChoice === choice.label}
checked={selectedChoice === choice.id}
style={{ borderColor: brandColor, color: brandColor }}
required={question.required && idx === 0}
/>
@@ -65,6 +70,16 @@ export default function MultipleChoiceSingleQuestion({
{choice.label}
</span>
</span>
{choice.id === "other" && selectedChoice === "other" && (
<input
ref={otherSpecify}
id="other-specify"
name="other-specify"
placeholder="Please specify"
className="fb-mt-3 fb-flex fb-h-10 fb-w-full fb-rounded-md fb-border fb-bg-white fb-border-slate-300 fb-bg-transparent fb-px-3 fb-py-2 fb-text-sm fb-text-slate-800 placeholder:fb-text-slate-400 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-slate-400 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50 dark:fb-border-slate-500 dark:fb-text-slate-300"
required={question.required}
/>
)}
</label>
))}
</div>