833 lines
29 KiB
TypeScript
833 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
import dynamic from "next/dynamic";
|
|
import { useRouter } from "next/navigation";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { toast } from "sonner";
|
|
import { updateForm } from "@/app/admin/form/actions";
|
|
import {
|
|
createQuestion,
|
|
updateQuestion,
|
|
deleteQuestion,
|
|
bulkUpdateQuestions,
|
|
} from "@/app/actions/questions";
|
|
import { Plus, Trash, ArrowUp, ArrowDown, GripVertical, Loader2, Image as ImageIcon, X } from "lucide-react";
|
|
import Image from 'next/image';
|
|
import { format, parseISO } from "date-fns";
|
|
import { DateTimePicker } from "@/components/date-time-picker";
|
|
import { getErrorMessage } from "@/lib/utils";
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
} from "@/components/ui/accordion";
|
|
import {
|
|
DndContext,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
closestCenter,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
arrayMove,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import {
|
|
useDebouncedAutoSave,
|
|
} from "@/hooks/use-debounced-autosave";
|
|
import { stripHtml } from "@/lib/utils";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import {
|
|
Field,
|
|
FieldDescription,
|
|
FieldGroup,
|
|
FieldLabel,
|
|
FieldLegend,
|
|
FieldSet,
|
|
} from "@/components/ui/field";
|
|
import {
|
|
getQuestionResponseSettings,
|
|
getQuestionResponseType,
|
|
type QuestionResponseType,
|
|
} from "@/lib/form/question-response-type";
|
|
import { SaveStatusIndicator } from "@/components/save-status-indicator";
|
|
|
|
const RichTextEditor = dynamic(
|
|
() =>
|
|
import("@/components/rich-text-editor").then(
|
|
(module) => module.RichTextEditor,
|
|
),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="h-32 animate-pulse border bg-muted/40" />
|
|
),
|
|
},
|
|
);
|
|
|
|
function SortableQuestion({
|
|
id,
|
|
position,
|
|
children,
|
|
}: {
|
|
id: string;
|
|
position: number;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id });
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
className="relative"
|
|
style={{
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.6 : 1,
|
|
}}
|
|
>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute left-2 top-4"
|
|
aria-label={`Drag question ${position} to reorder`}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<GripVertical />
|
|
</Button>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface Question {
|
|
id: string;
|
|
formId: string;
|
|
type: "text" | "textarea" | "radio" | "checkbox";
|
|
label: string;
|
|
imageUrl: string | null;
|
|
displayOrder: number;
|
|
required: boolean;
|
|
options: string[] | null;
|
|
allowOther: boolean;
|
|
imageAnswerMode: "none" | "optional" | "required";
|
|
}
|
|
|
|
interface FormEditorClientProps {
|
|
form: {
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
isOpen: boolean;
|
|
closesAt: string | null;
|
|
allowMultipleSubmissions: boolean;
|
|
};
|
|
initialQuestions: Question[];
|
|
}
|
|
|
|
function toDateTimeLocalValue(value: string | null) {
|
|
if (!value) return "";
|
|
|
|
const date = new Date(value);
|
|
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
|
return localDate.toISOString().slice(0, 16);
|
|
}
|
|
|
|
export default function FormEditorClient({ form, initialQuestions }: FormEditorClientProps) {
|
|
const router = useRouter();
|
|
const [formTitle, setFormTitle] = useState(form.title);
|
|
const [formDescription, setFormDescription] = useState(form.description || "");
|
|
const [formIsOpen, setFormIsOpen] = useState(form.isOpen);
|
|
const [closesAt, setClosesAt] = useState(
|
|
toDateTimeLocalValue(form.closesAt)
|
|
);
|
|
const [allowMultipleSubmissions, setAllowMultipleSubmissions] = useState(
|
|
form.allowMultipleSubmissions
|
|
);
|
|
const [questions, setQuestions] = useState<Question[]>(initialQuestions);
|
|
const [uploadingId, setUploadingId] = useState<string | null>(null);
|
|
const [addingQuestion, setAddingQuestion] = useState(false);
|
|
|
|
// Refs to always read latest state inside debounced callbacks
|
|
const formTitleRef = useRef(formTitle);
|
|
const formDescriptionRef = useRef(formDescription);
|
|
const formIsOpenRef = useRef(formIsOpen);
|
|
const closesAtRef = useRef(closesAt);
|
|
const allowMultipleSubmissionsRef = useRef(allowMultipleSubmissions);
|
|
const questionsRef = useRef(questions);
|
|
|
|
useEffect(() => {
|
|
formTitleRef.current = formTitle;
|
|
formDescriptionRef.current = formDescription;
|
|
formIsOpenRef.current = formIsOpen;
|
|
closesAtRef.current = closesAt;
|
|
allowMultipleSubmissionsRef.current = allowMultipleSubmissions;
|
|
questionsRef.current = questions;
|
|
}, [allowMultipleSubmissions, closesAt, formDescription, formIsOpen, formTitle, questions]);
|
|
|
|
const saveForm = useCallback(async () => {
|
|
const title = formTitleRef.current;
|
|
if (!title.trim()) {
|
|
throw new Error("Form title is required.");
|
|
}
|
|
await updateForm(form.id, {
|
|
title: title.trim(),
|
|
description: formDescriptionRef.current,
|
|
isOpen: formIsOpenRef.current,
|
|
closesAt: closesAtRef.current
|
|
? new Date(closesAtRef.current).toISOString()
|
|
: null,
|
|
allowMultipleSubmissions: allowMultipleSubmissionsRef.current,
|
|
});
|
|
router.refresh();
|
|
}, [form.id, router]);
|
|
|
|
const saveQuestions = useCallback(async () => {
|
|
const current = questionsRef.current;
|
|
const formattedUpdates = current.map(q => ({
|
|
...q,
|
|
options: q.options === null ? [] : q.options,
|
|
allowOther: q.allowOther,
|
|
imageAnswerMode: q.imageAnswerMode,
|
|
}));
|
|
await bulkUpdateQuestions(form.id, formattedUpdates);
|
|
router.refresh();
|
|
}, [form.id, router]);
|
|
|
|
const formAutoSave = useDebouncedAutoSave({ save: saveForm, delay: 1000 });
|
|
const questionsAutoSave = useDebouncedAutoSave({
|
|
save: saveQuestions,
|
|
delay: 1200,
|
|
});
|
|
|
|
const handleFormTitleChange = (val: string) => {
|
|
formTitleRef.current = val;
|
|
setFormTitle(val);
|
|
formAutoSave.schedule();
|
|
};
|
|
|
|
const handleFormDescriptionChange = (val: string) => {
|
|
formDescriptionRef.current = val;
|
|
setFormDescription(val);
|
|
formAutoSave.schedule();
|
|
};
|
|
|
|
const handleFormIsOpenChange = (val: boolean) => {
|
|
formIsOpenRef.current = val;
|
|
setFormIsOpen(val);
|
|
formAutoSave.schedule();
|
|
};
|
|
|
|
const handleClosesAtChange = (val: string) => {
|
|
closesAtRef.current = val;
|
|
setClosesAt(val);
|
|
formAutoSave.schedule();
|
|
};
|
|
|
|
const selectedCloseDateTime = closesAt ? parseISO(closesAt) : undefined;
|
|
|
|
const handleAllowMultipleSubmissionsChange = (val: boolean) => {
|
|
allowMultipleSubmissionsRef.current = val;
|
|
setAllowMultipleSubmissions(val);
|
|
formAutoSave.schedule();
|
|
};
|
|
|
|
const handleAddQuestion = async () => {
|
|
setAddingQuestion(true);
|
|
try {
|
|
const displayOrder = questions.length;
|
|
const question = await createQuestion(form.id, {
|
|
type: "text",
|
|
label: `Untitled Question ${questions.length + 1}`,
|
|
required: false,
|
|
displayOrder,
|
|
options: [],
|
|
});
|
|
setQuestions((current) => {
|
|
const next = [...current, question];
|
|
questionsRef.current = next;
|
|
return next;
|
|
});
|
|
router.refresh();
|
|
} catch (err: unknown) {
|
|
toast.error(getErrorMessage(err, "Could not add the question."));
|
|
} finally {
|
|
setAddingQuestion(false);
|
|
}
|
|
};
|
|
|
|
const handleUpdateQuestion = (id: string, updates: Partial<Question>) => {
|
|
setQuestions((prev) =>
|
|
{
|
|
const next = prev.map((q) =>
|
|
q.id === id ? { ...q, ...updates } as Question : q
|
|
);
|
|
questionsRef.current = next;
|
|
return next;
|
|
}
|
|
);
|
|
questionsAutoSave.schedule();
|
|
};
|
|
|
|
const handleResponseTypeChange = (
|
|
question: Question,
|
|
responseType: QuestionResponseType,
|
|
) => {
|
|
handleUpdateQuestion(
|
|
question.id,
|
|
getQuestionResponseSettings(responseType, question.required),
|
|
);
|
|
};
|
|
|
|
const handleDeleteQuestion = async (id: string) => {
|
|
try {
|
|
await deleteQuestion(id, form.id);
|
|
setQuestions((current) => {
|
|
const next = current.filter((question) => question.id !== id);
|
|
questionsRef.current = next;
|
|
return next;
|
|
});
|
|
router.refresh();
|
|
} catch (err: unknown) {
|
|
toast.error(getErrorMessage(err, "Could not remove the question."));
|
|
}
|
|
};
|
|
|
|
const handleMove = (index: number, direction: "up" | "down") => {
|
|
const newQuestions = [...questions];
|
|
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
|
if (targetIndex < 0 || targetIndex >= newQuestions.length) return;
|
|
|
|
const temp = newQuestions[index];
|
|
newQuestions[index] = newQuestions[targetIndex];
|
|
newQuestions[targetIndex] = temp;
|
|
|
|
// Update display orders based on new positions
|
|
newQuestions.forEach((q, idx) => {
|
|
q.displayOrder = idx;
|
|
});
|
|
|
|
questionsRef.current = newQuestions;
|
|
setQuestions(newQuestions);
|
|
questionsAutoSave.schedule();
|
|
};
|
|
|
|
const handleImageUpload = async (questionId: string, e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
setUploadingId(questionId);
|
|
|
|
toast.promise(
|
|
new Promise<string>(async (resolve, reject) => {
|
|
try {
|
|
const res = await fetch("/api/upload", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Failed to upload image");
|
|
resolve(data.url);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
}).then(async (imageUrl) => {
|
|
await updateQuestion(questionId, form.id, { imageUrl });
|
|
setQuestions((prev) =>
|
|
prev.map((q) => (q.id === questionId ? { ...q, imageUrl } : q))
|
|
);
|
|
router.refresh();
|
|
}).finally(() => {
|
|
setUploadingId(null);
|
|
}),
|
|
{
|
|
loading: "Uploading image...",
|
|
success: "Image uploaded!",
|
|
error: (err: unknown) => getErrorMessage(err, "Failed to upload image."),
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleRemoveImage = async (questionId: string) => {
|
|
toast.promise(
|
|
updateQuestion(questionId, form.id, { imageUrl: null }).then(() => {
|
|
setQuestions((prev) =>
|
|
prev.map((q) => (q.id === questionId ? { ...q, imageUrl: null } : q))
|
|
);
|
|
router.refresh();
|
|
}),
|
|
{
|
|
loading: "Removing image...",
|
|
success: "Image removed.",
|
|
error: (err: unknown) => getErrorMessage(err, "Failed to remove image."),
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleAddOption = (question: Question) => {
|
|
const options = question.options ? [...question.options] : [];
|
|
options.push(`Option ${options.length + 1}`);
|
|
handleUpdateQuestion(question.id, { options });
|
|
};
|
|
|
|
const handleUpdateOption = (question: Question, optIndex: number, val: string) => {
|
|
if (!question.options) return;
|
|
const options = [...question.options];
|
|
options[optIndex] = val;
|
|
handleUpdateQuestion(question.id, { options });
|
|
};
|
|
|
|
const handleRemoveOption = (question: Question, optIndex: number) => {
|
|
if (!question.options) return;
|
|
const options = question.options.filter((_, idx) => idx !== optIndex);
|
|
handleUpdateQuestion(question.id, { options });
|
|
};
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
})
|
|
);
|
|
|
|
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
|
if (!over || active.id === over.id) return;
|
|
const oldIndex = questions.findIndex((question) => question.id === active.id);
|
|
const newIndex = questions.findIndex((question) => question.id === over.id);
|
|
const next = arrayMove(questions, oldIndex, newIndex).map((question, index) => ({
|
|
...question,
|
|
displayOrder: index,
|
|
}));
|
|
questionsRef.current = next;
|
|
setQuestions(next);
|
|
questionsAutoSave.schedule();
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle>Form Settings</CardTitle>
|
|
<CardDescription>Configure the main title and description of your form.</CardDescription>
|
|
</div>
|
|
<SaveStatusIndicator
|
|
status={formAutoSave.status}
|
|
error={formAutoSave.error}
|
|
onRetry={formAutoSave.retry}
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FieldGroup>
|
|
<Field>
|
|
<FieldLabel htmlFor="form-title">Form Title</FieldLabel>
|
|
<Input
|
|
id="form-title"
|
|
value={formTitle}
|
|
onChange={(e) => handleFormTitleChange(e.target.value)}
|
|
placeholder="e.g. Untitled Form"
|
|
aria-invalid={!formTitle.trim()}
|
|
/>
|
|
{!formTitle.trim() && (
|
|
<p className="text-sm text-destructive">Form title is required.</p>
|
|
)}
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="form-description">Description</FieldLabel>
|
|
<RichTextEditor
|
|
value={formDescription}
|
|
onChange={(html) => handleFormDescriptionChange(html)}
|
|
placeholder="Provide a description..."
|
|
className="min-h-20 w-full"
|
|
/>
|
|
</Field>
|
|
<Field orientation="horizontal">
|
|
<Switch
|
|
id="form-is-open"
|
|
checked={formIsOpen}
|
|
onCheckedChange={handleFormIsOpenChange}
|
|
/>
|
|
<FieldLabel htmlFor="form-is-open" className="font-normal">
|
|
Accepting Submissions
|
|
</FieldLabel>
|
|
</Field>
|
|
<FieldSet className="gap-3">
|
|
<FieldLegend variant="label">
|
|
Automatically close submissions
|
|
</FieldLegend>
|
|
<Field>
|
|
<FieldLabel htmlFor="form-close-date">Date and time</FieldLabel>
|
|
<DateTimePicker
|
|
id="form-close-date"
|
|
value={selectedCloseDateTime}
|
|
onChange={(date) =>
|
|
handleClosesAtChange(
|
|
date ? format(date, "yyyy-MM-dd'T'HH:mm") : ""
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<FieldDescription>
|
|
Changes are saved automatically. Leave blank for no deadline.
|
|
The selected date and time use your device's time zone.
|
|
</FieldDescription>
|
|
</FieldSet>
|
|
<Field orientation="horizontal">
|
|
<Switch
|
|
id="allow-multiple-submissions"
|
|
checked={allowMultipleSubmissions}
|
|
onCheckedChange={handleAllowMultipleSubmissionsChange}
|
|
/>
|
|
<div className="flex flex-col gap-1">
|
|
<FieldLabel htmlFor="allow-multiple-submissions" className="font-normal">
|
|
Allow multiple submissions
|
|
</FieldLabel>
|
|
<FieldDescription>
|
|
Users can create separate responses instead of only editing their current response.
|
|
</FieldDescription>
|
|
</div>
|
|
</Field>
|
|
</FieldGroup>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Separator />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-xl font-bold">Questions</h2>
|
|
<SaveStatusIndicator
|
|
status={questionsAutoSave.status}
|
|
error={questionsAutoSave.error}
|
|
onRetry={questionsAutoSave.retry}
|
|
/>
|
|
</div>
|
|
<Button onClick={handleAddQuestion} variant="outline" disabled={addingQuestion}>
|
|
{addingQuestion ? (
|
|
<Loader2 data-icon="inline-start" className="animate-spin" />
|
|
) : (
|
|
<Plus data-icon="inline-start" />
|
|
)}
|
|
{addingQuestion ? "Adding" : "Add question"}
|
|
</Button>
|
|
</div>
|
|
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={questions.map((question) => question.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
{questions.map((q, index) => (
|
|
<SortableQuestion key={q.id} id={q.id} position={index + 1}>
|
|
<Accordion type="single" collapsible defaultValue={index === 0 ? q.id : undefined}>
|
|
<AccordionItem value={q.id} className="border-0">
|
|
<Card>
|
|
<CardHeader className="pl-12">
|
|
<div className="relative flex items-center">
|
|
<div className="min-w-0 flex-1">
|
|
<AccordionTrigger className="w-full min-w-0 py-0 pr-32 hover:no-underline [&_[data-slot=accordion-trigger-icon]]:absolute [&_[data-slot=accordion-trigger-icon]]:right-0 [&_[data-slot=accordion-trigger-icon]]:top-1/2 [&_[data-slot=accordion-trigger-icon]]:-translate-y-1/2">
|
|
<div className="min-w-0 text-left">
|
|
<CardTitle className="truncate text-base">
|
|
{stripHtml(q.label) || `Untitled question ${index + 1}`}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Question {index + 1} · {questionTypeLabel(q.type)}
|
|
{q.required || q.imageAnswerMode === "required" ? " · Required" : ""}
|
|
</CardDescription>
|
|
</div>
|
|
</AccordionTrigger>
|
|
</div>
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute right-8 top-1/2 -translate-y-1/2 text-destructive"
|
|
aria-label={`Remove question ${index + 1}`}
|
|
>
|
|
<Trash data-icon="inline-start" />
|
|
Remove
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Remove question {index + 1}?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This permanently removes the question and its stored answers.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Keep question</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
onClick={() => void handleDeleteQuestion(q.id)}
|
|
>
|
|
Remove question
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</CardHeader>
|
|
<AccordionContent className="h-auto">
|
|
<CardContent className="flex flex-col gap-4 pt-0">
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
|
<div className="flex-1 w-full">
|
|
<RichTextEditor
|
|
value={q.label}
|
|
onChange={(html) => handleUpdateQuestion(q.id, { label: html })}
|
|
placeholder="Question Text"
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2 self-end sm:self-auto">
|
|
<div className="flex items-center gap-1 border p-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={index === 0}
|
|
onClick={() => handleMove(index, "up")}
|
|
aria-label={`Move question ${index + 1} up`}
|
|
>
|
|
<ArrowUp />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={index === questions.length - 1}
|
|
onClick={() => handleMove(index, "down")}
|
|
aria-label={`Move question ${index + 1} down`}
|
|
>
|
|
<ArrowDown />
|
|
</Button>
|
|
</div>
|
|
|
|
<Select
|
|
value={q.type}
|
|
onValueChange={(val) => handleUpdateQuestion(q.id, { type: val as Question["type"] })}
|
|
>
|
|
<SelectTrigger className="w-36">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="text">Short answer</SelectItem>
|
|
<SelectItem value="textarea">Paragraph</SelectItem>
|
|
<SelectItem value="radio">Choose one</SelectItem>
|
|
<SelectItem value="checkbox">Choose multiple</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
{q.imageUrl ? (
|
|
<div className="relative max-w-full h-auto inline-block shrink-0 group">
|
|
<Image
|
|
width={200}
|
|
height={200}
|
|
src={q.imageUrl}
|
|
alt={q.label}
|
|
unoptimized
|
|
className="h-auto w-32 border object-contain"
|
|
/>
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
className="absolute -right-2 -top-2 size-6 rounded-full opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"
|
|
onClick={() => handleRemoveImage(q.id)}
|
|
aria-label={`Remove image from question ${index + 1}`}
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Label
|
|
htmlFor={`img-upload-${q.id}`}
|
|
className="flex cursor-pointer items-center gap-2 border px-3 py-2 text-sm text-muted-foreground hover:bg-accent"
|
|
>
|
|
{uploadingId === q.id ? (
|
|
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
|
) : (
|
|
<ImageIcon className="w-4 h-4 text-primary" />
|
|
)}
|
|
<span>Add Image</span>
|
|
</Label>
|
|
<input
|
|
id={`img-upload-${q.id}`}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={(e) => handleImageUpload(q.id, e)}
|
|
className="hidden"
|
|
disabled={uploadingId !== null}
|
|
/>
|
|
</div>
|
|
)}
|
|
<Field className="w-full max-w-sm">
|
|
<FieldLabel>Response type</FieldLabel>
|
|
<Select
|
|
value={getQuestionResponseType(q)}
|
|
onValueChange={(val) =>
|
|
handleResponseTypeChange(q, val as QuestionResponseType)
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="answer-only">Answer only</SelectItem>
|
|
<SelectItem value="answer-optional-image">
|
|
Answer + optional image
|
|
</SelectItem>
|
|
<SelectItem value="image-only">Image only</SelectItem>
|
|
<SelectItem value="answer-required-image">
|
|
Answer + required image
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
<FieldDescription>
|
|
“Image only” hides the normal answer field. “Answer + required image” requires both.
|
|
</FieldDescription>
|
|
</Field>
|
|
{(q.imageAnswerMode === "none" || q.imageAnswerMode === "optional") && (
|
|
<Field orientation="horizontal">
|
|
<Switch
|
|
id={`required-${q.id}`}
|
|
checked={q.required}
|
|
onCheckedChange={(checked) =>
|
|
handleUpdateQuestion(q.id, { required: checked })
|
|
}
|
|
/>
|
|
<div>
|
|
<FieldLabel htmlFor={`required-${q.id}`}>
|
|
Require answer
|
|
</FieldLabel>
|
|
<FieldDescription>
|
|
Users must complete the normal answer field.
|
|
</FieldDescription>
|
|
</div>
|
|
</Field>
|
|
)}
|
|
</div>
|
|
|
|
{(q.type === "radio" || q.type === "checkbox") && (
|
|
<div className="space-y-2 pl-4 border-l-2 border-primary/20">
|
|
<Label className="text-sm font-semibold">Options</Label>
|
|
<div className="space-y-2">
|
|
{(q.options || []).map((opt, optIdx) => (
|
|
<div key={optIdx} className="flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-muted-foreground" />
|
|
<Textarea
|
|
value={opt}
|
|
onChange={(e) => handleUpdateOption(q, optIdx, e.target.value)}
|
|
className="min-h-10 max-w-sm py-2"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive"
|
|
onClick={() => handleRemoveOption(q, optIdx)}
|
|
aria-label={`Remove option ${optIdx + 1}`}
|
|
>
|
|
<Trash />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleAddOption(q)}
|
|
className="text-primary hover:text-primary/80 h-8 gap-1 pl-1"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Option
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{(q.type === "radio" || q.type === "checkbox") && (
|
|
<div className="flex items-center gap-2 border-t border-border pt-2">
|
|
<Switch
|
|
id={`allowOther-${q.id}`}
|
|
checked={q.allowOther}
|
|
onCheckedChange={(checked) => handleUpdateQuestion(q.id, { allowOther: checked })}
|
|
/>
|
|
<Label htmlFor={`allowOther-${q.id}`} className="text-sm font-normal text-muted-foreground">
|
|
Add 'Other' option
|
|
</Label>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</AccordionContent>
|
|
</Card>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
</SortableQuestion>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function questionTypeLabel(type: Question["type"]) {
|
|
return {
|
|
text: "Short answer",
|
|
textarea: "Paragraph",
|
|
radio: "Choose one",
|
|
checkbox: "Choose multiple",
|
|
}[type];
|
|
}
|