feat: add allowOther field to questions and update image upload to return data URIs

This commit is contained in:
2026-05-30 01:35:33 +07:00 Unverified
parent 114804749c
commit 6a36965e8b
10 changed files with 689 additions and 27 deletions
+4
View File
@@ -37,6 +37,7 @@ export async function createQuestion(
required: data.required,
displayOrder: data.displayOrder,
options: data.options ?? [],
allowOther: false,
});
revalidatePath("/form");
revalidatePath("/form/admin");
@@ -52,6 +53,7 @@ export async function updateQuestion(
displayOrder: number;
options: string[];
imageUrl: string | null;
allowOther: boolean;
}>
) {
await assertAdmin();
@@ -77,6 +79,7 @@ export async function bulkUpdateQuestions(
required: boolean;
displayOrder: number;
options: string[];
allowOther: boolean;
}[]
) {
await assertAdmin();
@@ -92,6 +95,7 @@ export async function bulkUpdateQuestions(
required: u.required,
displayOrder: u.displayOrder,
options: u.options ?? [],
allowOther: u.allowOther,
})
.where(eq(questions.id, u.id))
)
+4 -6
View File
@@ -36,14 +36,12 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Invalid image extension" }, { status: 400 });
}
const filename = `${crypto.randomUUID()}.${originalExt}`;
const uploadDir = path.join(process.cwd(), "public", "form");
await mkdir(uploadDir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(path.join(uploadDir, filename), buffer);
const base64 = buffer.toString("base64");
const mimeType = file.type;
const dataUri = `data:${mimeType};base64,${base64}`;
return NextResponse.json({ url: `/form/${filename}` });
return NextResponse.json({ url: dataUri });
} catch (error: any) {
console.error("Upload error:", error);
return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 });
+26 -9
View File
@@ -31,6 +31,7 @@ interface Question {
displayOrder: number;
required: boolean;
options: string[] | null;
allowOther: boolean;
}
interface FormEditorClientProps {
@@ -102,6 +103,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const formattedUpdates = questions.map(q => ({
...q,
options: q.options === null ? [] : q.options,
allowOther: q.allowOther,
}));
await bulkUpdateQuestions(form.id, formattedUpdates);
toast.success("Questions saved!");
@@ -338,6 +340,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
height={200}
src={q.imageUrl}
alt={q.label}
unoptimized
className="w-32 h-auto rounded-md border object-contain"
/>
<Button
@@ -410,15 +413,29 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
)}
<div className="flex justify-between items-center pt-2 border-t border-border">
<div className="flex items-center space-x-2">
<Switch
id={`required-${q.id}`}
checked={q.required}
onCheckedChange={(checked) => handleUpdateQuestion(q.id, { required: checked })}
/>
<Label htmlFor={`required-${q.id}`} className="text-sm font-normal text-muted-foreground">
Required
</Label>
<div className="flex items-center flex-wrap gap-6">
<div className="flex items-center gap-2">
<Switch
id={`required-${q.id}`}
checked={q.required}
onCheckedChange={(checked) => handleUpdateQuestion(q.id, { required: checked })}
/>
<Label htmlFor={`required-${q.id}`} className="text-sm font-normal text-muted-foreground">
Required
</Label>
</div>
{(q.type === "radio" || q.type === "checkbox") && (
<div className="flex items-center gap-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>
)}
</div>
<Button
variant="ghost"
+5 -5
View File
@@ -122,13 +122,13 @@ export default function SubmissionDetailClient({
)}
</div>
{q.imageUrl && (
<div className="relative max-w-full h-auto max-h-60 overflow-hidden rounded-md border inline-block shrink-0">
<div className="relative w-24 h-24 overflow-hidden rounded-md border shrink-0">
<Image
width={100}
height={100}
src={q.imageUrl}
alt={q.label}
className="object-contain w-auto"
alt="Question image"
fill
unoptimized
className="object-cover"
/>
</div>
)}
+96 -6
View File
@@ -67,6 +67,7 @@ interface Question {
displayOrder: number;
required: boolean;
options: string[] | null;
allowOther: boolean;
}
interface FormFillerClientProps {
@@ -105,7 +106,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
const formSchema = useMemo(() => buildSchema(questions), [questions]);
const defaultValues = useMemo(() => {
const { defaultValues, initialOtherValues } = useMemo(() => {
const serverAnswers = { ...initialAnswers };
let draft: Record<string, any> = {};
try {
@@ -116,16 +117,43 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
const merged = { ...draft, ...serverAnswers };
const vals: Record<string, any> = {};
const others: Record<string, string> = {};
questions.forEach(q => {
const ans = merged[q.id];
if (q.type === "checkbox") {
vals[q.id] = merged[q.id] ? (Array.isArray(merged[q.id]) ? merged[q.id] : merged[q.id].split(", ").filter(Boolean)) : [];
let arr = ans ? (Array.isArray(ans) ? ans : ans.split(", ").filter(Boolean)) : [];
if (q.allowOther && q.options) {
const standardOptions = q.options;
const customAnswers = arr.filter((x: string) => !standardOptions.includes(x) && x !== "__OTHER__");
if (customAnswers.length > 0) {
others[q.id] = customAnswers.join(", ");
}
// If the draft had __OTHER__ or if there's a custom answer from server, ensure __OTHER__ is selected
if (arr.includes("__OTHER__") || customAnswers.length > 0) {
arr = arr.filter((x: string) => standardOptions.includes(x));
arr.push("__OTHER__");
}
}
vals[q.id] = arr;
} else if (q.type === "radio") {
let val = ans || "";
if (q.allowOther && q.options && val !== "" && !q.options.includes(val)) {
if (val !== "__OTHER__") {
others[q.id] = val;
}
val = "__OTHER__";
}
vals[q.id] = val;
} else {
vals[q.id] = merged[q.id] || "";
vals[q.id] = ans || "";
}
});
return vals;
return { defaultValues: vals, initialOtherValues: others };
}, [form.id, initialAnswers, questions]);
const [otherValues, setOtherValues] = useState<Record<string, string>>(initialOtherValues);
const methods = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues,
@@ -170,10 +198,22 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
try {
const formattedAnswers = questions.map((q) => {
const val = values[q.id] as string | string[] | undefined;
let val = values[q.id] as string | string[] | undefined;
if (q.allowOther) {
const customText = otherValues[q.id] || "";
if (Array.isArray(val)) {
val = val.map(v => v === "__OTHER__" ? customText : v);
} else if (val === "__OTHER__") {
val = customText;
}
}
const finalString = Array.isArray(val) ? val.join(", ") : (val || "");
if (q.required && !finalString.trim()) {
throw new Error(`จำเป็นต้องตอบ "${q.label}" (สำหรับตัวเลือก 'อื่นๆ')`);
}
return {
questionId: q.id,
value: Array.isArray(val) ? val.join(", ") : (val || ""),
value: finalString,
};
});
@@ -258,6 +298,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
src={q.imageUrl}
alt={q.label}
fill
unoptimized
className="object-contain rounded-md"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
/>
@@ -314,6 +355,25 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
</Label>
</div>
))}
{q.allowOther && (
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="__OTHER__" id={`${q.id}-other`} />
<Label htmlFor={`${q.id}-other`} className="font-normal cursor-pointer">
อื่นๆ
</Label>
</div>
{field.value === "__OTHER__" && (
<Input
className="ml-6 w-full max-w-xs"
value={otherValues[q.id] || ""}
onChange={(e) => setOtherValues(prev => ({...prev, [q.id]: e.target.value}))}
placeholder="โปรดระบุ"
disabled={!form.isOpen || isSubmitting}
/>
)}
</div>
)}
</RadioGroup>
</FormControl>
)}
@@ -343,6 +403,36 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
</div>
);
})}
{q.allowOther && (
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={`${q.id}-other`}
checked={Array.isArray(field.value) && field.value.includes("__OTHER__")}
disabled={!form.isOpen || isSubmitting}
onCheckedChange={(checked) => {
const current = Array.isArray(field.value) ? field.value : [];
const updated = checked
? [...current, "__OTHER__"]
: current.filter(x => x !== "__OTHER__");
field.onChange(updated);
}}
/>
<Label htmlFor={`${q.id}-other`} className="font-normal cursor-pointer">
อื่นๆ
</Label>
</div>
{Array.isArray(field.value) && field.value.includes("__OTHER__") && (
<Input
className="ml-6 w-full max-w-xs"
value={otherValues[q.id] || ""}
onChange={(e) => setOtherValues(prev => ({...prev, [q.id]: e.target.value}))}
placeholder="โปรดระบุ"
disabled={!form.isOpen || isSubmitting}
/>
)}
</div>
)}
</div>
</FormControl>
)}