perf(admin): lazy-load response navigation

This commit is contained in:
2026-08-09 22:01:24 +07:00 Unverified
parent 78373b76cc
commit 4eb8296135
4 changed files with 216 additions and 77 deletions
+7 -40
View File
@@ -7,7 +7,7 @@ import { getSessionDiscordId } from "@/lib/auth/auth";
import { getDiscordMemberProfile } from "@/lib/discord/discord"; import { getDiscordMemberProfile } from "@/lib/discord/discord";
import { db } from "@/db"; import { db } from "@/db";
import { submissions } from "@/db/schema"; import { submissions } from "@/db/schema";
import { asc, eq } from "drizzle-orm"; import { count, eq } from "drizzle-orm";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { isFormAcceptingSubmissions } from "@/lib/form/form-availability"; import { isFormAcceptingSubmissions } from "@/lib/form/form-availability";
@@ -25,43 +25,21 @@ export default async function AdminFormLayout(
} }
) { ) {
const params = await props.params; const params = await props.params;
const [session, form] = await Promise.all([ const [session, form, [{ responseCount }]] = await Promise.all([
getServerSession(authOptions), getServerSession(authOptions),
db.query.forms.findFirst({ db.query.forms.findFirst({
where: (record, { eq }) => eq(record.id, params.id), where: (record, { eq }) => eq(record.id, params.id),
}), }),
db
.select({ responseCount: count() })
.from(submissions)
.where(eq(submissions.formId, params.id)),
]); ]);
if (!form) { if (!form) {
notFound(); 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; let profile = null;
const discordId = getSessionDiscordId(session); const discordId = getSessionDiscordId(session);
if (discordId) { if (discordId) {
@@ -74,18 +52,7 @@ export default async function AdminFormLayout(
<FormAdminSidebar <FormAdminSidebar
formId={params.id} formId={params.id}
formTitle={form.title} formTitle={form.title}
responseItems={responseItems.map((response, index) => ({ responseCount={responseCount}
id: response.id,
displayName:
(response.userDiscordId
? responseProfiles[response.userDiscordId]?.globalName ||
responseProfiles[response.userDiscordId]?.username
: null) ||
response.userName ||
response.userDiscordId ||
"Anonymous",
queueNumber: index + 1,
}))}
isOpen={isFormAcceptingSubmissions(form)} isOpen={isFormAcceptingSubmissions(form)}
userName={session?.user?.name || ""} userName={session?.user?.name || ""}
userImage={session?.user?.image || ""} userImage={session?.user?.image || ""}
@@ -7,6 +7,8 @@ import SubmissionDetailClient from "./client";
import { DeleteSubmissionButton } from "@/components/delete-buttons"; import { DeleteSubmissionButton } from "@/components/delete-buttons";
import { RealtimeRefresh } from "@/components/realtime-refresh"; import { RealtimeRefresh } from "@/components/realtime-refresh";
import { getDiscordMemberProfile } from "@/lib/discord/discord"; import { getDiscordMemberProfile } from "@/lib/discord/discord";
import { submissions } from "@/db/schema";
import { and, asc, desc, eq, gt, lt, or } from "drizzle-orm";
interface SubmissionDetailPageProps { interface SubmissionDetailPageProps {
params: Promise<{ params: Promise<{
@@ -39,18 +41,53 @@ export default async function SubmissionDetailPage({
notFound(); notFound();
} }
const respondentProfile = submission.userDiscordId const [respondentProfile, questions, answersList, [previous], [next]] =
? await getDiscordMemberProfile(submission.userDiscordId) await Promise.all([
: null; submission.userDiscordId
? getDiscordMemberProfile(submission.userDiscordId)
const questions = await db.query.questions.findMany({ : null,
where: (q, { eq }) => eq(q.formId, submission.formId), db.query.questions.findMany({
orderBy: (q, { asc }) => [asc(q.displayOrder)], where: (q, { eq }) => eq(q.formId, submission.formId),
}); orderBy: (q, { asc }) => [asc(q.displayOrder)],
}),
const answersList = await db.query.answers.findMany({ db.query.answers.findMany({
where: (a, { eq }) => eq(a.submissionId, submission.id), 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<string, { value: string; imageCdnId: string | null }> = {}; const answersMap: Record<string, { value: string; imageCdnId: string | null }> = {};
for (const ans of answersList) { for (const ans of answersList) {
@@ -59,14 +96,8 @@ export default async function SubmissionDetailPage({
imageCdnId: ans.imageCdnId ?? null, imageCdnId: ans.imageCdnId ?? null,
}; };
} }
const submissionsList = await db.query.submissions.findMany({ const prevId = previous?.id ?? null;
where: (s, { eq }) => eq(s.formId, formId), const nextId = next?.id ?? null;
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;
return ( return (
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-10"> <div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-10">
+54 -1
View File
@@ -2,7 +2,7 @@
import { db } from "@/db"; import { db } from "@/db";
import { forms, submissions } from "@/db/schema"; 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 { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/auth/auth"; import { requireAdmin } from "@/lib/auth/auth";
@@ -10,6 +10,59 @@ import { sanitizeHtml } from "@/lib/sanitize-html";
import { sse } from "@/lib/realtime/sse"; import { sse } from "@/lib/realtime/sse";
import { isValidDiscordWebhookUrl } from "@/lib/discord/discord-webhook-settings"; 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) { export async function createForm(formData: FormData) {
await requireAdmin(); await requireAdmin();
+104 -16
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useEffect, useState, useTransition } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { import {
Sidebar, Sidebar,
@@ -12,11 +12,13 @@ import {
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarMenu,
SidebarMenuAction, SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
SidebarInput,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
@@ -27,6 +29,7 @@ import {
FilePenLine, FilePenLine,
LayoutDashboard, LayoutDashboard,
Settings, Settings,
Search,
} from "lucide-react"; } from "lucide-react";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { import {
@@ -36,6 +39,10 @@ import {
import { GuardedLink } from "@/components/guarded-link"; import { GuardedLink } from "@/components/guarded-link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { getResponseNavigation } from "@/app/admin/form/actions";
type ResponseNavigationData = Awaited<ReturnType<typeof getResponseNavigation>>;
export function FormAdminSidebar({ export function FormAdminSidebar({
formId, formId,
@@ -44,7 +51,7 @@ export function FormAdminSidebar({
userName, userName,
userImage, userImage,
profile, profile,
responseItems, responseCount,
}: { }: {
formId: string; formId: string;
formTitle: string; formTitle: string;
@@ -52,15 +59,34 @@ export function FormAdminSidebar({
userName?: string; userName?: string;
userImage?: string; userImage?: string;
profile?: DiscordMemberProfile | null; profile?: DiscordMemberProfile | null;
responseItems?: { responseCount: number;
id: string;
displayName: string;
queueNumber: number;
}[];
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const responseList = responseItems ?? []; const [responsesOpen, setResponsesOpen] = useState(
const [responsesOpen, setResponsesOpen] = useState(true); pathname.startsWith(`/admin/form/${formId}/result/`),
);
const [responseQuery, setResponseQuery] = useState("");
const [responseData, setResponseData] =
useState<ResponseNavigationData | null>(null);
const [responseError, setResponseError] = useState<string | null>(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`) const currentSection = pathname.startsWith(`/admin/form/${formId}/result`)
? "Responses" ? "Responses"
: pathname.startsWith(`/admin/form/${formId}/extra`) : pathname.startsWith(`/admin/form/${formId}/extra`)
@@ -154,8 +180,12 @@ export function FormAdminSidebar({
<span>{label}</span> <span>{label}</span>
</GuardedLink> </GuardedLink>
</SidebarMenuButton> </SidebarMenuButton>
{label === "Responses" && responseList.length > 0 && ( {label === "Responses" && responseCount > 0 && (
<SidebarMenuBadge>{responseCount}</SidebarMenuBadge>
)}
{label === "Responses" && responseCount > 0 && (
<SidebarMenuAction <SidebarMenuAction
className="right-8"
onClick={() => setResponsesOpen((open) => !open)} onClick={() => setResponsesOpen((open) => !open)}
aria-label={`${responsesOpen ? "Collapse" : "Expand"} responses`} aria-label={`${responsesOpen ? "Collapse" : "Expand"} responses`}
aria-expanded={responsesOpen} aria-expanded={responsesOpen}
@@ -170,10 +200,34 @@ export function FormAdminSidebar({
)} )}
{label === "Responses" && {label === "Responses" &&
responsesOpen && responsesOpen &&
responseList.length > 0 && ( responseCount > 0 && (
<ScrollArea> <div className="mt-1 flex flex-col gap-2 group-data-[collapsible=icon]:hidden">
<SidebarMenuSub> <form
{responseList.map((response) => { className="flex items-center gap-1 px-2"
onSubmit={(event) => {
event.preventDefault();
loadResponses(1);
}}
>
<SidebarInput
value={responseQuery}
onChange={(event) => setResponseQuery(event.target.value)}
placeholder="Search responses"
aria-label="Search response navigation"
/>
<Button
type="submit"
size="icon-sm"
variant="ghost"
disabled={isLoadingResponses}
aria-label="Search responses"
>
<Search />
</Button>
</form>
<ScrollArea className="max-h-[min(50vh,28rem)]">
<SidebarMenuSub>
{responseData?.items.map((response) => {
const responseHref = `/admin/form/${formId}/result/${response.id}`; const responseHref = `/admin/form/${formId}/result/${response.id}`;
return ( return (
@@ -191,8 +245,42 @@ export function FormAdminSidebar({
</SidebarMenuSubItem> </SidebarMenuSubItem>
); );
})} })}
</SidebarMenuSub> {isLoadingResponses && !responseData && (
</ScrollArea> <SidebarMenuSubItem className="px-2 py-1 text-xs text-muted-foreground">
Loading responses…
</SidebarMenuSubItem>
)}
{responseError && (
<SidebarMenuSubItem className="px-2 py-1 text-xs text-destructive">
{responseError}
</SidebarMenuSubItem>
)}
</SidebarMenuSub>
</ScrollArea>
{responseData && responseData.pageCount > 1 && (
<div className="flex items-center justify-between gap-1 px-2">
<Button
size="sm"
variant="ghost"
disabled={isLoadingResponses || responseData.page === 1}
onClick={() => loadResponses(responseData.page - 1)}
>
Previous
</Button>
<span className="text-xs text-muted-foreground">
{responseData.page} / {responseData.pageCount}
</span>
<Button
size="sm"
variant="ghost"
disabled={isLoadingResponses || responseData.page === responseData.pageCount}
onClick={() => loadResponses(responseData.page + 1)}
>
Next
</Button>
</div>
)}
</div>
)} )}
</SidebarMenuItem> </SidebarMenuItem>
))} ))}