feat: change question type (#2646)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2024-06-03 14:23:12 +05:30
committed by GitHub
parent a5f6ecb992
commit 2bf04e9818
6 changed files with 242 additions and 80 deletions

View File

@@ -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}>
<div
{...listeners}
{...attributes}
@@ -186,33 +172,7 @@ export const QuestionCard = ({
<div>
<div className="inline-flex">
<div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
{question.type === TSurveyQuestionType.FileUpload ? (
<ArrowUpFromLineIcon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.OpenText ? (
<MessageSquareTextIcon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? (
<Rows3Icon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? (
<ListIcon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.NPS ? (
<PresentationIcon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.CTA ? (
<MousePointerClickIcon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.Rating ? (
<StarIcon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.Consent ? (
<CheckIcon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.PictureSelection ? (
<ImageIcon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.Date ? (
<CalendarDaysIcon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.Cal ? (
<PhoneIcon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.Matrix ? (
<Grid3X3Icon className="h-5 w-5" />
) : question.type === TSurveyQuestionType.Address ? (
<HomeIcon className="h-5 w-5" />
) : null}
{QUESTIONS_ICON_MAP[question.type]}
</div>
<div>
<p className="text-sm font-semibold">
@@ -241,12 +201,16 @@ export const QuestionCard = ({
</div>
<div className="flex items-center space-x-2">
<QuestionDropdown
<QuestionMenu
questionIdx={questionIdx}
lastQuestion={lastQuestion}
duplicateQuestion={duplicateQuestion}
deleteQuestion={deleteQuestion}
moveQuestion={moveQuestion}
question={question}
product={product}
updateQuestion={updateQuestion}
addQuestion={addQuestion}
/>
</div>
</div>

View File

@@ -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 (
<div className="flex space-x-2">
<ArrowUpIcon
className={`h-4 cursor-pointer text-slate-500 hover:text-slate-600 ${
questionIdx === 0 ? "opacity-50" : ""
}`}
onClick={(e) => {
if (questionIdx !== 0) {
e.stopPropagation();
moveQuestion(questionIdx, true);
}
}}
/>
<ArrowDownIcon
className={`h-4 cursor-pointer text-slate-500 hover:text-slate-600 ${
lastQuestion ? "opacity-50" : ""
}`}
onClick={(e) => {
if (!lastQuestion) {
e.stopPropagation();
moveQuestion(questionIdx, false);
}
}}
/>
<CopyIcon
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
onClick={(e) => {
@@ -55,6 +119,114 @@ export const QuestionDropdown = ({
deleteQuestion(questionIdx);
}}
/>
<DropdownMenu>
<DropdownMenuTrigger>
<EllipsisIcon className="h-4 w-4 text-slate-500 hover:text-slate-600" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="flex flex-col">
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div className="cursor-pointer text-slate-500 hover:text-slate-600">
<span className="text-xs text-slate-500">Change question type</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="ml-4 border border-slate-200">
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
if (type === question.type) return null;
return (
<DropdownMenuItem
key={type}
className="min-h-8 cursor-pointer text-slate-500"
onClick={() => {
setChangeToType(type as TSurveyQuestionType);
if (question.logic) {
setLogicWarningModal(true);
return;
}
changeQuestionType(type as TSurveyQuestionType);
}}>
{QUESTIONS_ICON_MAP[type as TSurveyQuestionType]}
<span className="ml-2">{name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div className="cursor-pointer text-slate-500 hover:text-slate-600">
<span className="text-xs text-slate-500">Add question below</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="ml-4 border border-slate-200">
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
if (type === question.type) return null;
return (
<DropdownMenuItem
key={type}
className="min-h-8 cursor-pointer text-slate-500"
onClick={(e) => {
e.stopPropagation();
addQuestionBelow(type as TSurveyQuestionType);
}}>
{QUESTIONS_ICON_MAP[type as TSurveyQuestionType]}
<span className="ml-2">{name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
className={`flex min-h-8 cursor-pointer justify-between text-slate-500 hover:text-slate-600 ${
questionIdx === 0 ? "opacity-50" : ""
}`}
onClick={(e) => {
if (questionIdx !== 0) {
e.stopPropagation();
moveQuestion(questionIdx, true);
}
}}
disabled={questionIdx === 0}>
<span className="text-xs text-slate-500">Move up</span>
<ArrowUpIcon className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
className={`flex min-h-8 cursor-pointer justify-between text-slate-500 hover:text-slate-600 ${
lastQuestion ? "opacity-50" : ""
}`}
onClick={(e) => {
if (!lastQuestion) {
e.stopPropagation();
moveQuestion(questionIdx, false);
}
}}
disabled={lastQuestion}>
<span className="text-xs text-slate-500">Move down</span>
<ArrowDownIcon className="h-4 w-4" />
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmationModal
open={logicWarningModal}
setOpen={setLogicWarningModal}
title="Changing will cause logic errors"
text="Changing the question type will remove the logic conditions from this question"
buttonText="Change anyway"
onConfirm={onConfirm}
buttonVariant="darkCTA"
/>
</div>
);
};

View File

@@ -20,6 +20,7 @@ interface QuestionsDraggableProps {
invalidQuestions: string[] | null;
internalQuestionIdMap: Record<string, string>;
attributeClasses: TAttributeClass[];
addQuestion: (question: any, index?: number) => void;
}
export const QuestionsDroppable = ({
@@ -36,6 +37,7 @@ export const QuestionsDroppable = ({
updateQuestion,
internalQuestionIdMap,
attributeClasses,
addQuestion,
}: QuestionsDraggableProps) => {
return (
<div className="group mb-5 grid w-full gap-5">
@@ -58,6 +60,7 @@ export const QuestionsDroppable = ({
lastQuestion={questionIdx === localSurvey.questions.length - 1}
isInvalid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
attributeClasses={attributeClasses}
addQuestion={addQuestion}
/>
))}
</SortableContext>

View File

@@ -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}
/>
</DndContext>

View File

@@ -143,7 +143,7 @@ export const SurveyEditor = ({
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none" ref={surveyEditorRef}>
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none" ref={surveyEditorRef}>
<QuestionsAudienceTabs
activeId={activeView}
setActiveId={setActiveView}

View File

@@ -1,6 +1,6 @@
import { createId } from "@paralleldrive/cuid2";
import {
ArrowUpFromLine,
ArrowUpFromLineIcon,
CalendarDaysIcon,
CheckIcon,
Grid3X3Icon,
@@ -28,12 +28,13 @@ import {
TSurveyNPSQuestion,
TSurveyOpenTextQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestionType,
TSurveyRatingQuestion,
} from "@formbricks/types/surveys";
import { replaceQuestionPresetPlaceholders } from "./templates";
export type TSurveyQuestionType = {
export type TQuestion = {
id: string;
label: string;
description: string;
@@ -41,7 +42,7 @@ export type TSurveyQuestionType = {
preset: any;
};
export const questionTypes: TSurveyQuestionType[] = [
export const questionTypes: TQuestion[] = [
{
id: QuestionId.OpenText,
label: "Free text",
@@ -172,7 +173,7 @@ export const questionTypes: TSurveyQuestionType[] = [
id: QuestionId.FileUpload,
label: "File Upload",
description: "Allow respondents to upload a file",
icon: ArrowUpFromLine,
icon: ArrowUpFromLineIcon,
preset: {
headline: { default: "File Upload" },
allowMultipleFiles: false,
@@ -217,6 +218,22 @@ export const questionTypes: TSurveyQuestionType[] = [
},
];
export const QUESTIONS_ICON_MAP = questionTypes.reduce(
(prev, curr) => ({
...prev,
[curr.id]: <curr.icon className="h-5 w-5" />,
}),
{}
);
export const QUESTIONS_NAME_MAP = questionTypes.reduce(
(prev, curr) => ({
...prev,
[curr.id]: curr.label,
}),
{}
) as Record<TSurveyQuestionType, string>;
export const universalQuestionPresets = {
required: true,
};