ubiquitous-invention/apps/web/components/forms/form-responses.tsx

138 lines
4.3 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
import { ExternalLink, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/trpc";
import { usePanelStore } from "@/lib/stores/panel-store";
import { cn } from "@/lib/utils";
import type { FormField } from "./form-renderer";
function formatCellValue(value: unknown): string {
if (value === undefined || value === null) return "—";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
return value.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ");
}
try {
return JSON.stringify(value);
} catch {
return "—";
}
}
function tableColumns(fields: FormField[]): FormField[] {
return fields.filter((f) => f.type !== "section_header" && f.type !== "divider");
}
export interface FormResponsesProps {
formId: string;
fields: FormField[];
className?: string;
}
export function FormResponses({ formId, fields, className }: FormResponsesProps) {
const open = usePanelStore((s) => s.open);
const cols = React.useMemo(() => tableColumns(fields), [fields]);
const listQuery = api.forms.listResponses.useQuery(
{ formId },
{ enabled: Boolean(formId) },
);
if (listQuery.isPending) {
return (
<div className={cn("flex justify-center py-12", className)}>
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
<span className="sr-only">Loading responses</span>
</div>
);
}
if (listQuery.isError) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>
Could not load responses.
</p>
);
}
const rows = listQuery.data?.responses ?? [];
if (rows.length === 0) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>No responses yet.</p>
);
}
return (
<div className={cn("w-full overflow-x-auto rounded-md border border-border", className)}>
<table className="w-full min-w-[640px] border-collapse text-left text-sm">
<thead>
<tr className="border-b border-border bg-muted/40">
{cols.map((f) => (
<th
key={f.id}
className="whitespace-nowrap px-3 py-2 font-medium text-muted-foreground"
>
{f.label}
</th>
))}
<th className="whitespace-nowrap px-3 py-2 font-medium text-muted-foreground">
Submitted
</th>
<th className="whitespace-nowrap px-3 py-2 font-medium text-muted-foreground">
Task
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const data =
row.data && typeof row.data === "object" && row.data !== null
? (row.data as Record<string, unknown>)
: {};
const submitted =
row.submittedAt instanceof Date
? row.submittedAt.toLocaleString()
: String(row.submittedAt ?? "—");
return (
<tr key={row.id} className="border-b border-border last:border-0">
{cols.map((f) => (
<td key={f.id} className="max-w-[240px] truncate px-3 py-2 align-top">
{formatCellValue(data[f.id])}
</td>
))}
<td className="whitespace-nowrap px-3 py-2 align-top text-muted-foreground">
{submitted}
</td>
<td className="px-3 py-2 align-top">
{row.createdObjectId ? (
<Button
type="button"
variant="link"
className="inline-flex h-auto items-center gap-1 p-0 text-primary"
onClick={() => open("object-detail", row.createdObjectId)}
>
Open task
<ExternalLink className="size-3.5 opacity-70" aria-hidden />
</Button>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}