From 6a73b60c483e4cb30b7dee253a80067e6ff7dee5 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 31 Jul 2026 00:54:15 +0700 Subject: [PATCH] feat: schedule automatic form closure --- .gitignore | 4 + app/actions/submissions.ts | 5 +- app/admin/form/[id]/client.tsx | 52 +- app/admin/form/[id]/layout.tsx | 3 +- app/admin/form/[id]/page.tsx | 8 +- app/admin/form/[id]/preview/page.tsx | 2 +- app/admin/form/actions.ts | 12 +- app/admin/form/page.tsx | 21 +- app/form/[id]/page.tsx | 7 +- app/form/client.tsx | 67 ++- app/form/page.tsx | 6 +- components/date-time-picker.tsx | 152 ++++++ components/ui/input-group.tsx | 158 ++++++ db/schema/form.ts | 1 + drizzle/0015_form_closes_at.sql | 1 + drizzle/meta/0015_snapshot.json | 693 +++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/admin-dashboard.ts | 11 +- lib/form-availability.test.ts | 37 ++ lib/form-availability.ts | 14 + 20 files changed, 1226 insertions(+), 35 deletions(-) create mode 100644 components/date-time-picker.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 drizzle/0015_form_closes_at.sql create mode 100644 drizzle/meta/0015_snapshot.json create mode 100644 lib/form-availability.test.ts create mode 100644 lib/form-availability.ts diff --git a/.gitignore b/.gitignore index 019d9c0..ec22680 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ drizzle/* !drizzle/0012_clammy_the_stranger.sql !drizzle/0013_regular_unus.sql !drizzle/0014_lethal_pepper_potts.sql +!drizzle/0015_form_closes_at.sql +!drizzle/meta/ +drizzle/meta/* +!drizzle/meta/0015_snapshot.json diff --git a/app/actions/submissions.ts b/app/actions/submissions.ts index 0ceee68..17c2008 100644 --- a/app/actions/submissions.ts +++ b/app/actions/submissions.ts @@ -14,6 +14,7 @@ import { type StoredAnswerSnapshot, } from "@/lib/submission-intake"; import { submissionWebhookMessage } from "@/lib/submission-webhook"; +import { isFormAcceptingSubmissions } from "@/lib/form-availability"; function getSubmissionResultLink(formId: string, submissionId: string) { const baseUrl = process.env.BASE_URL || process.env.NEXTAUTH_URL; @@ -32,7 +33,9 @@ export async function submitForm( const discordId = await requireDiscordId(); const userName = session.user.name ?? null; const form = await requireFormAccess(formId, discordId); - if (!form.isOpen) throw new Error("This form is disabled for now"); + if (!isFormAcceptingSubmissions(form)) { + throw new Error("This form is closed for submissions"); + } const questionsList = await db.query.questions.findMany({ where: (question, { eq }) => eq(question.formId, formId), diff --git a/app/admin/form/[id]/client.tsx b/app/admin/form/[id]/client.tsx index de395ac..5672f37 100644 --- a/app/admin/form/[id]/client.tsx +++ b/app/admin/form/[id]/client.tsx @@ -20,6 +20,8 @@ import { } from "@/app/actions/questions"; import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Image as ImageIcon, X, Check, GripVertical, AlertCircle } from "lucide-react"; import Image from 'next/image'; +import { format, parseISO } from "date-fns"; +import { DateTimePicker } from "@/components/date-time-picker"; import { RichTextEditor } from "@/components/rich-text-editor"; import { getErrorMessage } from "@/lib/utils"; import { @@ -66,6 +68,8 @@ import { FieldDescription, FieldGroup, FieldLabel, + FieldLegend, + FieldSet, } from "@/components/ui/field"; interface Question { @@ -87,16 +91,28 @@ interface FormEditorClientProps { title: string; description: string | null; isOpen: boolean; + closesAt: string | null; allowMultipleSubmissions: boolean; }; initialQuestions: Question[]; } +function toDateTimeLocalValue(value: string | null) { + if (!value) return ""; + + const date = new Date(value); + const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return localDate.toISOString().slice(0, 16); +} + export default function FormEditorClient({ form, initialQuestions }: FormEditorClientProps) { const router = useRouter(); const [formTitle, setFormTitle] = useState(form.title); const [formDescription, setFormDescription] = useState(form.description || ""); const [formIsOpen, setFormIsOpen] = useState(form.isOpen); + const [closesAt, setClosesAt] = useState( + toDateTimeLocalValue(form.closesAt) + ); const [allowMultipleSubmissions, setAllowMultipleSubmissions] = useState( form.allowMultipleSubmissions ); @@ -108,6 +124,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC const formTitleRef = useRef(formTitle); const formDescriptionRef = useRef(formDescription); const formIsOpenRef = useRef(formIsOpen); + const closesAtRef = useRef(closesAt); const allowMultipleSubmissionsRef = useRef(allowMultipleSubmissions); const questionsRef = useRef(questions); @@ -115,9 +132,10 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC formTitleRef.current = formTitle; formDescriptionRef.current = formDescription; formIsOpenRef.current = formIsOpen; + closesAtRef.current = closesAt; allowMultipleSubmissionsRef.current = allowMultipleSubmissions; questionsRef.current = questions; - }, [allowMultipleSubmissions, formDescription, formIsOpen, formTitle, questions]); + }, [allowMultipleSubmissions, closesAt, formDescription, formIsOpen, formTitle, questions]); const saveForm = useCallback(async () => { const title = formTitleRef.current; @@ -128,6 +146,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC title: title.trim(), description: formDescriptionRef.current, isOpen: formIsOpenRef.current, + closesAt: closesAtRef.current + ? new Date(closesAtRef.current).toISOString() + : null, allowMultipleSubmissions: allowMultipleSubmissionsRef.current, }); router.refresh(); @@ -169,6 +190,14 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC formAutoSave.schedule(); }; + const handleClosesAtChange = (val: string) => { + closesAtRef.current = val; + setClosesAt(val); + formAutoSave.schedule(); + }; + + const selectedCloseDateTime = closesAt ? parseISO(closesAt) : undefined; + const handleAllowMultipleSubmissionsChange = (val: boolean) => { allowMultipleSubmissionsRef.current = val; setAllowMultipleSubmissions(val); @@ -389,6 +418,27 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC Accepting Submissions +
+ + Automatically close submissions + + + Date and time + + handleClosesAtChange( + date ? format(date, "yyyy-MM-dd'T'HH:mm") : "" + ) + } + /> + + + Changes are saved automatically. Leave blank for no deadline. + The selected date and time use your device's time zone. + +
- +
); diff --git a/app/admin/form/[id]/preview/page.tsx b/app/admin/form/[id]/preview/page.tsx index b120a07..aa17503 100644 --- a/app/admin/form/[id]/preview/page.tsx +++ b/app/admin/form/[id]/preview/page.tsx @@ -28,7 +28,7 @@ export default async function AdminFormPreviewPage({ Admin preview ) : ( - formsList.map((form) => ( + formsList.map((form) => { + const isAccepting = isFormAcceptingSubmissions(form); + return ( @@ -76,7 +79,7 @@ export default async function AdminFormsPage() { - {form.isOpen ? ( + {isAccepting ? ( Open ) : ( Closed @@ -106,21 +109,24 @@ export default async function AdminFormsPage() { - )) + ); + }) )}
- {formsList.map((form) => ( + {formsList.map((form) => { + const isAccepting = isFormAcceptingSubmissions(form); + return (
{form.title} - - {form.isOpen ? "Open" : "Closed"} + + {isAccepting ? "Open" : "Closed"}
@@ -134,7 +140,8 @@ export default async function AdminFormsPage() {
- ))} + ); + })} {formsList.length === 0 && ( diff --git a/app/form/[id]/page.tsx b/app/form/[id]/page.tsx index 58639fc..517a11f 100644 --- a/app/form/[id]/page.tsx +++ b/app/form/[id]/page.tsx @@ -11,6 +11,7 @@ import { type FormAnswerValue, } from "@/lib/form-answer-values"; import { getFormSubmissionStats } from "@/lib/form-submission-stats"; +import { isFormAcceptingSubmissions } from "@/lib/form-availability"; export const dynamic = "force-dynamic"; @@ -98,7 +99,11 @@ export default async function FormsSubmitPage(
(null); + const deadlineReached = + closesAtTime !== null && reachedDeadline === closesAtTime; + const isAcceptingSubmissions = + preview || (form.isOpen && !deadlineReached); + + useEffect(() => { + if (closesAtTime === null) return; + + let timeoutId: number | undefined; + const checkDeadline = () => { + const remaining = closesAtTime - Date.now(); + if (remaining <= 0) { + setReachedDeadline(closesAtTime); + return; + } + timeoutId = window.setTimeout( + checkDeadline, + Math.min(remaining, 2_147_483_647) + ); + }; + + const remaining = closesAtTime - Date.now(); + timeoutId = window.setTimeout( + checkDeadline, + Math.max(0, Math.min(remaining, 2_147_483_647)) + ); + return () => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + }; + }, [closesAtTime]); const methods = useForm>({ resolver: zodResolver(formSchema), @@ -332,7 +367,7 @@ export function FormFillerClient({ }, [form.id, isUpdating, preview, questions, reset]); useEffect(() => { - if (preview || !draftReady || !form.isOpen) { + if (preview || !draftReady || !isAcceptingSubmissions) { return; } @@ -348,7 +383,7 @@ export function FormFillerClient({ } catch { queueMicrotask(() => setDraftSaved(false)); } - }, [draftReady, form.id, form.isOpen, getValues, imageAnswers, otherValues, preview, watchedValues]); + }, [draftReady, form.id, getValues, imageAnswers, isAcceptingSubmissions, otherValues, preview, watchedValues]); const switchToSubmission = (submissionId: string | null) => { const submission = submissionId @@ -432,7 +467,7 @@ export function FormFillerClient({ const onSubmit = async (values: z.infer) => { if (preview) return; - if (!form.isOpen) return; + if (!isAcceptingSubmissions) return; for (const question of questions) { const value = values[question.id] as FormAnswerValue | undefined; @@ -519,7 +554,7 @@ export function FormFillerClient({ } }; - if (!form.isOpen && !isUpdating) { + if (!isAcceptingSubmissions && !isUpdating) { return ( @@ -567,7 +602,7 @@ export function FormFillerClient({ ช่องที่มีป้าย “จำเป็น” ต้องตอบให้ครบ )} - {!form.isOpen && ( + {!isAcceptingSubmissions && ( คุณยังดูหรือลบคำตอบเดิมได้ แต่บันทึกการแก้ไขไม่ได้ @@ -699,7 +734,7 @@ export function FormFillerClient({ value={(field.value as string) || ""} aria-labelledby={questionLabelId} aria-required={q.required} - disabled={!form.isOpen || isSubmitting} + disabled={!isAcceptingSubmissions || isSubmitting} /> @@ -720,7 +755,7 @@ export function FormFillerClient({ aria-labelledby={questionLabelId} aria-required={q.required} className="min-h-32 resize-y" - disabled={!form.isOpen || isSubmitting} + disabled={!isAcceptingSubmissions || isSubmitting} /> @@ -743,7 +778,7 @@ export function FormFillerClient({ aria-labelledby={questionLabelId} aria-required={q.required} className="flex flex-col gap-3" - disabled={!form.isOpen || isSubmitting} + disabled={!isAcceptingSubmissions || isSubmitting} > {q.options.map((option, optionIndex) => ( )} @@ -835,7 +870,7 @@ export function FormFillerClient({ { const current = Array.isArray(field.value) ? field.value @@ -867,7 +902,7 @@ export function FormFillerClient({ Array.isArray(field.value) && field.value.includes("__OTHER__") } - disabled={!form.isOpen || isSubmitting} + disabled={!isAcceptingSubmissions || isSubmitting} onCheckedChange={(checked) => { const current = Array.isArray(field.value) ? field.value @@ -910,7 +945,7 @@ export function FormFillerClient({ } className="w-full max-w-xs sm:ml-6" placeholder="โปรดระบุ" - disabled={!form.isOpen || isSubmitting} + disabled={!isAcceptingSubmissions || isSubmitting} /> )} @@ -939,7 +974,7 @@ export function FormFillerClient({ }} type="file" accept="image/png,image/jpeg,image/webp,image/gif" - disabled={!form.isOpen || isSubmitting || uploadingAnswerId !== null} + disabled={!isAcceptingSubmissions || isSubmitting || uploadingAnswerId !== null} onChange={(event) => handleAnswerImageUpload(q.id, event)} className="max-w-sm" aria-invalid={Boolean(imageErrors[q.id])} @@ -983,7 +1018,7 @@ export function FormFillerClient({ type="button" variant="ghost" onClick={() => clearAnswerImage(q.id)} - disabled={!form.isOpen || isSubmitting} + disabled={!isAcceptingSubmissions || isSubmitting} > ลบรูป @@ -1014,7 +1049,7 @@ export function FormFillerClient({

{preview ? "ตัวอย่างสำหรับผู้ดูแลระบบ — ไม่มีการส่งคำตอบ" - : !form.isOpen + : !isAcceptingSubmissions ? "ปิดรับคำตอบแล้ว" : draftSaved ? "บันทึกฉบับร่างไว้ในอุปกรณ์นี้แล้ว" @@ -1061,7 +1096,7 @@ export function FormFillerClient({ )} - {form.isOpen && ( + {isAcceptingSubmissions && ( + + + + + + + + Time (24-hour HH:mm) + + + handleTimeChange(event.target.value)} + onBlur={() => setTimeInput(time)} + /> + + + + + +

+ + +
+ + + + ); +} diff --git a/components/ui/input-group.tsx b/components/ui/input-group.tsx new file mode 100644 index 0000000..55f311c --- /dev/null +++ b/components/ui/input-group.tsx @@ -0,0 +1,158 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-2 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-4xl **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]", + "inline-end": + "order-last pr-3 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]", + "block-start": + "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-3 [.border-b]:pb-3", + "block-end": + "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-3 [.border-t]:pt-3", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((event.target as HTMLElement).closest("button")) { + return + } + event.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 rounded-4xl text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: "", + "icon-xs": "size-6 p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +