diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceMultiForm.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceMultiForm.tsx index e3a175320d..cfcf479e4e 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceMultiForm.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceMultiForm.tsx @@ -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({ )} ))} - +
+ + {question.choices.filter((c) => c.id === "other").length === 0 && ( + <> +

or

+ + + )} +
diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceSingleForm.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceSingleForm.tsx index fcc4288024..515a34c7d8 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceSingleForm.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceSingleForm.tsx @@ -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({ )} ))} - +
+ + {question.choices.filter((c) => c.id === "other").length === 0 && ( + <> +

or

+ + + )} +
diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/MultipleChoiceSummary.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/MultipleChoiceSummary.tsx index cd2da50b61..8cf8b1b5fa 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/MultipleChoiceSummary.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/MultipleChoiceSummary.tsx @@ -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; + 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

+ {result.otherValues.length > 0 && ( +
+
+
Specified "Other" answers
+
{surveyType === "web" && "User"}
+
+ {result.otherValues + .filter((otherValue) => otherValue !== "") + .map((otherValue, idx) => ( +
+ {surveyType === "link" && ( +
+ {otherValue.value} +
+ )} + {surveyType === "web" && ( + +
+ {otherValue.value} +
+
+ {otherValue.person.id && } + {otherValue.person.email} +
+ + )} +
+ ))} +
+ )} ))} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx index 57fd3ee007..66844733ba 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx @@ -90,6 +90,8 @@ export default function SummaryList({ environmentId, surveyId }) { MultipleChoiceMultiQuestion | MultipleChoiceSingleQuestion > } + environmentId={environmentId} + surveyType={survey.type} /> ); } diff --git a/apps/web/components/preview/MultipleChoiceMultiQuestion.tsx b/apps/web/components/preview/MultipleChoiceMultiQuestion.tsx index 3dcf407087..dffe7e9063 100644 --- a/apps/web/components/preview/MultipleChoiceMultiQuestion.tsx +++ b/apps/web/components/preview/MultipleChoiceMultiQuestion.tsx @@ -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([]); 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 (
{ 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(""); }}> @@ -48,39 +59,64 @@ export default function MultipleChoiceMultiQuestion({
{question.choices && question.choices.map((choice) => ( - + + ))}
diff --git a/apps/web/components/preview/MultipleChoiceSingleQuestion.tsx b/apps/web/components/preview/MultipleChoiceSingleQuestion.tsx index ebcb4f4f8c..c06aae7a5e 100644 --- a/apps/web/components/preview/MultipleChoiceSingleQuestion.tsx +++ b/apps/web/components/preview/MultipleChoiceSingleQuestion.tsx @@ -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(null); + const otherSpecify = useRef(null); + return ( { 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} + {choice.id === "other" && selectedChoice === "other" && ( + + )} ))} diff --git a/packages/js/src/components/MultipleChoiceMultiQuestion.tsx b/packages/js/src/components/MultipleChoiceMultiQuestion.tsx index 8d119ef55c..2dd1f5f55e 100644 --- a/packages/js/src/components/MultipleChoiceMultiQuestion.tsx +++ b/packages/js/src/components/MultipleChoiceMultiQuestion.tsx @@ -20,16 +20,25 @@ export default function MultipleChoiceMultiQuestion({ brandColor, }: MultipleChoiceMultiProps) { const [selectedChoices, setSelectedChoices] = useState([]); + const [showOther, setShowOther] = useState(false); + const [otherSpecified, setOtherSpecified] = useState(""); const isAtLeastOneChecked = () => { - return selectedChoices.length > 0; + return selectedChoices.length > 0 || otherSpecified.length > 0; }; return ( { 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(""); }}> @@ -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" )}> { + 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 }} /> {choice.label} + {choice.id === "other" && showOther && ( + setOtherSpecified(e.currentTarget.value)} + aria-labelledby={`${choice.id}-label`} + required={question.required} + /> + )} ))} diff --git a/packages/js/src/components/MultipleChoiceSingleQuestion.tsx b/packages/js/src/components/MultipleChoiceSingleQuestion.tsx index 19eb09282a..e69717015a 100644 --- a/packages/js/src/components/MultipleChoiceSingleQuestion.tsx +++ b/packages/js/src/components/MultipleChoiceSingleQuestion.tsx @@ -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(null); + const otherSpecify = useRef(null); + return ( { 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} + {choice.id === "other" && selectedChoice === "other" && ( + + )} ))}