From 2bf04e98189d796b9da2fdfd7675da76df5c2af2 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Mon, 3 Jun 2024 14:23:12 +0530 Subject: [PATCH] feat: change question type (#2646) Co-authored-by: Matthias Nannt --- .../edit/components/QuestionCard.tsx | 62 ++--- .../edit/components/QuestionMenu.tsx | 220 ++++++++++++++++-- .../edit/components/QuestionsDroppable.tsx | 3 + .../edit/components/QuestionsView.tsx | 10 +- .../edit/components/SurveyEditor.tsx | 2 +- .../app/lib/{questions.ts => questions.tsx} | 25 +- 6 files changed, 242 insertions(+), 80 deletions(-) rename apps/web/app/lib/{questions.ts => questions.tsx} (93%) diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx index a0c6e7f484..6524a74430 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx @@ -1,27 +1,10 @@ "use client"; -import { getTSurveyQuestionTypeName } from "@/app/lib/questions"; +import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeName } from "@/app/lib/questions"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import * as Collapsible from "@radix-ui/react-collapsible"; -import { - ArrowUpFromLineIcon, - CalendarDaysIcon, - CheckIcon, - ChevronDownIcon, - ChevronRightIcon, - Grid3X3Icon, - GripIcon, - HomeIcon, - ImageIcon, - ListIcon, - MessageSquareTextIcon, - MousePointerClickIcon, - PhoneIcon, - PresentationIcon, - Rows3Icon, - StarIcon, -} from "lucide-react"; +import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react"; import { useState } from "react"; import { cn } from "@formbricks/lib/cn"; @@ -45,7 +28,7 @@ import { MultipleChoiceQuestionForm } from "./MultipleChoiceQuestionForm"; import { NPSQuestionForm } from "./NPSQuestionForm"; import { OpenQuestionForm } from "./OpenQuestionForm"; import { PictureSelectionForm } from "./PictureSelectionForm"; -import { QuestionDropdown } from "./QuestionMenu"; +import { QuestionMenu } from "./QuestionMenu"; import { RatingQuestionForm } from "./RatingQuestionForm"; interface QuestionCardProps { @@ -64,6 +47,7 @@ interface QuestionCardProps { setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; attributeClasses: TAttributeClass[]; + addQuestion: (question: any, index?: number) => void; } export const QuestionCard = ({ @@ -82,6 +66,7 @@ export const QuestionCard = ({ setSelectedLanguageCode, isInvalid, attributeClasses, + addQuestion, }: QuestionCardProps) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: question.id, @@ -154,7 +139,8 @@ export const QuestionCard = ({ "flex flex-row rounded-lg bg-white transition-all duration-300 ease-in-out" )} ref={setNodeRef} - style={style}> + style={style} + id={question.id}>
- {question.type === TSurveyQuestionType.FileUpload ? ( - - ) : question.type === TSurveyQuestionType.OpenText ? ( - - ) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? ( - - ) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? ( - - ) : question.type === TSurveyQuestionType.NPS ? ( - - ) : question.type === TSurveyQuestionType.CTA ? ( - - ) : question.type === TSurveyQuestionType.Rating ? ( - - ) : question.type === TSurveyQuestionType.Consent ? ( - - ) : question.type === TSurveyQuestionType.PictureSelection ? ( - - ) : question.type === TSurveyQuestionType.Date ? ( - - ) : question.type === TSurveyQuestionType.Cal ? ( - - ) : question.type === TSurveyQuestionType.Matrix ? ( - - ) : question.type === TSurveyQuestionType.Address ? ( - - ) : null} + {QUESTIONS_ICON_MAP[question.type]}

@@ -241,12 +201,16 @@ export const QuestionCard = ({

-
diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionMenu.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionMenu.tsx index bf0dfb922f..17cbff84d7 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionMenu.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionMenu.tsx @@ -1,6 +1,22 @@ "use client"; -import { ArrowDownIcon, ArrowUpIcon, CopyIcon, TrashIcon } from "lucide-react"; +import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions"; +import { createId } from "@paralleldrive/cuid2"; +import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react"; +import React, { useState } from "react"; + +import { TProduct } from "@formbricks/types/product"; +import { TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys"; +import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@formbricks/ui/DropdownMenu"; interface QuestionDropdownProps { questionIdx: number; @@ -8,39 +24,87 @@ interface QuestionDropdownProps { duplicateQuestion: (questionIdx: number) => void; deleteQuestion: (questionIdx: number) => void; moveQuestion: (questionIdx: number, up: boolean) => void; + question: TSurveyQuestion; + product: TProduct; + updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + addQuestion: (question: any, index?: number) => void; } -export const QuestionDropdown = ({ +export const QuestionMenu = ({ questionIdx, lastQuestion, duplicateQuestion, deleteQuestion, moveQuestion, + product, + question, + updateQuestion, + addQuestion, }: QuestionDropdownProps) => { + const [logicWarningModal, setLogicWarningModal] = useState(false); + const [changeToType, setChangeToType] = useState(question.type); + + const changeQuestionType = (type: TSurveyQuestionType) => { + const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question; + + const questionDefaults = getQuestionDefaults(type, product); + + // if going from single select to multi select or vice versa, we need to keep the choices as well + + if ( + (type === TSurveyQuestionType.MultipleChoiceSingle && + question.type === TSurveyQuestionType.MultipleChoiceMulti) || + (type === TSurveyQuestionType.MultipleChoiceMulti && + question.type === TSurveyQuestionType.MultipleChoiceSingle) + ) { + updateQuestion(questionIdx, { + choices: question.choices, + type, + logic: undefined, + }); + + return; + } + + updateQuestion(questionIdx, { + ...questionDefaults, + type, + headline, + subheader, + required, + imageUrl, + videoUrl, + buttonLabel, + backButtonLabel, + logic: undefined, + }); + }; + + const addQuestionBelow = (type: TSurveyQuestionType) => { + const questionDefaults = getQuestionDefaults(type, product); + + addQuestion( + { + ...questionDefaults, + type, + id: createId(), + required: true, + }, + questionIdx + 1 + ); + + // scroll to the new question + const section = document.getElementById(`${question.id}`); + section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" }); + }; + + const onConfirm = () => { + changeQuestionType(changeToType); + setLogicWarningModal(false); + }; + return (
- { - if (questionIdx !== 0) { - e.stopPropagation(); - moveQuestion(questionIdx, true); - } - }} - /> - { - if (!lastQuestion) { - e.stopPropagation(); - moveQuestion(questionIdx, false); - } - }} - /> { @@ -55,6 +119,114 @@ export const QuestionDropdown = ({ deleteQuestion(questionIdx); }} /> + + + + + + + +
+ + +
+ Change question type +
+
+ + + {Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => { + if (type === question.type) return null; + + return ( + { + setChangeToType(type as TSurveyQuestionType); + if (question.logic) { + setLogicWarningModal(true); + return; + } + + changeQuestionType(type as TSurveyQuestionType); + }}> + {QUESTIONS_ICON_MAP[type as TSurveyQuestionType]} + {name} + + ); + })} + +
+ + + +
+ Add question below +
+
+ + + {Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => { + if (type === question.type) return null; + + return ( + { + e.stopPropagation(); + addQuestionBelow(type as TSurveyQuestionType); + }}> + {QUESTIONS_ICON_MAP[type as TSurveyQuestionType]} + {name} + + ); + })} + +
+ { + if (questionIdx !== 0) { + e.stopPropagation(); + moveQuestion(questionIdx, true); + } + }} + disabled={questionIdx === 0}> + Move up + + + + { + if (!lastQuestion) { + e.stopPropagation(); + moveQuestion(questionIdx, false); + } + }} + disabled={lastQuestion}> + Move down + + +
+
+
+ +
); }; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsDroppable.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsDroppable.tsx index 47a3a3310e..56834cf58e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsDroppable.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsDroppable.tsx @@ -20,6 +20,7 @@ interface QuestionsDraggableProps { invalidQuestions: string[] | null; internalQuestionIdMap: Record; attributeClasses: TAttributeClass[]; + addQuestion: (question: any, index?: number) => void; } export const QuestionsDroppable = ({ @@ -36,6 +37,7 @@ export const QuestionsDroppable = ({ updateQuestion, internalQuestionIdMap, attributeClasses, + addQuestion, }: QuestionsDraggableProps) => { return (
@@ -58,6 +60,7 @@ export const QuestionsDroppable = ({ lastQuestion={questionIdx === localSurvey.questions.length - 1} isInvalid={invalidQuestions ? invalidQuestions.includes(question.id) : false} attributeClasses={attributeClasses} + addQuestion={addQuestion} /> ))} diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx index ffb6aa9be5..15e2f1d188 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx @@ -216,14 +216,19 @@ export const QuestionsView = ({ toast.success("Question duplicated."); }; - const addQuestion = (question: any) => { + const addQuestion = (question: any, index?: number) => { const updatedSurvey = { ...localSurvey }; if (backButtonLabel) { question.backButtonLabel = backButtonLabel; } const languageSymbols = extractLanguageCodes(localSurvey.languages); const translatedQuestion = translateQuestion(question, languageSymbols); - updatedSurvey.questions.push({ ...translatedQuestion, isDraft: true }); + + if (index) { + updatedSurvey.questions.splice(index, 0, { ...translatedQuestion, isDraft: true }); + } else { + updatedSurvey.questions.push({ ...translatedQuestion, isDraft: true }); + } setLocalSurvey(updatedSurvey); setActiveQuestionId(question.id); @@ -361,6 +366,7 @@ export const QuestionsView = ({ invalidQuestions={invalidQuestions} internalQuestionIdMap={internalQuestionIdMap} attributeClasses={attributeClasses} + addQuestion={addQuestion} /> diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx index 421af51fd0..18045dfff5 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyEditor.tsx @@ -143,7 +143,7 @@ export const SurveyEditor = ({ setSelectedLanguageCode={setSelectedLanguageCode} />
-
+
({ + ...prev, + [curr.id]: , + }), + {} +); + +export const QUESTIONS_NAME_MAP = questionTypes.reduce( + (prev, curr) => ({ + ...prev, + [curr.id]: curr.label, + }), + {} +) as Record; + export const universalQuestionPresets = { required: true, };