feat: add allowOther field to questions and update image upload to return data URIs
This commit is contained in:
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user