refactor: fix ai slop

This commit is contained in:
2026-07-31 22:22:33 +07:00 Unverified
parent 8de32bcb3f
commit 255b895112
8 changed files with 214 additions and 74 deletions
+50 -25
View File
@@ -71,6 +71,11 @@ import {
FieldLegend,
FieldSet,
} from "@/components/ui/field";
import {
getQuestionResponseSettings,
getQuestionResponseType,
type QuestionResponseType,
} from "@/lib/question-response-type";
interface Question {
id: string;
@@ -241,6 +246,16 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
questionsAutoSave.schedule();
};
const handleResponseTypeChange = (
question: Question,
responseType: QuestionResponseType,
) => {
handleUpdateQuestion(
question.id,
getQuestionResponseSettings(responseType, question.required),
);
};
const handleDeleteQuestion = async (id: string) => {
try {
await deleteQuestion(id, form.id);
@@ -504,7 +519,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
</CardTitle>
<CardDescription>
Question {index + 1} · {questionTypeLabel(q.type)}
{q.required ? " · Required" : ""}
{q.required || q.imageAnswerMode === "required" ? " · Required" : ""}
</CardDescription>
</div>
</AccordionTrigger>
@@ -639,29 +654,12 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
/>
</div>
)}
<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>
<Field className="w-full max-w-xs">
<FieldLabel>Answer image</FieldLabel>
<Field className="w-full max-w-sm">
<FieldLabel>Response type</FieldLabel>
<Select
value={q.imageAnswerMode}
value={getQuestionResponseType(q)}
onValueChange={(val) =>
handleUpdateQuestion(q.id, {
imageAnswerMode: val as Question["imageAnswerMode"],
})
handleResponseTypeChange(q, val as QuestionResponseType)
}
>
<SelectTrigger>
@@ -669,13 +667,40 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="none">Off</SelectItem>
<SelectItem value="optional">Optional</SelectItem>
<SelectItem value="required">Required</SelectItem>
<SelectItem value="answer-only">Answer only</SelectItem>
<SelectItem value="answer-optional-image">
Answer + optional image
</SelectItem>
<SelectItem value="image-only">Image only</SelectItem>
<SelectItem value="answer-required-image">
Answer + required image
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>
“Image only” hides the normal answer field. “Answer + required image” requires both.
</FieldDescription>
</Field>
{(q.imageAnswerMode === "none" || q.imageAnswerMode === "optional") && (
<Field orientation="horizontal">
<Switch
id={`required-${q.id}`}
checked={q.required}
onCheckedChange={(checked) =>
handleUpdateQuestion(q.id, { required: checked })
}
/>
<div>
<FieldLabel htmlFor={`required-${q.id}`}>
Require answer
</FieldLabel>
<FieldDescription>
Users must complete the normal answer field.
</FieldDescription>
</div>
</Field>
)}
</div>
{(q.type === "radio" || q.type === "checkbox") && (
@@ -157,15 +157,11 @@ export default function SubmissionDetailClient({
<Card>
<CardHeader>
<CardTitle>Admin note</CardTitle>
<CardDescription>
Private context for administrators. Respondents cannot see this note.
</CardDescription>
<CardTitle>Note</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<Field data-invalid={noteAutoSave.status === "error"}>
<FieldLabel htmlFor="admin-note">Note</FieldLabel>
<Textarea
id="admin-note"
value={adminNote}
@@ -173,13 +169,11 @@ export default function SubmissionDetailClient({
setAdminNote(event.target.value);
noteAutoSave.schedule();
}}
placeholder="Add a private note about this submission..."
maxLength={10_000}
placeholder="This kinda sigma"
aria-invalid={noteAutoSave.status === "error"}
/>
<FieldDescription>
Autosaves after you stop typing. Only administrators can view
this note.
Autosaves after you stop typing.
</FieldDescription>
<SaveStatusIndicator
status={noteAutoSave.status}
@@ -275,12 +269,7 @@ export default function SubmissionDetailClient({
{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 && (
<span className="text-xs font-semibold text-muted-foreground mb-1">
Current Answer:
</span>
)}
<span className="text-sm whitespace-pre-wrap break-all leading-relaxed">
<span className="text-sm whitespace-pre-wrap break-all">
{answerValue}
</span>
</div>
+2 -2
View File
@@ -142,7 +142,7 @@ export default async function ResultsListPage({
<TableHead>Respondent</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Status</TableHead>
<TableHead>Admin note</TableHead>
<TableHead>Note</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -237,7 +237,7 @@ export default async function ResultsListPage({
<CardContent className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">
Admin note
Note
</p>
{submission.adminNote ? (
<p className="line-clamp-3 whitespace-pre-wrap text-sm">
+2 -2
View File
@@ -42,7 +42,7 @@ export default function NotFound() {
<h2 className="text-2xl font-semibold">404 ไม่พบหน้านี้</h2>
<span>{message}</span>
<div className="flex gap-2">
<Link href="/">
<Link href="/form">
<Button
onClick={() => {
goHome(true);
@@ -51,7 +51,7 @@ export default function NotFound() {
variant="destructive"
>
{goingHome ? <Spinner /> : <Home />}
กลับหน้าแรก
กลับหน้าเลือก
</Button>
</Link>
<Link href="https://status.sudloh.com">
+22 -30
View File
@@ -55,6 +55,7 @@ import {
decodeCheckboxAnswer,
type FormAnswerValue,
} from "@/lib/form-answer-values";
import { isImageOnlyResponse } from "@/lib/question-response-type";
export function FormLayoutClient({
userName,
@@ -148,14 +149,6 @@ const buildSchema = (questions: Question[]) => {
return z.object(shape);
};
function isImageOnlyQuestion(question: Question) {
return (
question.imageAnswerMode === "required" &&
!question.required &&
(question.type === "text" || question.type === "textarea")
);
}
function prepareInitialState(
questions: Question[],
answers: Record<string, AnswerDraft | undefined>,
@@ -445,6 +438,7 @@ export function FormFillerClient({
const formData = new FormData();
formData.append("file", file);
setUploadingAnswerId(questionId);
const uploadToastId = toast.loading("กำลังอัปโหลดรูป");
try {
const response = await fetch("/api/upload", {
@@ -457,9 +451,11 @@ export function FormFillerClient({
}
setImageAnswers((current) => ({ ...current, [questionId]: data.id }));
setImageErrors((current) => ({ ...current, [questionId]: null }));
toast.success("อัปโหลดรูปแล้ว");
toast.success("อัปโหลดรูปแล้ว", { id: uploadToastId });
} catch (error) {
toast.error(getErrorMessage(error, "ไม่สามารถอัปโหลดรูปได้"));
toast.error(getErrorMessage(error, "ไม่สามารถอัปโหลดรูปได้"), {
id: uploadToastId,
});
} finally {
setUploadingAnswerId(null);
}
@@ -576,7 +572,7 @@ export function FormFillerClient({
<span>{questions.length} คำถาม</span>
<span>{totalAnswers} คำตอบทั้งหมด</span>
{queueNumber !== null && (
<Badge variant="outline">ลำดับของคุณ #{queueNumber}</Badge>
<Badge variant="outline">ลำดับของคุณ #{queueNumber + 1}</Badge>
)}
</CardDescription>
</CardHeader>
@@ -597,9 +593,11 @@ export function FormFillerClient({
</CardContent>
)}
<CardFooter className="flex-wrap gap-2 border-t">
{questions.some((question) => question.required) && (
{questions.some(
(question) => question.required || question.imageAnswerMode === "required",
) && (
<span className="text-sm text-muted-foreground">
ช่องที่มีป้าย “จำเป็น” ต้องตอบให้ครบ
คำถามที่มี “จำเป็น” ต้องตอบให้ครบ
</span>
)}
{!isAcceptingSubmissions && (
@@ -665,10 +663,10 @@ export function FormFillerClient({
control={control}
name={q.id}
render={({ field, fieldState }) => (
<Card className="shadow-sm">
<Card>
<CardHeader>
<CardDescription>
ข้อ {questionIndex + 1} จาก {questions.length}
ข้อที่ {questionIndex + 1}
</CardDescription>
<div className="flex items-start justify-between gap-4">
<CardTitle
@@ -678,7 +676,7 @@ export function FormFillerClient({
<HtmlDisplay html={q.label} />
</CardTitle>
<div className="flex shrink-0 items-start gap-2">
{q.required && (
{(q.required || q.imageAnswerMode === "required") && (
<Badge variant="destructive">จำเป็น</Badge>
)}
{q.imageUrl && (
@@ -721,7 +719,7 @@ export function FormFillerClient({
</CardHeader>
<CardContent>
<FormItem>
{q.type === "text" && !isImageOnlyQuestion(q) && (
{q.type === "text" && !isImageOnlyResponse(q) && (
<Field data-invalid={fieldState.invalid || undefined}>
<FormControl
aria-describedby={
@@ -741,7 +739,7 @@ export function FormFillerClient({
</Field>
)}
{q.type === "textarea" && !isImageOnlyQuestion(q) && (
{q.type === "textarea" && !isImageOnlyResponse(q) && (
<Field data-invalid={fieldState.invalid || undefined}>
<FormControl
aria-describedby={
@@ -762,7 +760,7 @@ export function FormFillerClient({
</Field>
)}
{q.type === "radio" && q.options && (
{q.type === "radio" && q.options && !isImageOnlyResponse(q) && (
<FieldSet data-invalid={fieldState.invalid || undefined}>
<FieldLegend className="sr-only">
{plainQuestionLabel}
@@ -843,7 +841,7 @@ export function FormFillerClient({
</FieldSet>
)}
{q.type === "checkbox" && q.options && (
{q.type === "checkbox" && q.options && !isImageOnlyResponse(q) && (
<FieldSet data-invalid={fieldState.invalid || undefined}>
<FieldLegend className="sr-only">
{plainQuestionLabel}
@@ -984,12 +982,6 @@ export function FormFillerClient({
: undefined
}
/>
{uploadingAnswerId === q.id && (
<Badge variant="secondary">
<Spinner data-icon="inline-start" />
กำลังอัปโหลด
</Badge>
)}
{imageAnswers[q.id] && (
<Dialog>
<DialogTrigger asChild>
@@ -1016,7 +1008,7 @@ export function FormFillerClient({
{imageAnswers[q.id] && (
<Button
type="button"
variant="ghost"
variant="destructive"
onClick={() => clearAnswerImage(q.id)}
disabled={!isAcceptingSubmissions || isSubmitting}
>
@@ -1121,12 +1113,12 @@ export function FormFillerClient({
<AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-center text-2xl font-bold">
<AlertDialogTitle className="text-2xl">
ส่งคำตอบแล้ว
</AlertDialogTitle>
<AlertDialogDescription>
บันทึกคำตอบของคุณเรียบร้อย คุณอยู่ในคิวที่ #{queueNumber} จากทั้งหมด{" "}
{totalAnswers} คำตอบ และกลับมาแก้ไขได้ตราบใดที่แบบฟอร์มยังเปิดอยู่
บันทึกคำตอบของคุณเรียบร้อย คำตอบของคุณอยู่ลำดับที่ #{(queueNumber ?? 0) + 1} จากทั้งหมด{" "}
{totalAnswers} คำตอบ คุณสามารถแก้ไขได้คำตอบตอนไหนก็ได้ที่แบบฟอร์มยังเปิดอยู่
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import {
getQuestionResponseSettings,
getQuestionResponseType,
isImageOnlyResponse,
} from "@/lib/question-response-type";
describe("question response types", () => {
test("maps stored question settings to the four editor choices", () => {
expect(getQuestionResponseType({ required: true, imageAnswerMode: "none" }))
.toBe("answer-only");
expect(getQuestionResponseType({ required: true, imageAnswerMode: "optional" }))
.toBe("answer-optional-image");
expect(getQuestionResponseType({ required: false, imageAnswerMode: "required" }))
.toBe("image-only");
expect(getQuestionResponseType({ required: true, imageAnswerMode: "required" }))
.toBe("answer-required-image");
});
test("turns each editor choice into its validation settings", () => {
expect(getQuestionResponseSettings("answer-only", false)).toEqual({
required: false,
imageAnswerMode: "none",
});
expect(getQuestionResponseSettings("answer-only", true)).toEqual({
required: true,
imageAnswerMode: "none",
});
expect(getQuestionResponseSettings("answer-optional-image", false)).toEqual({
required: false,
imageAnswerMode: "optional",
});
expect(getQuestionResponseSettings("image-only")).toEqual({
required: false,
imageAnswerMode: "required",
});
expect(getQuestionResponseSettings("answer-required-image")).toEqual({
required: true,
imageAnswerMode: "required",
});
});
test("recognizes only required-image questions without a required answer as image-only", () => {
expect(isImageOnlyResponse({ required: false, imageAnswerMode: "required" })).toBeTrue();
expect(isImageOnlyResponse({ required: true, imageAnswerMode: "required" })).toBeFalse();
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { ImageAnswerMode } from "@/db/schema/form";
export type QuestionResponseType =
| "answer-only"
| "answer-optional-image"
| "image-only"
| "answer-required-image";
type QuestionResponseSettings = {
required: boolean;
imageAnswerMode: ImageAnswerMode;
};
export function getQuestionResponseType(
question: QuestionResponseSettings,
): QuestionResponseType {
if (question.imageAnswerMode === "required") {
return question.required ? "answer-required-image" : "image-only";
}
return question.imageAnswerMode === "optional"
? "answer-optional-image"
: "answer-only";
}
export function getQuestionResponseSettings(
responseType: QuestionResponseType,
currentRequired = true,
): QuestionResponseSettings {
switch (responseType) {
case "answer-optional-image":
return { required: currentRequired, imageAnswerMode: "optional" };
case "image-only":
return { required: false, imageAnswerMode: "required" };
case "answer-required-image":
return { required: true, imageAnswerMode: "required" };
default:
return { required: currentRequired, imageAnswerMode: "none" };
}
}
export function isImageOnlyResponse(question: QuestionResponseSettings) {
return getQuestionResponseType(question) === "image-only";
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test";
import { normalizeSubmissionAnswers } from "@/lib/submission-intake";
const question = {
id: "question-1",
type: "text" as const,
label: "Proof",
options: null,
allowOther: false,
};
describe("image response submission validation", () => {
test("accepts an image-only response without text", () => {
expect(normalizeSubmissionAnswers(
[{ ...question, required: false, imageAnswerMode: "required" }],
[{ questionId: question.id, value: "", imageCdnId: "image-1" }],
)).toEqual([
{
questionId: question.id,
value: "",
imageCdnId: "image-1",
displayValue: "",
},
]);
});
test("requires both parts of an answer + required image response", () => {
expect(() => normalizeSubmissionAnswers(
[{ ...question, required: true, imageAnswerMode: "required" }],
[{ questionId: question.id, value: "", imageCdnId: "image-1" }],
)).toThrow("จำเป็นต้องตอบ");
expect(() => normalizeSubmissionAnswers(
[{ ...question, required: true, imageAnswerMode: "required" }],
[{ questionId: question.id, value: "answer", imageCdnId: null }],
)).toThrow("จำเป็นต้องอัปโหลดรูป");
expect(normalizeSubmissionAnswers(
[{ ...question, required: true, imageAnswerMode: "required" }],
[{ questionId: question.id, value: "answer", imageCdnId: "image-1" }],
)).toHaveLength(1);
});
});