feat: improve form results and harden secrets
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user