mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 16:30:21 -06:00
feat: change question type (#2646)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user