feat: improve form response experience
This commit is contained in:
+115
-16
@@ -8,10 +8,17 @@ import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth";
|
||||
import { sse } from "@/lib/sse";
|
||||
import {
|
||||
decodeCheckboxAnswer,
|
||||
encodeAnswerValue,
|
||||
formatStoredAnswer,
|
||||
type FormAnswerValue,
|
||||
} from "@/lib/form-answer-values";
|
||||
import { stripHtml } from "@/lib/utils";
|
||||
|
||||
export async function submitForm(
|
||||
formId: string,
|
||||
answersList: { questionId: string; value: string }[]
|
||||
answersList: { questionId: string; value: FormAnswerValue }[]
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) throw new Error("Not authenticated");
|
||||
@@ -21,6 +28,96 @@ export async function submitForm(
|
||||
const form = await requireFormAccess(formId, discordId);
|
||||
if (!form.isOpen) throw new Error("This form is disabled for now");
|
||||
|
||||
const questionsList = await db.query.questions.findMany({
|
||||
where: (question, { eq }) => eq(question.formId, formId),
|
||||
orderBy: (question, { asc }) => asc(question.displayOrder),
|
||||
});
|
||||
if (questionsList.length === 0) {
|
||||
throw new Error("แบบฟอร์มนี้ยังไม่มีคำถาม");
|
||||
}
|
||||
|
||||
const questionsById = new Map(questionsList.map((question) => [question.id, question]));
|
||||
const submittedQuestionIds = new Set<string>();
|
||||
for (const answer of answersList) {
|
||||
if (!questionsById.has(answer.questionId) || submittedQuestionIds.has(answer.questionId)) {
|
||||
throw new Error("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม");
|
||||
}
|
||||
submittedQuestionIds.add(answer.questionId);
|
||||
}
|
||||
|
||||
const submittedAnswers = new Map(
|
||||
answersList.map((answer) => [answer.questionId, answer.value])
|
||||
);
|
||||
const normalizedAnswers = questionsList.map((question) => {
|
||||
const rawValue = submittedAnswers.get(question.id);
|
||||
|
||||
if (question.type === "checkbox") {
|
||||
if (
|
||||
rawValue !== undefined &&
|
||||
typeof rawValue !== "string" &&
|
||||
(!Array.isArray(rawValue) ||
|
||||
rawValue.some((value) => typeof value !== "string"))
|
||||
) {
|
||||
throw new Error(`รูปแบบคำตอบของ “${stripHtml(question.label)}” ไม่ถูกต้อง`);
|
||||
}
|
||||
|
||||
const selectedValues = [...new Set(decodeCheckboxAnswer(rawValue))];
|
||||
if (
|
||||
selectedValues.some(
|
||||
(value) => !value.trim() || value === "__OTHER__"
|
||||
)
|
||||
) {
|
||||
throw new Error(`โปรดระบุคำตอบสำหรับ “${stripHtml(question.label)}”`);
|
||||
}
|
||||
if (question.required && selectedValues.length === 0) {
|
||||
throw new Error(`จำเป็นต้องตอบ “${stripHtml(question.label)}”`);
|
||||
}
|
||||
|
||||
const standardOptions = question.options ?? [];
|
||||
const customValues = selectedValues.filter(
|
||||
(value) => !standardOptions.includes(value)
|
||||
);
|
||||
if (
|
||||
(!question.allowOther && customValues.length > 0) ||
|
||||
customValues.length > 1
|
||||
) {
|
||||
throw new Error(`ตัวเลือกของ “${stripHtml(question.label)}” ไม่ถูกต้อง`);
|
||||
}
|
||||
|
||||
return {
|
||||
questionId: question.id,
|
||||
value: encodeAnswerValue(selectedValues),
|
||||
displayValue: selectedValues.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
if (rawValue !== undefined && typeof rawValue !== "string") {
|
||||
throw new Error(`รูปแบบคำตอบของ “${stripHtml(question.label)}” ไม่ถูกต้อง`);
|
||||
}
|
||||
|
||||
const value = rawValue ?? "";
|
||||
if (value === "__OTHER__") {
|
||||
throw new Error(`โปรดระบุคำตอบสำหรับ “${stripHtml(question.label)}”`);
|
||||
}
|
||||
if (question.required && !value.trim()) {
|
||||
throw new Error(`จำเป็นต้องตอบ “${stripHtml(question.label)}”`);
|
||||
}
|
||||
if (
|
||||
question.type === "radio" &&
|
||||
value &&
|
||||
!(question.options ?? []).includes(value) &&
|
||||
!question.allowOther
|
||||
) {
|
||||
throw new Error(`ตัวเลือกของ “${stripHtml(question.label)}” ไม่ถูกต้อง`);
|
||||
}
|
||||
|
||||
return {
|
||||
questionId: question.id,
|
||||
value,
|
||||
displayValue: value,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const existingSubmission = await tx.query.submissions.findFirst({
|
||||
where: (submission, { eq, and }) =>
|
||||
@@ -69,9 +166,9 @@ export async function submitForm(
|
||||
submissionId = inserted.id;
|
||||
}
|
||||
|
||||
if (answersList.length > 0) {
|
||||
if (normalizedAnswers.length > 0) {
|
||||
await tx.insert(answers).values(
|
||||
answersList.map((answer) => ({
|
||||
normalizedAnswers.map((answer) => ({
|
||||
submissionId,
|
||||
questionId: answer.questionId,
|
||||
value: answer.value,
|
||||
@@ -89,19 +186,15 @@ export async function submitForm(
|
||||
|
||||
if (form.discordWebhookUrl && templateToUse) {
|
||||
try {
|
||||
const questionsList = await db.query.questions.findMany({
|
||||
where: (q, { eq }) => eq(q.formId, formId),
|
||||
orderBy: (q, { asc }) => asc(q.displayOrder)
|
||||
});
|
||||
const questionMap = questionsList.reduce((acc, q, index) => {
|
||||
const questionOrderMap = questionsList.reduce((acc, q, index) => {
|
||||
acc[q.id] = String(index + 1);
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const answersDict = answersList.reduce((acc, a) => {
|
||||
const idx = questionMap[a.questionId];
|
||||
const answersDict = normalizedAnswers.reduce((acc, answer) => {
|
||||
const idx = questionOrderMap[answer.questionId];
|
||||
if (idx) {
|
||||
acc[idx] = a.value;
|
||||
acc[idx] = answer.displayValue;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
@@ -109,11 +202,17 @@ export async function submitForm(
|
||||
let updatesText = "";
|
||||
if (result.isUpdate) {
|
||||
const changed: string[] = [];
|
||||
for (const ans of answersList) {
|
||||
const oldVal = result.oldAnswersDict[ans.questionId];
|
||||
if (oldVal !== ans.value) {
|
||||
const qTitle = questionsList.find(q => q.id === ans.questionId)?.label || `Question`;
|
||||
changed.push(`**${qTitle}**: \`${oldVal || "Empty"}\` ➡️ \`${ans.value || "Empty"}\``);
|
||||
for (const answer of normalizedAnswers) {
|
||||
const question = questionsById.get(answer.questionId);
|
||||
const oldValue = result.oldAnswersDict[answer.questionId];
|
||||
const oldDisplayValue = question
|
||||
? formatStoredAnswer(oldValue, question.type)
|
||||
: oldValue;
|
||||
if (oldDisplayValue !== answer.displayValue) {
|
||||
const questionTitle = stripHtml(question?.label || "Question");
|
||||
changed.push(
|
||||
`**${questionTitle}**: \`${oldDisplayValue || "Empty"}\` ➡️ \`${answer.displayValue || "Empty"}\``
|
||||
);
|
||||
}
|
||||
}
|
||||
updatesText = changed.length > 0 ? changed.join("\n") : "No changes made.";
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import Image from 'next/image';
|
||||
import { stripHtml } from "@/lib/utils";
|
||||
import { HtmlDisplay } from "@/components/html-display";
|
||||
import { formatStoredAnswer } from "@/lib/form-answer-values";
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
@@ -94,15 +95,16 @@ export default function SubmissionDetailClient({
|
||||
{/* Answers List */}
|
||||
<div className="space-y-4">
|
||||
{questions.map((q) => {
|
||||
const ansValue = answers[q.id] || "";
|
||||
const rawAnswerValue = answers[q.id] || "";
|
||||
const answerValue = formatStoredAnswer(rawAnswerValue, q.type);
|
||||
|
||||
const wasEdited = editHistory.some(
|
||||
(edit) => (edit.oldAnswers[q.id] ?? "") !== ansValue
|
||||
(edit) => (edit.oldAnswers[q.id] ?? "") !== rawAnswerValue
|
||||
);
|
||||
|
||||
const qAnswerHistory = editHistory.map((edit) => ({
|
||||
editedAt: edit.editedAt,
|
||||
answer: edit.oldAnswers[q.id] ?? "",
|
||||
answer: formatStoredAnswer(edit.oldAnswers[q.id], q.type),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -135,7 +137,7 @@ export default function SubmissionDetailClient({
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{ansValue ? (
|
||||
{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 && (
|
||||
@@ -144,14 +146,14 @@ export default function SubmissionDetailClient({
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm whitespace-pre-wrap break-all leading-relaxed">
|
||||
{ansValue}
|
||||
{answerValue}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => handleCopy(ansValue, q.id)}
|
||||
onClick={() => handleCopy(answerValue, q.id)}
|
||||
>
|
||||
{copiedId === q.id ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
|
||||
+25
-15
@@ -6,6 +6,10 @@ import { notFound } from "next/navigation";
|
||||
import LoginDialog from "@/components/login-dialog";
|
||||
import { canAccessForm, getSessionDiscordId } from "@/lib/auth";
|
||||
import { RealtimeRefresh } from "@/components/realtime-refresh";
|
||||
import {
|
||||
decodeCheckboxAnswer,
|
||||
type FormAnswerValue,
|
||||
} from "@/lib/form-answer-values";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -26,6 +30,10 @@ export default async function FormsSubmitPage(
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!discordId) {
|
||||
return <LoginDialog />;
|
||||
}
|
||||
|
||||
if (!(await canAccessForm(form, discordId))) {
|
||||
return notFound();
|
||||
}
|
||||
@@ -35,34 +43,36 @@ export default async function FormsSubmitPage(
|
||||
orderBy: (q, { asc }) => [asc(q.displayOrder)],
|
||||
});
|
||||
|
||||
const initialAnswers: Record<string, string> = {};
|
||||
if (discordId) {
|
||||
const existingSubmission = await db.query.submissions.findFirst({
|
||||
where: (s, { eq, and }) =>
|
||||
and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)),
|
||||
});
|
||||
const existingSubmission = await db.query.submissions.findFirst({
|
||||
where: (s, { eq, and }) =>
|
||||
and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)),
|
||||
});
|
||||
|
||||
if (existingSubmission) {
|
||||
const answersList = await db.query.answers.findMany({
|
||||
where: (a, { eq }) => eq(a.submissionId, existingSubmission.id),
|
||||
});
|
||||
for (const a of answersList) {
|
||||
initialAnswers[a.questionId] = a.value;
|
||||
}
|
||||
const initialAnswers: Record<string, FormAnswerValue> = {};
|
||||
if (existingSubmission) {
|
||||
const answersList = await db.query.answers.findMany({
|
||||
where: (a, { eq }) => eq(a.submissionId, existingSubmission.id),
|
||||
});
|
||||
const questionTypes = new Map(questionsList.map((question) => [question.id, question.type]));
|
||||
for (const answer of answersList) {
|
||||
initialAnswers[answer.questionId] =
|
||||
questionTypes.get(answer.questionId) === "checkbox"
|
||||
? decodeCheckboxAnswer(answer.value)
|
||||
: answer.value;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RealtimeRefresh topic="forms" formId={form.id} />
|
||||
<div className="max-w-2xl mx-auto p-4 md:p-6 space-y-6">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<FormFillerClient
|
||||
form={form}
|
||||
questions={questionsList}
|
||||
initialAnswers={initialAnswers}
|
||||
hasSubmission={Boolean(existingSubmission)}
|
||||
/>
|
||||
</div>
|
||||
{!discordId && <LoginDialog />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+653
-305
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -64,7 +64,7 @@ export default async function FormLayout({
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 p-6 md:p-8 overflow-y-auto max-w-6xl mx-auto w-full">
|
||||
<main className="mx-auto w-full max-w-6xl flex-1 overflow-y-auto px-3 py-4 sm:p-6 md:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -13,19 +13,24 @@ import { Button } from "@/components/ui/button";
|
||||
export default function LoginDialog() {
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">ต้อง Login</DialogTitle>
|
||||
<DialogDescription className="text-base mt-1">
|
||||
คุณต้องล็อกอินเพื่อดำเนินการต่อ
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
เข้าสู่ระบบก่อนกรอกแบบฟอร์ม
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-base">
|
||||
ระบบใช้บัญชี Discord เพื่อตรวจสอบสิทธิ์และเก็บคำตอบหนึ่งชุดต่อคน
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Button
|
||||
className="w-full gap-2 mt-2"
|
||||
className="mt-2 w-full"
|
||||
onClick={() => signIn("discord", { callbackUrl: window.location.href })}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
data-icon="inline-start"
|
||||
viewBox="0 0 127.14 96.36"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Accordion({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn(
|
||||
"flex w-full flex-col overflow-hidden rounded-2xl border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b data-open:bg-muted/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between gap-6 border border-transparent p-4 text-left text-sm font-medium transition-all outline-none hover:underline disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden px-4 text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--radix-accordion-content-height) pt-0 pb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-xl has-[>[data-slot=field]]:border *:data-[slot=field]:p-4 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# ADR 0001: Form response UX and answer representation
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-24
|
||||
|
||||
## Context
|
||||
|
||||
The form flow supports one Discord-linked submission per person, local draft recovery,
|
||||
response updates, deletion, role-based access, and four question types. The previous
|
||||
interface blurred local drafts and server submissions, hid role-gated forms behind a
|
||||
404 before login, front-loaded long descriptions, and represented checkbox answers as
|
||||
comma-separated text.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Authentication happens before authorization. Anonymous visitors to a valid direct
|
||||
form link see the Discord login prompt; authenticated visitors without permission
|
||||
continue to receive the not-found state.
|
||||
2. Drafts remain browser-local and are explicitly described as saved on this device.
|
||||
Draft storage contains both normal answers and free text entered for “Other”.
|
||||
3. Long form descriptions remain available in full but start collapsed behind a
|
||||
clearly labelled rules disclosure.
|
||||
4. The UI distinguishes a new answer, an editable submitted answer, and a closed form.
|
||||
When a form closes, an existing response is read-only but can still be deleted.
|
||||
5. Question text programmatically labels its control. Radio and checkbox choices use
|
||||
semantic fieldsets, and image enlargement uses a keyboard-accessible button.
|
||||
6. Checkbox answers are stored as JSON arrays in the existing text column. Readers
|
||||
retain a fallback for legacy comma-separated values, so no database migration is
|
||||
required.
|
||||
7. Server Actions independently validate authentication, form access, required
|
||||
answers, question ownership, answer shape, and allowed choices.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Existing checkbox answers remain readable, while new answers can safely contain
|
||||
commas.
|
||||
- Local drafts do not follow a person to another device; the interface says so.
|
||||
- Users must authenticate before seeing form questions.
|
||||
- Closing a form prevents new submissions and updates but does not prevent a person
|
||||
from deleting their existing response.
|
||||
- A future cross-device draft feature should introduce a server-side draft entity
|
||||
rather than changing the meaning of the current local draft.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Glossary
|
||||
|
||||
## Form
|
||||
|
||||
An administrator-authored questionnaire with an open or closed state and optional
|
||||
Discord role restrictions.
|
||||
|
||||
## Question
|
||||
|
||||
An ordered prompt within a form. Its answer shape is text, long text, one choice, or
|
||||
multiple choices.
|
||||
|
||||
## Local draft
|
||||
|
||||
Unsubmitted or in-progress answers stored in the current browser for one form. A local
|
||||
draft is device-specific and is not authoritative.
|
||||
|
||||
## Submission
|
||||
|
||||
The server-saved answer set for one form and one Discord account. A person can have at
|
||||
most one submission per form.
|
||||
|
||||
## Update
|
||||
|
||||
A replacement of the answer values in an existing submission. The previous values are
|
||||
retained in edit history.
|
||||
|
||||
## Other answer
|
||||
|
||||
Free text associated with an explicit “Other” choice. Selecting “Other” requires
|
||||
non-empty text, even when the overall question is optional.
|
||||
|
||||
## Closed form
|
||||
|
||||
A form that no longer accepts new submissions or updates. A person with an existing
|
||||
submission may still read or delete it.
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
decodeCheckboxAnswer,
|
||||
encodeAnswerValue,
|
||||
formatStoredAnswer,
|
||||
} from "@/lib/form-answer-values";
|
||||
|
||||
describe("form answer values", () => {
|
||||
test("round-trips checkbox choices containing commas", () => {
|
||||
const choices = ["Alpha, beta", "Gamma"];
|
||||
const stored = encodeAnswerValue(choices);
|
||||
|
||||
expect(decodeCheckboxAnswer(stored)).toEqual(choices);
|
||||
expect(formatStoredAnswer(stored, "checkbox")).toBe("Alpha, beta, Gamma");
|
||||
});
|
||||
|
||||
test("continues to read legacy comma-separated checkbox answers", () => {
|
||||
expect(decodeCheckboxAnswer("Alpha, Beta")).toEqual(["Alpha", "Beta"]);
|
||||
});
|
||||
|
||||
test("does not decode JSON-looking text answers", () => {
|
||||
expect(formatStoredAnswer('["plain text"]', "text")).toBe('["plain text"]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { QuestionType } from "@/db/schema/form";
|
||||
|
||||
export type FormAnswerValue = string | string[];
|
||||
|
||||
export function decodeCheckboxAnswer(value: FormAnswerValue | undefined): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Older checkbox answers were stored as comma-separated text.
|
||||
}
|
||||
|
||||
return value.split(", ").filter(Boolean);
|
||||
}
|
||||
|
||||
export function encodeAnswerValue(value: FormAnswerValue): string {
|
||||
return Array.isArray(value) ? JSON.stringify(value) : value;
|
||||
}
|
||||
|
||||
export function formatStoredAnswer(
|
||||
value: string | undefined,
|
||||
questionType: QuestionType | string
|
||||
): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return questionType === "checkbox"
|
||||
? decodeCheckboxAnswer(value).join(", ")
|
||||
: value;
|
||||
}
|
||||
Reference in New Issue
Block a user