feat: schedule automatic form closure
This commit is contained in:
@@ -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
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
<FieldSet className="gap-3">
|
||||
<FieldLegend variant="label">
|
||||
Automatically close submissions
|
||||
</FieldLegend>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="form-close-date">Date and time</FieldLabel>
|
||||
<DateTimePicker
|
||||
id="form-close-date"
|
||||
value={selectedCloseDateTime}
|
||||
onChange={(date) =>
|
||||
handleClosesAtChange(
|
||||
date ? format(date, "yyyy-MM-dd'T'HH:mm") : ""
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<FieldDescription>
|
||||
Changes are saved automatically. Leave blank for no deadline.
|
||||
The selected date and time use your device's time zone.
|
||||
</FieldDescription>
|
||||
</FieldSet>
|
||||
<Field orientation="horizontal">
|
||||
<Switch
|
||||
id="allow-multiple-submissions"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getSessionDiscordId } from "@/lib/auth";
|
||||
import { getDiscordMemberProfile } from "@/lib/discord";
|
||||
import { db } from "@/db";
|
||||
import { notFound } from "next/navigation";
|
||||
import { isFormAcceptingSubmissions } from "@/lib/form-availability";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -45,7 +46,7 @@ export default async function AdminFormLayout(
|
||||
<FormAdminSidebar
|
||||
formId={params.id}
|
||||
formTitle={form.title}
|
||||
isOpen={form.isOpen}
|
||||
isOpen={isFormAcceptingSubmissions(form)}
|
||||
userName={session?.user?.name || ""}
|
||||
userImage={session?.user?.image || ""}
|
||||
profile={profile}
|
||||
|
||||
@@ -28,7 +28,13 @@ export default async function AdminDashboardPage(
|
||||
<>
|
||||
<RealtimeRefresh topic="forms" formId={form.id} />
|
||||
<div className="mx-auto w-full max-w-6xl p-4 pb-12 md:p-6">
|
||||
<FormEditorClient form={form} initialQuestions={questionsList} />
|
||||
<FormEditorClient
|
||||
form={{
|
||||
...form,
|
||||
closesAt: form.closesAt?.toISOString() ?? null,
|
||||
}}
|
||||
initialQuestions={questionsList}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ export default async function AdminFormPreviewPage({
|
||||
<Badge variant="secondary">Admin preview</Badge>
|
||||
</div>
|
||||
<FormFillerClient
|
||||
form={{ ...form, isOpen: true }}
|
||||
form={{ ...form, isOpen: true, closesAt: null }}
|
||||
questions={questions}
|
||||
totalAnswers={0}
|
||||
queueNumber={null}
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function createDefaultForm() {
|
||||
redirect(`/admin/form/${form.id}`);
|
||||
}
|
||||
|
||||
export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; allowMultipleSubmissions?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) {
|
||||
export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; closesAt?: string | null; allowMultipleSubmissions?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) {
|
||||
await requireAdmin();
|
||||
if (data.title !== undefined && !data.title.trim()) {
|
||||
throw new Error("Form title is required.");
|
||||
@@ -59,8 +59,18 @@ export async function updateForm(id: string, data: { title?: string; description
|
||||
throw new Error("Enter a valid Discord webhook URL.");
|
||||
}
|
||||
|
||||
const closesAt = data.closesAt === undefined
|
||||
? undefined
|
||||
: data.closesAt === null
|
||||
? null
|
||||
: new Date(data.closesAt);
|
||||
if (closesAt instanceof Date && Number.isNaN(closesAt.getTime())) {
|
||||
throw new Error("Enter a valid closing date and time.");
|
||||
}
|
||||
|
||||
const nextData = {
|
||||
...data,
|
||||
closesAt,
|
||||
description: data.description === undefined
|
||||
? undefined
|
||||
: data.description
|
||||
|
||||
+14
-7
@@ -17,6 +17,7 @@ import { createDefaultForm } from "./actions";
|
||||
import { RealtimeRefresh } from "@/components/realtime-refresh";
|
||||
import { getFormSubmissionStats } from "@/lib/form-submission-stats";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { isFormAcceptingSubmissions } from "@/lib/form-availability";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -67,7 +68,9 @@ export default async function AdminFormsPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
formsList.map((form) => (
|
||||
formsList.map((form) => {
|
||||
const isAccepting = isFormAcceptingSubmissions(form);
|
||||
return (
|
||||
<TableRow key={form.id} className="group hover:bg-muted/50 transition-colors">
|
||||
<TableCell className="p-0 align-middle">
|
||||
<Link href={`/admin/form/${form.id}`} className="flex items-center p-3 font-medium h-full w-full">
|
||||
@@ -76,7 +79,7 @@ export default async function AdminFormsPage() {
|
||||
</TableCell>
|
||||
<TableCell className="p-0 align-middle">
|
||||
<Link href={`/admin/form/${form.id}`} className="flex items-center p-3 h-full w-full">
|
||||
{form.isOpen ? (
|
||||
{isAccepting ? (
|
||||
<Badge variant="default">Open</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Closed</Badge>
|
||||
@@ -106,21 +109,24 @@ export default async function AdminFormsPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{formsList.map((form) => (
|
||||
{formsList.map((form) => {
|
||||
const isAccepting = isFormAcceptingSubmissions(form);
|
||||
return (
|
||||
<Card key={form.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CardTitle className="text-base">
|
||||
<Link href={`/admin/form/${form.id}`}>{form.title}</Link>
|
||||
</CardTitle>
|
||||
<Badge variant={form.isOpen ? "default" : "secondary"}>
|
||||
{form.isOpen ? "Open" : "Closed"}
|
||||
<Badge variant={isAccepting ? "default" : "secondary"}>
|
||||
{isAccepting ? "Open" : "Closed"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -134,7 +140,8 @@ export default async function AdminFormsPage() {
|
||||
<DeleteFormButton formId={form.id} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{formsList.length === 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user