feat:update form

This commit is contained in:
2026-07-28 00:02:34 +07:00 Unverified
parent 0e8c816276
commit fa35f00f38
25 changed files with 919 additions and 149 deletions
+72 -12
View File
@@ -61,6 +61,12 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
interface Question {
id: string;
@@ -72,6 +78,7 @@ interface Question {
required: boolean;
options: string[] | null;
allowOther: boolean;
imageAnswerMode: "none" | "optional" | "required";
}
interface FormEditorClientProps {
@@ -80,6 +87,7 @@ interface FormEditorClientProps {
title: string;
description: string | null;
isOpen: boolean;
allowMultipleSubmissions: boolean;
};
initialQuestions: Question[];
}
@@ -89,6 +97,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const [formTitle, setFormTitle] = useState(form.title);
const [formDescription, setFormDescription] = useState(form.description || "");
const [formIsOpen, setFormIsOpen] = useState(form.isOpen);
const [allowMultipleSubmissions, setAllowMultipleSubmissions] = useState(
form.allowMultipleSubmissions
);
const [questions, setQuestions] = useState<Question[]>(initialQuestions);
const [uploadingId, setUploadingId] = useState<string | null>(null);
const [addingQuestion, setAddingQuestion] = useState(false);
@@ -97,14 +108,16 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const formTitleRef = useRef(formTitle);
const formDescriptionRef = useRef(formDescription);
const formIsOpenRef = useRef(formIsOpen);
const allowMultipleSubmissionsRef = useRef(allowMultipleSubmissions);
const questionsRef = useRef(questions);
useEffect(() => {
formTitleRef.current = formTitle;
formDescriptionRef.current = formDescription;
formIsOpenRef.current = formIsOpen;
allowMultipleSubmissionsRef.current = allowMultipleSubmissions;
questionsRef.current = questions;
}, [formDescription, formIsOpen, formTitle, questions]);
}, [allowMultipleSubmissions, formDescription, formIsOpen, formTitle, questions]);
const saveForm = useCallback(async () => {
const title = formTitleRef.current;
@@ -115,6 +128,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
title: title.trim(),
description: formDescriptionRef.current,
isOpen: formIsOpenRef.current,
allowMultipleSubmissions: allowMultipleSubmissionsRef.current,
});
router.refresh();
}, [form.id, router]);
@@ -125,6 +139,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
...q,
options: q.options === null ? [] : q.options,
allowOther: q.allowOther,
imageAnswerMode: q.imageAnswerMode,
}));
await bulkUpdateQuestions(form.id, formattedUpdates);
router.refresh();
@@ -154,6 +169,12 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
formAutoSave.schedule();
};
const handleAllowMultipleSubmissionsChange = (val: boolean) => {
allowMultipleSubmissionsRef.current = val;
setAllowMultipleSubmissions(val);
formAutoSave.schedule();
};
const handleAddQuestion = async () => {
setAddingQuestion(true);
try {
@@ -334,9 +355,10 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="form-title">Form Title</Label>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel htmlFor="form-title">Form Title</FieldLabel>
<Input
id="form-title"
value={formTitle}
@@ -347,26 +369,42 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
{!formTitle.trim() && (
<p className="text-sm text-destructive">Form title is required.</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="form-description">Description</Label>
</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"
/>
</div>
<div className="flex items-center space-x-2 pt-2">
</Field>
<Field orientation="horizontal">
<Switch
id="form-is-open"
checked={formIsOpen}
onCheckedChange={handleFormIsOpenChange}
/>
<Label htmlFor="form-is-open" className="text-sm font-normal text-muted-foreground">
<FieldLabel htmlFor="form-is-open" className="font-normal">
Accepting Submissions
</Label>
</div>
</FieldLabel>
</Field>
<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>
@@ -566,6 +604,28 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
Required
</Label>
</div>
<Field className="w-full max-w-xs">
<FieldLabel>Answer image</FieldLabel>
<Select
value={q.imageAnswerMode}
onValueChange={(val) =>
handleUpdateQuestion(q.id, {
imageAnswerMode: val as Question["imageAnswerMode"],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="none">Off</SelectItem>
<SelectItem value="optional">Optional</SelectItem>
<SelectItem value="required">Required</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
{(q.type === "radio" || q.type === "checkbox") && (
@@ -26,6 +26,7 @@ import Image from 'next/image';
import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
import { formatStoredAnswer } from "@/lib/form-answer-values";
import { normalizeStoredAnswerSnapshot } from "@/lib/submission-intake";
interface Question {
id: string;
@@ -40,10 +41,10 @@ interface SubmissionDetailClientProps {
userName: string | null;
userDiscordId: string | null;
submittedAt: Date;
editHistory?: { editedAt: string; oldAnswers: Record<string, string> }[] | null;
editHistory?: { editedAt: string; oldAnswers: Record<string, string | { value: string; imageCdnId: string | null }> }[] | null;
};
questions: Question[];
answers: Record<string, string>;
answers: Record<string, { value: string; imageCdnId: string | null }>;
}
export default function SubmissionDetailClient({
@@ -103,16 +104,25 @@ export default function SubmissionDetailClient({
{/* Answers List */}
<div className="space-y-4">
{questions.map((q) => {
const rawAnswerValue = answers[q.id] || "";
const rawAnswer = answers[q.id] ?? { value: "", imageCdnId: null };
const rawAnswerValue = rawAnswer.value;
const answerValue = formatStoredAnswer(rawAnswerValue, q.type);
const imageUrl = rawAnswer.imageCdnId ? `/cdn/${rawAnswer.imageCdnId}` : "";
const wasEdited = editHistory.some(
(edit) => (edit.oldAnswers[q.id] ?? "") !== rawAnswerValue
(edit) => {
const oldSnapshot = normalizeStoredAnswerSnapshot(edit.oldAnswers[q.id]);
return oldSnapshot.value !== rawAnswerValue || oldSnapshot.imageCdnId !== rawAnswer.imageCdnId;
}
);
const qAnswerHistory = editHistory.map((edit) => ({
editedAt: edit.editedAt,
answer: formatStoredAnswer(edit.oldAnswers[q.id], q.type),
answer: (() => {
const snapshot = normalizeStoredAnswerSnapshot(edit.oldAnswers[q.id]);
const text = formatStoredAnswer(snapshot.value, q.type);
return text || (snapshot.imageCdnId ? `/cdn/${snapshot.imageCdnId}` : "");
})(),
}));
return (
@@ -171,8 +181,8 @@ export default function SubmissionDetailClient({
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
{answerValue ? (
<CardContent className="flex flex-col gap-4">
{answerValue && (
<div className="flex items-start justify-between gap-4 p-3 bg-muted rounded-md group">
<div className="flex flex-col min-w-0">
{wasEdited && (
@@ -198,7 +208,11 @@ export default function SubmissionDetailClient({
)}
</Button>
</div>
) : (
)}
{imageUrl && (
<AnswerImagePreview src={imageUrl} label={stripHtml(q.label)} />
)}
{!answerValue && !imageUrl && (
<span className="text-sm text-muted-foreground italic pl-3 border-l-2 border-muted block">
No response provided
</span>
@@ -255,3 +269,53 @@ export default function SubmissionDetailClient({
</div>
);
}
function AnswerImagePreview({ src, label }: { src: string; label: string }) {
const [unavailable, setUnavailable] = useState(false);
if (unavailable) {
return (
<span className="text-sm text-muted-foreground italic">
Image unavailable
</span>
);
}
return (
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
className="h-auto w-fit p-2"
aria-label={`Open image answer for ${label}`}
>
<Image
src={src}
alt=""
width={224}
height={160}
unoptimized
className="max-h-40 max-w-56 rounded object-contain"
onError={() => setUnavailable(true)}
/>
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Image answer</DialogTitle>
<DialogDescription>{label}</DialogDescription>
</DialogHeader>
<Image
src={src}
alt=""
width={1200}
height={800}
unoptimized
className="max-h-[70vh] w-full rounded-md object-contain"
onError={() => setUnavailable(true)}
/>
</DialogContent>
</Dialog>
);
}
@@ -47,9 +47,12 @@ export default async function SubmissionDetailPage({
where: (a, { eq }) => eq(a.submissionId, submission.id),
});
const answersMap: Record<string, string> = {};
const answersMap: Record<string, { value: string; imageCdnId: string | null }> = {};
for (const ans of answersList) {
answersMap[ans.questionId] = ans.value;
answersMap[ans.questionId] = {
value: ans.value,
imageCdnId: ans.imageCdnId ?? null,
};
}
const submissionsList = await db.query.submissions.findMany({
where: (s, { eq }) => eq(s.formId, formId),
+25 -6
View File
@@ -7,7 +7,7 @@ import {
getFilteredSubmissions,
parseResultFilters,
} from "@/lib/admin-form-results";
import { formatStoredAnswer } from "@/lib/form-answer-values";
import { answerCellValue } from "@/lib/form-answer-values";
import { stripHtml } from "@/lib/utils";
function csvCell(value: unknown) {
@@ -25,6 +25,24 @@ function safeFilename(value: string) {
);
}
function safeAsciiFilename(value: string) {
return (
value
.normalize("NFKD")
.replace(/[^\x00-\x7F]/g, "")
.replace(/[^A-Za-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 60) || "form"
);
}
function csvContentDisposition(title: string, date: string) {
const unicodeFilename = `${safeFilename(title)}-responses-${date}.csv`;
const asciiFilename = `${safeAsciiFilename(title)}-responses-${date}.csv`;
return `attachment; filename="${asciiFilename}"; filename*=UTF-8''${encodeURIComponent(unicodeFilename)}`;
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -59,7 +77,7 @@ export async function GET(
const answerMap = new Map(
answerRows.map((answer) => [
`${answer.submissionId}:${answer.questionId}`,
answer.value,
{ value: answer.value, imageCdnId: answer.imageCdnId ?? null },
])
);
const header = [
@@ -83,9 +101,10 @@ export async function GET(
? submission.editHistory.length
: 0,
...questions.map((question) =>
formatStoredAnswer(
answerMap.get(`${submission.id}:${question.id}`) || "",
question.type
answerCellValue(
answerMap.get(`${submission.id}:${question.id}`)?.value || "",
question.type,
answerMap.get(`${submission.id}:${question.id}`)?.imageCdnId
)
),
]
@@ -98,7 +117,7 @@ export async function GET(
return new NextResponse(`\uFEFF${lines.join("\r\n")}`, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${safeFilename(form.title)}-responses-${date}.csv"`,
"Content-Disposition": csvContentDisposition(form.title, date),
},
});
}
+1 -1
View File
@@ -73,7 +73,7 @@ export default async function ResultsListPage({
{total > 0 && <DeleteAllSubmissionsButton formId={id} />}
</div>
<ResultsFilters formId={id} filters={filters} />
<ResultsFilters key={listQuery} formId={id} filters={filters} />
{rows.length === 0 ? (
<Card>