feat: improve form results and harden secrets

This commit is contained in:
2026-07-30 14:48:46 +07:00 Unverified
parent 6b0440602b
commit a014f72d0f
32 changed files with 471 additions and 11 deletions
@@ -1,8 +1,15 @@
"use client";
import React, { useState } from "react";
import React, { useCallback, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import {
ContextMenu,
ContextMenuTrigger,
@@ -12,7 +19,15 @@ import {
ContextMenuItem,
ContextMenuGroup
} from "@/components/ui/context-menu";
import { Copy, Check, Users, Calendar, Clock } from "lucide-react";
import {
AlertCircle,
Calendar,
Check,
Clock,
Copy,
Loader2,
Users,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
@@ -27,6 +42,11 @@ import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
import { formatStoredAnswer } from "@/lib/form-answer-values";
import { normalizeStoredAnswerSnapshot } from "@/lib/submission-intake";
import { updateSubmissionAdminNote } from "@/app/admin/form/actions";
import {
useDebouncedAutoSave,
type AutoSaveStatus,
} from "@/hooks/use-debounced-autosave";
import {
DiscordProfileDropdown,
type DiscordMemberProfile,
@@ -42,10 +62,12 @@ interface Question {
interface SubmissionDetailClientProps {
submission: {
id: string;
formId: string;
userName: string | null;
userDiscordId: string | null;
userProfile: DiscordMemberProfile | null;
submittedAt: Date;
adminNote: string | null;
editHistory?: { editedAt: string; oldAnswers: Record<string, string | { value: string; imageCdnId: string | null }> }[] | null;
};
questions: Question[];
@@ -58,6 +80,7 @@ export default function SubmissionDetailClient({
answers,
}: SubmissionDetailClientProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [adminNote, setAdminNote] = useState(submission.adminNote ?? "");
const handleCopy = (text: string, id: string) => {
if (!text) return;
@@ -72,7 +95,19 @@ export default function SubmissionDetailClient({
const editHistory = Array.isArray(submission.editHistory) ? submission.editHistory : [];
const saveAdminNote = useCallback(
() =>
updateSubmissionAdminNote(
submission.formId,
submission.id,
adminNote
).then(() => undefined),
[adminNote, submission.formId, submission.id]
);
const noteAutoSave = useDebouncedAutoSave({
save: saveAdminNote,
delay: 1000,
});
return (
<div className="space-y-6">
@@ -120,6 +155,42 @@ export default function SubmissionDetailClient({
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Admin note</CardTitle>
<CardDescription>
Private context for administrators. Respondents cannot see this note.
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field data-invalid={noteAutoSave.status === "error"}>
<FieldLabel htmlFor="admin-note">Note</FieldLabel>
<Textarea
id="admin-note"
value={adminNote}
onChange={(event) => {
setAdminNote(event.target.value);
noteAutoSave.schedule();
}}
placeholder="Add a private note about this submission..."
maxLength={10_000}
aria-invalid={noteAutoSave.status === "error"}
/>
<FieldDescription>
Autosaves after you stop typing. Only administrators can view
this note.
</FieldDescription>
<SaveStatusIndicator
status={noteAutoSave.status}
error={noteAutoSave.error}
onRetry={noteAutoSave.retry}
/>
</Field>
</FieldGroup>
</CardContent>
</Card>
{/* Answers List */}
<div className="space-y-4">
{questions.map((q) => {
@@ -289,6 +360,54 @@ export default function SubmissionDetailClient({
);
}
function SaveStatusIndicator({
status,
error,
onRetry,
}: {
status: AutoSaveStatus;
error: string | null;
onRetry: () => Promise<void>;
}) {
if (status === "clean") return null;
return (
<span
className="flex items-center gap-1.5 text-xs text-muted-foreground"
aria-live="polite"
>
{status === "dirty" && "Unsaved"}
{status === "saving" && (
<>
<Loader2 className="animate-spin" />
Saving
</>
)}
{status === "saved" && (
<>
<Check />
Saved
</>
)}
{status === "error" && (
<>
<AlertCircle />
<span>{error || "Could not save the note."}</span>
<Button
type="button"
variant="link"
size="sm"
className="h-auto px-1"
onClick={() => void onRetry().catch(() => undefined)}
>
Retry
</Button>
</>
)}
</span>
);
}
function AnswerImagePreview({ src, label }: { src: string; label: string }) {
const [unavailable, setUnavailable] = useState(false);
@@ -82,6 +82,7 @@ export default async function SubmissionDetailPage({
</div>
<SubmissionDetailClient
key={submission.id}
submission={{
...submission,
userProfile: respondentProfile,
+27 -1
View File
@@ -142,6 +142,7 @@ export default async function ResultsListPage({
<TableHead>Respondent</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Status</TableHead>
<TableHead>Admin note</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -181,6 +182,17 @@ export default async function ResultsListPage({
{edited ? "Edited" : "Original"}
</Badge>
</TableCell>
<TableCell className="max-w-80">
{submission.adminNote ? (
<p className="line-clamp-2 whitespace-pre-wrap text-sm">
{submission.adminNote}
</p>
) : (
<span className="text-sm italic text-muted-foreground">
No note
</span>
)}
</TableCell>
<TableCell className="relative z-10 text-right">
<DeleteSubmissionButton id={submission.id} compact />
</TableCell>
@@ -222,7 +234,21 @@ export default async function ResultsListPage({
{formatDate(submission.submittedAt)}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end">
<CardContent className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">
Admin note
</p>
{submission.adminNote ? (
<p className="line-clamp-3 whitespace-pre-wrap text-sm">
{submission.adminNote}
</p>
) : (
<p className="text-sm italic text-muted-foreground">
No note
</p>
)}
</div>
<div className="relative z-10">
<DeleteSubmissionButton id={submission.id} compact />
</div>