refactor: fix ai slop
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user