mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 10:08:34 -06:00
feat: replaces dnd with dnd-kit (#2564)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
@@ -19,7 +20,7 @@ import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
import { SelectQuestionChoice } from "./SelectQuestionChoice";
|
||||
|
||||
interface OpenQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -237,74 +238,56 @@ export const MultipleChoiceQuestionForm = ({
|
||||
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="choices">Options</Label>
|
||||
<div className="mt-2 -space-y-2" id="choices">
|
||||
{question.choices &&
|
||||
question.choices.map((choice, choiceIdx) => (
|
||||
<div className="inline-flex w-full items-center">
|
||||
<div className="flex w-full space-x-2">
|
||||
<QuestionFormInput
|
||||
key={choice.id}
|
||||
id={`choice-${choiceIdx}`}
|
||||
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={choice.label}
|
||||
onBlur={() => {
|
||||
const duplicateLabel = findDuplicateLabel();
|
||||
if (duplicateLabel) {
|
||||
toast.error("Duplicate choices");
|
||||
setisInvalidValue(duplicateLabel);
|
||||
} else {
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
}}
|
||||
updateChoice={updateChoice}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid &&
|
||||
!isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguages)
|
||||
}
|
||||
className={`${choice.id === "other" ? "border border-dashed" : ""}`}
|
||||
/>
|
||||
{choice.id === "other" && (
|
||||
<QuestionFormInput
|
||||
id="otherOptionPlaceholder"
|
||||
localSurvey={localSurvey}
|
||||
placeholder={"Please specify"}
|
||||
<div className="mt-2" id="choices">
|
||||
<DndContext
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active.id === "other" || over?.id === "other") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active || !over) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIndex = question.choices.findIndex((choice) => choice.id === active.id);
|
||||
const overIndex = question.choices.findIndex((choice) => choice.id === over.id);
|
||||
|
||||
const newChoices = [...question.choices];
|
||||
|
||||
newChoices.splice(activeIndex, 1);
|
||||
newChoices.splice(overIndex, 0, question.choices[activeIndex]);
|
||||
|
||||
updateQuestion(questionIdx, { choices: newChoices });
|
||||
}}>
|
||||
<SortableContext items={question.choices} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col">
|
||||
{question.choices &&
|
||||
question.choices.map((choice, choiceIdx) => (
|
||||
<SelectQuestionChoice
|
||||
key={choice.id}
|
||||
choice={choice}
|
||||
choiceIdx={choiceIdx}
|
||||
questionIdx={questionIdx}
|
||||
value={
|
||||
question.otherOptionPlaceholder
|
||||
? question.otherOptionPlaceholder
|
||||
: createI18nString("Please specify", surveyLanguageCodes)
|
||||
}
|
||||
updateQuestion={updateQuestion}
|
||||
updateChoice={updateChoice}
|
||||
deleteChoice={deleteChoice}
|
||||
addChoice={addChoice}
|
||||
setisInvalidValue={setisInvalidValue}
|
||||
isInvalid={isInvalid}
|
||||
localSurvey={localSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid &&
|
||||
!isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguages)
|
||||
}
|
||||
className="border border-dashed"
|
||||
surveyLanguages={surveyLanguages}
|
||||
findDuplicateLabel={findDuplicateLabel}
|
||||
question={question}
|
||||
updateQuestion={updateQuestion}
|
||||
surveyLanguageCodes={surveyLanguageCodes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{question.choices && question.choices.length > 2 && (
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => deleteChoice(choiceIdx)}
|
||||
/>
|
||||
)}
|
||||
<div className="ml-2 h-4 w-4">
|
||||
{choice.id !== "other" && (
|
||||
<PlusIcon
|
||||
className="h-full w-full cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => addChoice(choiceIdx)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
{question.choices.filter((c) => c.id === "other").length === 0 && (
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => addOther()}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { 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,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
Grid3X3Icon,
|
||||
GripIcon,
|
||||
HomeIcon,
|
||||
ImageIcon,
|
||||
ListIcon,
|
||||
@@ -20,12 +23,11 @@ import {
|
||||
StarIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TI18nString, TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
@@ -48,6 +50,7 @@ import { RatingQuestionForm } from "./RatingQuestionForm";
|
||||
interface QuestionCardProps {
|
||||
localSurvey: TSurvey;
|
||||
product: TProduct;
|
||||
question: TSurveyQuestion;
|
||||
questionIdx: number;
|
||||
moveQuestion: (questionIndex: number, up: boolean) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
@@ -64,6 +67,7 @@ interface QuestionCardProps {
|
||||
export default function QuestionCard({
|
||||
localSurvey,
|
||||
product,
|
||||
question,
|
||||
questionIdx,
|
||||
moveQuestion,
|
||||
updateQuestion,
|
||||
@@ -76,7 +80,10 @@ export default function QuestionCard({
|
||||
setSelectedLanguageCode,
|
||||
isInvalid,
|
||||
}: QuestionCardProps) {
|
||||
const question = localSurvey.questions[questionIdx];
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: question.id,
|
||||
});
|
||||
|
||||
const open = activeQuestionId === question.id;
|
||||
const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0);
|
||||
|
||||
@@ -131,369 +138,374 @@ export default function QuestionCard({
|
||||
}
|
||||
};
|
||||
|
||||
const style = {
|
||||
transition: transition ?? "transform 100ms ease",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<Draggable draggableId={question.id} index={questionIdx}>
|
||||
{(provided) => (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"flex flex-row rounded-lg bg-white transition-all duration-300 ease-in-out"
|
||||
)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}>
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-700" : "bg-slate-400",
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
|
||||
isInvalid && "bg-red-400 hover:bg-red-600"
|
||||
)}>
|
||||
{questionIdx + 1}
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={() => {
|
||||
if (activeQuestionId !== question.id) {
|
||||
setActiveQuestionId(question.id);
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
}}
|
||||
className="flex-1 rounded-r-lg border border-slate-200">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className={cn(open ? "" : " ", "flex cursor-pointer justify-between p-4 hover:bg-slate-50")}>
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"flex flex-row rounded-lg bg-white transition-all duration-300 ease-in-out"
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={style}>
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
open ? "bg-slate-700" : "bg-slate-400",
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
|
||||
isInvalid && "bg-red-400 hover:bg-red-600",
|
||||
"flex flex-col items-center justify-between"
|
||||
)}>
|
||||
<span>{questionIdx + 1}</span>
|
||||
|
||||
<button className="hidden hover:cursor-move group-hover:block">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={() => {
|
||||
if (activeQuestionId !== question.id) {
|
||||
setActiveQuestionId(question.id);
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
}}
|
||||
className="flex-1 rounded-r-lg border border-slate-200">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className={cn(open ? "" : " ", "flex cursor-pointer justify-between p-4 hover:bg-slate-50")}>
|
||||
<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}
|
||||
</div>
|
||||
<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}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
{recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
]
|
||||
? formatTextWithSlashes(
|
||||
recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
] ?? ""
|
||||
)
|
||||
: getTSurveyQuestionTypeName(question.type)}
|
||||
</p>
|
||||
{!open && question?.required && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{question?.required && "Required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<QuestionDropdown
|
||||
questionIdx={questionIdx}
|
||||
lastQuestion={lastQuestion}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
deleteQuestion={deleteQuestion}
|
||||
moveQuestion={moveQuestion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-4">
|
||||
{question.type === TSurveyQuestionType.OpenText ? (
|
||||
<OpenQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.NPS ? (
|
||||
<NPSQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.CTA ? (
|
||||
<CTAQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Rating ? (
|
||||
<RatingQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Consent ? (
|
||||
<ConsentQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<DateQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
<PictureSelectionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.FileUpload ? (
|
||||
<FileUploadQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Cal ? (
|
||||
<CalQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Matrix ? (
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Address ? (
|
||||
<AddressQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">
|
||||
<Collapsible.CollapsibleTrigger className="flex items-center text-sm text-slate-700">
|
||||
{openAdvanced ? (
|
||||
<ChevronDownIcon className="mr-1 h-4 w-3" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mr-2 h-4 w-3" />
|
||||
)}
|
||||
{openAdvanced ? "Hide Advanced Settings" : "Show Advanced Settings"}
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
|
||||
<Collapsible.CollapsibleContent className="space-y-4">
|
||||
{question.type !== TSurveyQuestionType.NPS &&
|
||||
question.type !== TSurveyQuestionType.Rating &&
|
||||
question.type !== TSurveyQuestionType.CTA ? (
|
||||
<div className="mt-2 flex space-x-2">
|
||||
<div className="w-full">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={lastQuestion ? "Finish" : "Next"}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
onBlur={(e) => {
|
||||
if (!question.buttonLabel) return;
|
||||
let translatedNextButtonLabel = {
|
||||
...question.buttonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
|
||||
if (questionIdx === localSurvey.questions.length - 1) return;
|
||||
updateEmptyNextButtonLabels(translatedNextButtonLabel);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={"Back"}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{(question.type === TSurveyQuestionType.Rating ||
|
||||
question.type === TSurveyQuestionType.NPS) &&
|
||||
questionIdx !== 0 && (
|
||||
<div className="mt-4">
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={"Back"}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdvancedSettings
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
localSurvey={localSurvey}
|
||||
updateQuestion={updateQuestion}
|
||||
/>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
|
||||
{open && (
|
||||
<div className="mx-4 flex justify-end space-x-6 border-t border-slate-200">
|
||||
{question.type === "openText" && (
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="longAnswer">Long Answer</Label>
|
||||
<Switch
|
||||
id="longAnswer"
|
||||
disabled={question.inputType !== "text"}
|
||||
checked={question.longAnswer !== false}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuestion(questionIdx, {
|
||||
longAnswer:
|
||||
typeof question.longAnswer === "undefined" ? false : !question.longAnswer,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm font-semibold">
|
||||
{recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
]
|
||||
? formatTextWithSlashes(
|
||||
recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
] ?? ""
|
||||
)
|
||||
: getTSurveyQuestionTypeName(question.type)}
|
||||
</p>
|
||||
{!open && question?.required && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">{question?.required && "Required"}</p>
|
||||
)}
|
||||
{
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="required-toggle">Required</Label>
|
||||
<Switch
|
||||
id="required-toggle"
|
||||
checked={question.required}
|
||||
disabled={getIsRequiredToggleDisabled()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRequiredToggle();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<QuestionDropdown
|
||||
questionIdx={questionIdx}
|
||||
lastQuestion={lastQuestion}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
deleteQuestion={deleteQuestion}
|
||||
moveQuestion={moveQuestion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-4">
|
||||
{question.type === TSurveyQuestionType.OpenText ? (
|
||||
<OpenQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.NPS ? (
|
||||
<NPSQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.CTA ? (
|
||||
<CTAQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Rating ? (
|
||||
<RatingQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Consent ? (
|
||||
<ConsentQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<DateQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
<PictureSelectionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.FileUpload ? (
|
||||
<FileUploadQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Cal ? (
|
||||
<CalQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Matrix ? (
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Address ? (
|
||||
<AddressQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">
|
||||
<Collapsible.CollapsibleTrigger className="flex items-center text-sm text-slate-700">
|
||||
{openAdvanced ? (
|
||||
<ChevronDownIcon className="mr-1 h-4 w-3" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mr-2 h-4 w-3" />
|
||||
)}
|
||||
{openAdvanced ? "Hide Advanced Settings" : "Show Advanced Settings"}
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
|
||||
<Collapsible.CollapsibleContent className="space-y-4">
|
||||
{question.type !== TSurveyQuestionType.NPS &&
|
||||
question.type !== TSurveyQuestionType.Rating &&
|
||||
question.type !== TSurveyQuestionType.CTA ? (
|
||||
<div className="mt-2 flex space-x-2">
|
||||
<div className="w-full">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={lastQuestion ? "Finish" : "Next"}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
onBlur={(e) => {
|
||||
if (!question.buttonLabel) return;
|
||||
let translatedNextButtonLabel = {
|
||||
...question.buttonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
|
||||
if (questionIdx === localSurvey.questions.length - 1) return;
|
||||
updateEmptyNextButtonLabels(translatedNextButtonLabel);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={"Back"}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
) : null}
|
||||
{(question.type === TSurveyQuestionType.Rating ||
|
||||
question.type === TSurveyQuestionType.NPS) &&
|
||||
questionIdx !== 0 && (
|
||||
<div className="mt-4">
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={"Back"}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdvancedSettings
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
localSurvey={localSurvey}
|
||||
updateQuestion={updateQuestion}
|
||||
/>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
|
||||
{open && (
|
||||
<div className="mx-4 flex justify-end space-x-6 border-t border-slate-200">
|
||||
{question.type === "openText" && (
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="longAnswer">Long Answer</Label>
|
||||
<Switch
|
||||
id="longAnswer"
|
||||
disabled={question.inputType !== "text"}
|
||||
checked={question.longAnswer !== false}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuestion(questionIdx, {
|
||||
longAnswer: typeof question.longAnswer === "undefined" ? false : !question.longAnswer,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
{
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="required-toggle">Required</Label>
|
||||
<Switch
|
||||
id="required-toggle"
|
||||
checked={question.required}
|
||||
disabled={getIsRequiredToggleDisabled()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRequiredToggle();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import QuestionCard from "./QuestionCard";
|
||||
|
||||
interface QuestionsDraggableProps {
|
||||
localSurvey: TSurvey;
|
||||
product: TProduct;
|
||||
moveQuestion: (questionIndex: number, up: boolean) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
invalidQuestions: string[] | null;
|
||||
internalQuestionIdMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export const QuestionsDroppable = ({
|
||||
activeQuestionId,
|
||||
deleteQuestion,
|
||||
duplicateQuestion,
|
||||
invalidQuestions,
|
||||
localSurvey,
|
||||
moveQuestion,
|
||||
product,
|
||||
selectedLanguageCode,
|
||||
setActiveQuestionId,
|
||||
setSelectedLanguageCode,
|
||||
updateQuestion,
|
||||
internalQuestionIdMap,
|
||||
}: QuestionsDraggableProps) => {
|
||||
return (
|
||||
<div className="group mb-5 grid w-full gap-5">
|
||||
<SortableContext items={localSurvey.questions} strategy={verticalListSortingStrategy}>
|
||||
{localSurvey.questions.map((question, questionIdx) => (
|
||||
<QuestionCard
|
||||
key={internalQuestionIdMap[question.id]}
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
moveQuestion={moveQuestion}
|
||||
updateQuestion={updateQuestion}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
deleteQuestion={deleteQuestion}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
lastQuestion={questionIdx === localSurvey.questions.length - 1}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
closestCorners,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DragDropContext } from "react-beautiful-dnd";
|
||||
import React, { SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multiLanguage/components/MultiLanguageCard";
|
||||
@@ -17,12 +24,11 @@ import AddQuestionButton from "./AddQuestionButton";
|
||||
import EditThankYouCard from "./EditThankYouCard";
|
||||
import EditWelcomeCard from "./EditWelcomeCard";
|
||||
import HiddenFieldsCard from "./HiddenFieldsCard";
|
||||
import QuestionCard from "./QuestionCard";
|
||||
import { StrictModeDroppable } from "./StrictModeDroppable";
|
||||
import { QuestionsDroppable } from "./QuestionsDroppable";
|
||||
|
||||
interface QuestionsViewProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
setLocalSurvey: React.Dispatch<SetStateAction<TSurvey>>;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
product: TProduct;
|
||||
@@ -53,6 +59,7 @@ export const QuestionsView = ({
|
||||
return acc;
|
||||
}, {});
|
||||
}, [localSurvey.questions]);
|
||||
|
||||
const surveyLanguages = localSurvey.languages;
|
||||
const [backButtonLabel, setbackButtonLabel] = useState(null);
|
||||
const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => {
|
||||
@@ -211,17 +218,6 @@ export const QuestionsView = ({
|
||||
internalQuestionIdMap[question.id] = createId();
|
||||
};
|
||||
|
||||
const onDragEnd = (result) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
const [reorderedQuestion] = newQuestions.splice(result.source.index, 1);
|
||||
newQuestions.splice(result.destination.index, 0, reorderedQuestion);
|
||||
const updatedSurvey = { ...localSurvey, questions: newQuestions };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
const moveQuestion = (questionIndex: number, up: boolean) => {
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
const [reorderedQuestion] = newQuestions.splice(questionIndex, 1);
|
||||
@@ -303,6 +299,26 @@ export const QuestionsView = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeQuestionId, setActiveQuestionId]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
const sourceIndex = newQuestions.findIndex((question) => question.id === active.id);
|
||||
const destinationIndex = newQuestions.findIndex((question) => question.id === over?.id);
|
||||
const [reorderedQuestion] = newQuestions.splice(sourceIndex, 1);
|
||||
newQuestions.splice(destinationIndex, 0, reorderedQuestion);
|
||||
const updatedSurvey = { ...localSurvey, questions: newQuestions };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-16 px-5 py-4">
|
||||
<div className="mb-5 flex flex-col gap-5">
|
||||
@@ -316,36 +332,24 @@ export const QuestionsView = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
/>
|
||||
</div>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="mb-5 grid grid-cols-1 gap-5 ">
|
||||
<StrictModeDroppable droppableId="questionsList">
|
||||
{(provided) => (
|
||||
<div className="grid w-full gap-5" ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{localSurvey.questions.map((question, questionIdx) => (
|
||||
// display a question form
|
||||
<QuestionCard
|
||||
key={internalQuestionIdMap[question.id]}
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
questionIdx={questionIdx}
|
||||
moveQuestion={moveQuestion}
|
||||
updateQuestion={updateQuestion}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
deleteQuestion={deleteQuestion}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
lastQuestion={questionIdx === localSurvey.questions.length - 1}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
</div>
|
||||
</DragDropContext>
|
||||
|
||||
<DndContext sensors={sensors} onDragEnd={onDragEnd} collisionDetection={closestCorners}>
|
||||
<QuestionsDroppable
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
moveQuestion={moveQuestion}
|
||||
updateQuestion={updateQuestion}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
deleteQuestion={deleteQuestion}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
invalidQuestions={invalidQuestions}
|
||||
internalQuestionIdMap={internalQuestionIdMap}
|
||||
/>
|
||||
</DndContext>
|
||||
|
||||
<AddQuestionButton addQuestion={addQuestion} product={product} />
|
||||
<div className="mt-5 flex flex-col gap-5">
|
||||
<EditThankYouCard
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { createI18nString } from "@formbricks/lib/i18n/utils";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
} from "@formbricks/types/surveys";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface ChoiceProps {
|
||||
choice: {
|
||||
id: string;
|
||||
label: Record<string, string>;
|
||||
};
|
||||
choiceIdx: number;
|
||||
questionIdx: number;
|
||||
updateChoice: (choiceIdx: number, updatedAttributes: { label: TI18nString }) => void;
|
||||
deleteChoice: (choiceIdx: number) => void;
|
||||
addChoice: (choiceIdx: number) => void;
|
||||
setisInvalidValue: (value: string | null) => void;
|
||||
isInvalid: boolean;
|
||||
localSurvey: TSurvey;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
surveyLanguages: TSurveyLanguage[];
|
||||
findDuplicateLabel: () => string | null;
|
||||
question: TSurveyMultipleChoiceQuestion;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMultipleChoiceQuestion>) => void;
|
||||
surveyLanguageCodes: string[];
|
||||
}
|
||||
|
||||
export const SelectQuestionChoice = ({
|
||||
addChoice,
|
||||
choice,
|
||||
choiceIdx,
|
||||
deleteChoice,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
questionIdx,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
setisInvalidValue,
|
||||
surveyLanguages,
|
||||
updateChoice,
|
||||
findDuplicateLabel,
|
||||
question,
|
||||
surveyLanguageCodes,
|
||||
updateQuestion,
|
||||
}: ChoiceProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: choice.id,
|
||||
disabled: choice.id === "other",
|
||||
});
|
||||
|
||||
const style = {
|
||||
transition: transition ?? "transform 100ms ease",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2" ref={setNodeRef} style={style}>
|
||||
{/* drag handle */}
|
||||
<div
|
||||
className={cn("flex items-center", choice.id === "other" && "invisible")}
|
||||
{...listeners}
|
||||
{...attributes}>
|
||||
<GripVerticalIcon className="mt-3 h-4 w-4 cursor-move text-slate-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full space-x-2">
|
||||
<QuestionFormInput
|
||||
key={choice.id}
|
||||
id={`choice-${choiceIdx}`}
|
||||
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={choice.label}
|
||||
onBlur={() => {
|
||||
const duplicateLabel = findDuplicateLabel();
|
||||
if (duplicateLabel) {
|
||||
toast.error("Duplicate choices");
|
||||
setisInvalidValue(duplicateLabel);
|
||||
} else {
|
||||
setisInvalidValue(null);
|
||||
}
|
||||
}}
|
||||
updateChoice={updateChoice}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid && !isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguages)
|
||||
}
|
||||
className={`${choice.id === "other" ? "border border-dashed" : ""} mt-0`}
|
||||
/>
|
||||
{choice.id === "other" && (
|
||||
<QuestionFormInput
|
||||
id="otherOptionPlaceholder"
|
||||
localSurvey={localSurvey}
|
||||
placeholder={"Please specify"}
|
||||
questionIdx={questionIdx}
|
||||
value={
|
||||
question.otherOptionPlaceholder
|
||||
? question.otherOptionPlaceholder
|
||||
: createI18nString("Please specify", surveyLanguageCodes)
|
||||
}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid && !isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguages)
|
||||
}
|
||||
className="border border-dashed"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{question.choices && question.choices.length > 2 && (
|
||||
<TrashIcon
|
||||
className="h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => deleteChoice(choiceIdx)}
|
||||
/>
|
||||
)}
|
||||
<div className="h-4 w-4">
|
||||
{choice.id !== "other" && (
|
||||
<PlusIcon
|
||||
className="h-full w-full cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => addChoice(choiceIdx)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Droppable, DroppableProps } from "react-beautiful-dnd";
|
||||
|
||||
// React beautiful DnD currently not working with React 18 Strict Mode
|
||||
|
||||
export const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
const animation = requestAnimationFrame(() => setEnabled(true));
|
||||
return () => {
|
||||
cancelAnimationFrame(animation);
|
||||
setEnabled(false);
|
||||
};
|
||||
}, []);
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
return <Droppable {...props}>{children}</Droppable>;
|
||||
};
|
||||
@@ -12,9 +12,12 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@formbricks/api": "workspace:*",
|
||||
"@formbricks/database": "workspace:*",
|
||||
"@formbricks/ee": "workspace:*",
|
||||
"@formbricks/email": "workspace:*",
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/js-core": "workspace:*",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
@@ -22,7 +25,6 @@
|
||||
"@formbricks/tailwind-config": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@formbricks/email": "workspace:*",
|
||||
"@headlessui/react": "^2.0.3",
|
||||
"@json2csv/node": "^7.0.6",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.46.0",
|
||||
@@ -55,7 +57,6 @@
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "18.3.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.51.4",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
|
||||
@@ -284,7 +284,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.getByLabel("Headline").fill(surveys.germanCreate.welcomeCard.headline);
|
||||
|
||||
// Fill Open text question in german
|
||||
await page.getByRole("button", { name: "Free text Required" }).click();
|
||||
await page.getByRole("main").getByText("Free text").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
@@ -297,7 +297,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.getByLabel("Placeholder").fill(surveys.germanCreate.openTextQuestion.placeholder);
|
||||
|
||||
// Fill Single select question in german
|
||||
await page.getByRole("button", { name: "Single-Select Required" }).click();
|
||||
await page.getByRole("main").getByText("Single-Select").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
@@ -312,7 +312,8 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.getByPlaceholder("Option 2").fill(surveys.germanCreate.singleSelectQuestion.options[1]);
|
||||
|
||||
// Fill Multi select question in german
|
||||
await page.getByRole("button", { name: "Multi-Select Required" }).click();
|
||||
await page.getByRole("main").getByText("Multi-Select").click();
|
||||
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
@@ -325,7 +326,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.getByPlaceholder("Option 3").fill(surveys.germanCreate.multiSelectQuestion.options[2]);
|
||||
|
||||
// Fill Picture select question in german
|
||||
await page.getByRole("button", { name: "Picture Selection Required" }).click();
|
||||
await page.getByRole("main").getByText("Picture Selection").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
@@ -336,7 +337,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
.fill(surveys.germanCreate.pictureSelectQuestion.description);
|
||||
|
||||
// Fill Rating question in german
|
||||
await page.getByRole("button", { name: "Rating Required" }).click();
|
||||
await page.getByRole("main").getByText("Rating").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
@@ -351,7 +352,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.getByPlaceholder("Very satisfied").fill(surveys.germanCreate.ratingQuestion.highLabel);
|
||||
|
||||
// Fill NPS question in german
|
||||
await page.getByRole("button", { name: "Net Promoter Score (NPS) Required" }).click();
|
||||
await page.getByRole("main").getByText("Net Promoter Score (NPS)").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.npsQuestion.question);
|
||||
await page.getByLabel("Lower Label").click();
|
||||
@@ -360,21 +361,21 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.getByLabel("Upper Label").fill(surveys.germanCreate.npsQuestion.highLabel);
|
||||
|
||||
// Fill Date question in german
|
||||
await page.getByRole("button", { name: "Date Required" }).click();
|
||||
await page.getByRole("main").getByText("Date").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.dateQuestion.question);
|
||||
|
||||
// Fill File upload question in german
|
||||
await page.getByRole("button", { name: "File Upload Required" }).click();
|
||||
await page.getByRole("main").getByText("File Upload").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
.fill(surveys.germanCreate.fileUploadQuestion.question);
|
||||
|
||||
// Fill Matrix question in german
|
||||
await page.getByRole("button", { name: "9 Matrix" }).click();
|
||||
await page.getByRole("main").getByText("Matrix").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.matrix.question);
|
||||
await page.getByPlaceholder("Your description here. Recall").click();
|
||||
@@ -397,7 +398,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
await page.locator("#column-3").fill(surveys.germanCreate.matrix.columns[3]);
|
||||
|
||||
// Fill Address question in german
|
||||
await page.getByRole("button", { name: "Address Required" }).click();
|
||||
await page.getByRole("main").getByText("Address").click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
|
||||
@@ -145,11 +145,13 @@ export const createSurvey = async (
|
||||
await page.getByText("Welcome CardOn").click();
|
||||
|
||||
// Open Text Question
|
||||
await page.getByRole("button", { name: "1 What would you like to know" }).click();
|
||||
await page.getByRole("main").getByText("What would you like to know?").click();
|
||||
|
||||
await page.getByLabel("Question").fill(params.openTextQuestion.question);
|
||||
await page.getByLabel("Description").fill(params.openTextQuestion.description);
|
||||
await page.getByLabel("Placeholder").fill(params.openTextQuestion.placeholder);
|
||||
await page.getByRole("button", { name: params.openTextQuestion.question }).click();
|
||||
|
||||
await page.locator("p").filter({ hasText: params.openTextQuestion.question }).click();
|
||||
|
||||
// Single Select Question
|
||||
await page
|
||||
|
||||
@@ -382,9 +382,10 @@ export const QuestionFormInput = ({
|
||||
<div className="w-full">
|
||||
<div className="w-full">
|
||||
<div className="mb-2 mt-3">
|
||||
<Label htmlFor={id}>{label ?? getLabelById(id)}</Label>
|
||||
<Label htmlFor={id}>{getLabelById(id)}</Label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
<div className="flex flex-col gap-4 bg-white">
|
||||
{showImageUploader && id === "headline" && (
|
||||
<FileInput
|
||||
id="question-image"
|
||||
|
||||
1315
pnpm-lock.yaml
generated
1315
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user