perf(admin): lazy-load response navigation
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user