diff --git a/app/admin/form/[id]/layout.tsx b/app/admin/form/[id]/layout.tsx index bfe8278..80c556a 100644 --- a/app/admin/form/[id]/layout.tsx +++ b/app/admin/form/[id]/layout.tsx @@ -7,7 +7,7 @@ import { getSessionDiscordId } from "@/lib/auth/auth"; import { getDiscordMemberProfile } from "@/lib/discord/discord"; import { db } from "@/db"; import { submissions } from "@/db/schema"; -import { asc, eq } from "drizzle-orm"; +import { count, eq } from "drizzle-orm"; import { notFound } from "next/navigation"; import { isFormAcceptingSubmissions } from "@/lib/form/form-availability"; @@ -25,43 +25,21 @@ export default async function AdminFormLayout( } ) { const params = await props.params; - const [session, form] = await Promise.all([ + const [session, form, [{ responseCount }]] = await Promise.all([ getServerSession(authOptions), db.query.forms.findFirst({ where: (record, { eq }) => eq(record.id, params.id), }), + db + .select({ responseCount: count() }) + .from(submissions) + .where(eq(submissions.formId, params.id)), ]); if (!form) { notFound(); } - const responseItems = await db - .select({ - id: submissions.id, - userName: submissions.userName, - userDiscordId: submissions.userDiscordId, - }) - .from(submissions) - .where(eq(submissions.formId, params.id)) - .orderBy(asc(submissions.submittedAt), asc(submissions.id)); - - const responseDiscordIds = [ - ...new Set( - responseItems - .map((response) => response.userDiscordId) - .filter((value): value is string => Boolean(value)) - ), - ]; - const responseProfiles = Object.fromEntries( - await Promise.all( - responseDiscordIds.map(async (discordId) => [ - discordId, - await getDiscordMemberProfile(discordId), - ]) - ) - ); - let profile = null; const discordId = getSessionDiscordId(session); if (discordId) { @@ -74,18 +52,7 @@ export default async function AdminFormLayout( ({ - id: response.id, - displayName: - (response.userDiscordId - ? responseProfiles[response.userDiscordId]?.globalName || - responseProfiles[response.userDiscordId]?.username - : null) || - response.userName || - response.userDiscordId || - "Anonymous", - queueNumber: index + 1, - }))} + responseCount={responseCount} isOpen={isFormAcceptingSubmissions(form)} userName={session?.user?.name || ""} userImage={session?.user?.image || ""} diff --git a/app/admin/form/[id]/result/[submissionId]/page.tsx b/app/admin/form/[id]/result/[submissionId]/page.tsx index d1aeb3f..9e41efe 100644 --- a/app/admin/form/[id]/result/[submissionId]/page.tsx +++ b/app/admin/form/[id]/result/[submissionId]/page.tsx @@ -7,6 +7,8 @@ import SubmissionDetailClient from "./client"; import { DeleteSubmissionButton } from "@/components/delete-buttons"; import { RealtimeRefresh } from "@/components/realtime-refresh"; import { getDiscordMemberProfile } from "@/lib/discord/discord"; +import { submissions } from "@/db/schema"; +import { and, asc, desc, eq, gt, lt, or } from "drizzle-orm"; interface SubmissionDetailPageProps { params: Promise<{ @@ -39,18 +41,53 @@ export default async function SubmissionDetailPage({ notFound(); } - const respondentProfile = submission.userDiscordId - ? await getDiscordMemberProfile(submission.userDiscordId) - : null; - - const questions = await db.query.questions.findMany({ - where: (q, { eq }) => eq(q.formId, submission.formId), - orderBy: (q, { asc }) => [asc(q.displayOrder)], - }); - - const answersList = await db.query.answers.findMany({ - where: (a, { eq }) => eq(a.submissionId, submission.id), - }); + const [respondentProfile, questions, answersList, [previous], [next]] = + await Promise.all([ + submission.userDiscordId + ? getDiscordMemberProfile(submission.userDiscordId) + : null, + db.query.questions.findMany({ + where: (q, { eq }) => eq(q.formId, submission.formId), + orderBy: (q, { asc }) => [asc(q.displayOrder)], + }), + db.query.answers.findMany({ + where: (a, { eq }) => eq(a.submissionId, submission.id), + }), + db + .select({ id: submissions.id }) + .from(submissions) + .where( + and( + eq(submissions.formId, formId), + or( + gt(submissions.submittedAt, submission.submittedAt), + and( + eq(submissions.submittedAt, submission.submittedAt), + gt(submissions.id, submission.id), + ), + ), + ), + ) + .orderBy(asc(submissions.submittedAt), asc(submissions.id)) + .limit(1), + db + .select({ id: submissions.id }) + .from(submissions) + .where( + and( + eq(submissions.formId, formId), + or( + lt(submissions.submittedAt, submission.submittedAt), + and( + eq(submissions.submittedAt, submission.submittedAt), + lt(submissions.id, submission.id), + ), + ), + ), + ) + .orderBy(desc(submissions.submittedAt), desc(submissions.id)) + .limit(1), + ]); const answersMap: Record = {}; for (const ans of answersList) { @@ -59,14 +96,8 @@ export default async function SubmissionDetailPage({ imageCdnId: ans.imageCdnId ?? null, }; } - const submissionsList = await db.query.submissions.findMany({ - where: (s, { eq }) => eq(s.formId, formId), - orderBy: (s, { desc }) => [desc(s.submittedAt)], - }); - - const currentIndex = submissionsList.findIndex((s) => s.id === submissionId); - const prevId = currentIndex > 0 ? submissionsList[currentIndex - 1].id : null; - const nextId = currentIndex >= 0 && currentIndex < submissionsList.length - 1 ? submissionsList[currentIndex + 1].id : null; + const prevId = previous?.id ?? null; + const nextId = next?.id ?? null; return (
diff --git a/app/admin/form/actions.ts b/app/admin/form/actions.ts index 2a71982..eacee1b 100644 --- a/app/admin/form/actions.ts +++ b/app/admin/form/actions.ts @@ -2,7 +2,7 @@ import { db } from "@/db"; import { forms, submissions } from "@/db/schema"; -import { and, eq } from "drizzle-orm"; +import { and, asc, count, eq, ilike, or } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { requireAdmin } from "@/lib/auth/auth"; @@ -10,6 +10,59 @@ import { sanitizeHtml } from "@/lib/sanitize-html"; import { sse } from "@/lib/realtime/sse"; import { isValidDiscordWebhookUrl } from "@/lib/discord/discord-webhook-settings"; +const RESPONSE_NAVIGATION_PAGE_SIZE = 50; + +export async function getResponseNavigation( + formId: string, + page = 1, + query = "", +) { + await requireAdmin(); + const safeQuery = query.trim().slice(0, 100); + const requestedPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1; + const where = and( + eq(submissions.formId, formId), + safeQuery + ? or( + ilike(submissions.userName, `%${safeQuery}%`), + ilike(submissions.userDiscordId, `%${safeQuery}%`), + ) + : undefined, + ); + const [{ total }] = await db + .select({ total: count() }) + .from(submissions) + .where(where); + const pageCount = Math.max( + 1, + Math.ceil(total / RESPONSE_NAVIGATION_PAGE_SIZE), + ); + const currentPage = Math.min(requestedPage, pageCount); + const rows = await db + .select({ + id: submissions.id, + userName: submissions.userName, + userDiscordId: submissions.userDiscordId, + }) + .from(submissions) + .where(where) + .orderBy(asc(submissions.submittedAt), asc(submissions.id)) + .limit(RESPONSE_NAVIGATION_PAGE_SIZE) + .offset((currentPage - 1) * RESPONSE_NAVIGATION_PAGE_SIZE); + + return { + items: rows.map((row, index) => ({ + id: row.id, + displayName: row.userName || row.userDiscordId || "Anonymous", + queueNumber: + (currentPage - 1) * RESPONSE_NAVIGATION_PAGE_SIZE + index + 1, + })), + page: currentPage, + pageCount, + total, + }; +} + export async function createForm(formData: FormData) { await requireAdmin(); diff --git a/components/form-admin-sidebar.tsx b/components/form-admin-sidebar.tsx index 64cfbba..8e517f7 100644 --- a/components/form-admin-sidebar.tsx +++ b/components/form-admin-sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useEffect, useState, useTransition } from "react"; import { usePathname } from "next/navigation"; import { Sidebar, @@ -12,11 +12,13 @@ import { SidebarHeader, SidebarMenu, SidebarMenuAction, + SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, + SidebarInput, } from "@/components/ui/sidebar"; import { ScrollArea } from "@/components/ui/scroll-area"; import { @@ -27,6 +29,7 @@ import { FilePenLine, LayoutDashboard, Settings, + Search, } from "lucide-react"; import { ThemeToggle } from "@/components/theme-toggle"; import { @@ -36,6 +39,10 @@ import { import { GuardedLink } from "@/components/guarded-link"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { getResponseNavigation } from "@/app/admin/form/actions"; + +type ResponseNavigationData = Awaited>; export function FormAdminSidebar({ formId, @@ -44,7 +51,7 @@ export function FormAdminSidebar({ userName, userImage, profile, - responseItems, + responseCount, }: { formId: string; formTitle: string; @@ -52,15 +59,34 @@ export function FormAdminSidebar({ userName?: string; userImage?: string; profile?: DiscordMemberProfile | null; - responseItems?: { - id: string; - displayName: string; - queueNumber: number; - }[]; + responseCount: number; }) { const pathname = usePathname(); - const responseList = responseItems ?? []; - const [responsesOpen, setResponsesOpen] = useState(true); + const [responsesOpen, setResponsesOpen] = useState( + pathname.startsWith(`/admin/form/${formId}/result/`), + ); + const [responseQuery, setResponseQuery] = useState(""); + const [responseData, setResponseData] = + useState(null); + const [responseError, setResponseError] = useState(null); + const [isLoadingResponses, startResponseTransition] = useTransition(); + + const loadResponses = useCallback((page: number, query = responseQuery) => { + startResponseTransition(async () => { + try { + setResponseError(null); + setResponseData(await getResponseNavigation(formId, page, query)); + } catch { + setResponseError("Could not load responses."); + } + }); + }, [formId, responseQuery]); + + useEffect(() => { + if (responsesOpen && responseCount > 0 && !responseData) { + loadResponses(1, ""); + } + }, [loadResponses, responsesOpen, responseCount, responseData]); const currentSection = pathname.startsWith(`/admin/form/${formId}/result`) ? "Responses" : pathname.startsWith(`/admin/form/${formId}/extra`) @@ -154,8 +180,12 @@ export function FormAdminSidebar({ {label} - {label === "Responses" && responseList.length > 0 && ( + {label === "Responses" && responseCount > 0 && ( + {responseCount} + )} + {label === "Responses" && responseCount > 0 && ( setResponsesOpen((open) => !open)} aria-label={`${responsesOpen ? "Collapse" : "Expand"} responses`} aria-expanded={responsesOpen} @@ -170,10 +200,34 @@ export function FormAdminSidebar({ )} {label === "Responses" && responsesOpen && - responseList.length > 0 && ( - - - {responseList.map((response) => { + responseCount > 0 && ( +
+
{ + event.preventDefault(); + loadResponses(1); + }} + > + setResponseQuery(event.target.value)} + placeholder="Search responses" + aria-label="Search response navigation" + /> + + + + + {responseData?.items.map((response) => { const responseHref = `/admin/form/${formId}/result/${response.id}`; return ( @@ -191,8 +245,42 @@ export function FormAdminSidebar({ ); })} - - + {isLoadingResponses && !responseData && ( + + Loading responses… + + )} + {responseError && ( + + {responseError} + + )} + + + {responseData && responseData.pageCount > 1 && ( +
+ + + {responseData.page} / {responseData.pageCount} + + +
+ )} +
)} ))}