feat:update form
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user