Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/blade/src/app/admin/forms/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export default async function FormEditorPage({

return (
<>
<div>{JSON.stringify(accessCheck)}</div>
<EditorClient procs={extractProcedures(appRouter)} slug={params.slug} />
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { FormType } from "@forge/consts/knight-hacks";

import { FileUploadResponsesTable } from "./FileUploadResponsesTable";
import { ResponseBarChart } from "./ResponseBarChart";
import { ResponseHorizontalBarChart } from "./ResponseHorizontalBarChart";
import { ResponsePieChart } from "./ResponsePieChart";
import { ResponsesTable } from "./ResponsesTable";

interface AllResponsesViewProps {
formData: FormType;
responses: {
submittedAt: Date;
responseData: Record<string, unknown>;
member: {
firstName: string;
lastName: string;
email: string;
id: string;
} | null;
}[];
}

export function AllResponsesView({
formData,
responses,
}: AllResponsesViewProps) {
return (
<>
{/* charts section , shows aggregated data visualization */}
{/* space-y-2 on mobile, space-y-6 on desktop adds vertical spacing between charts */}
{/* max-w-4xl mx-auto centers the charts and limits width */}
<div className="mx-auto max-w-4xl space-y-2 md:space-y-6">
{formData.questions.map((question) => {
// render pie chart for MULTIPLE_CHOICE or DROPDOWN questions
if (
question.type === "MULTIPLE_CHOICE" ||
question.type === "DROPDOWN"
) {
return (
<ResponsePieChart
key={question.question}
question={question.question}
responses={responses}
/>
);
}

// render bar chart for LINEAR_SCALE or NUMBER questions
if (question.type === "LINEAR_SCALE" || question.type === "NUMBER") {
return (
<ResponseBarChart
key={question.question}
question={question.question}
responses={responses}
/>
);
}

// render horizontal bar chart for CHECKBOXES questions
if (question.type === "CHECKBOXES") {
return (
<ResponseHorizontalBarChart
key={question.question}
question={question.question}
responses={responses}
/>
);
}

return null;
})}
</div>

{/* text responses section - for SHORT_ANSWER, PARAGRAPH, EMAIL, and PHONE questions */}
{/* renders a separate table for each text-based question */}
<div className="mx-auto mt-3 max-w-4xl space-y-2 md:mt-8 md:space-y-6">
{formData.questions.map((question) => {
// render table for SHORT_ANSWER, PARAGRAPH, EMAIL, or PHONE questions
if (
question.type === "SHORT_ANSWER" ||
question.type === "PARAGRAPH" ||
question.type === "EMAIL" ||
question.type === "PHONE"
) {
return (
<ResponsesTable
key={question.question}
question={question.question}
responses={responses}
/>
);
}

return null;
})}
</div>
{/* date and time responses section - for DATE and TIME questions */}
{/* renders a separate table for each date/time question */}
<div className="mx-auto mt-3 max-w-4xl space-y-2 md:mt-8 md:space-y-6">
{formData.questions.map((question) => {
// render table for DATE or TIME questions
if (question.type === "DATE" || question.type === "TIME") {
return (
<ResponsesTable
key={question.question}
question={question.question}
responses={responses}
/>
);
}

return null;
})}
</div>
<div className="mx-auto mt-3 max-w-4xl space-y-2 md:mt-8 md:space-y-6">
{formData.questions.map((question) => {
if (question.type === "FILE_UPLOAD") {
return (
<FileUploadResponsesTable
key={question.question}
question={question.question}
responses={responses}
/>
);
}

return null;
})}
</div>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"use client";

import { useState } from "react";
import {
Code,
Download,
File,
FileSpreadsheet,
FileText,
Loader2,
} from "lucide-react";

import { Button } from "@forge/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@forge/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@forge/ui/table";
import { toast } from "@forge/ui/toast";

import { api } from "~/trpc/react";

interface FileUploadResponsesTableProps {
question: string;
responses: {
submittedAt: Date;
responseData: Record<string, unknown>;
member: {
firstName: string;
lastName: string;
email: string;
id: string;
} | null;
}[];
}

export function FileUploadResponsesTable({
question,
responses,
}: FileUploadResponsesTableProps) {
if (responses.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>{question}</CardTitle>
</CardHeader>
<CardContent>
<p className="py-8 text-center text-muted-foreground">
No Responses yet.
</p>
</CardContent>
</Card>
);
}

return (
<Card>
<CardHeader>
<CardTitle>{question}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{responses.length} {responses.length === 1 ? "response" : "responses"}
</p>
</CardHeader>
<CardContent>
<div className="max-h-[500px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead className="min-w-[150px]">Name</TableHead>
<TableHead className="min-w-[150px]">Email</TableHead>
<TableHead className="min-w-[200px]">File</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{responses.map((response, responseIndex) => {
const objectName = response.responseData[question] as
| string
| undefined;

if (!objectName || typeof objectName !== "string") {
return (
<TableRow key={responseIndex}>
<TableCell>
{response.member
? `${response.member.firstName} ${response.member.lastName}`
: "Anonymous"}
</TableCell>
<TableCell>{response.member?.email ?? "N/A"}</TableCell>
<TableCell className="max-w-[500px]">—</TableCell>
</TableRow>
);
}

return (
<FileUploadRow
key={responseIndex}
objectName={objectName}
member={response.member}
/>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

function FileUploadRow({
objectName,
member,
}: {
objectName: string;
member: {
firstName: string;
lastName: string;
email: string;
id: string;
} | null;
}) {
const [isDownloading, setIsDownloading] = useState(false);

const fullFileName = objectName.split("/").pop() || "file";
const cleanFileName = fullFileName.replace(/^\d+-/, "");
const fileName = cleanFileName || fullFileName;
const fileExtension = fileName.split(".").pop()?.toLowerCase() || "";
const isPdf = fileExtension === "pdf";
const isCsv = fileExtension === "csv";
const isJson = fileExtension === "json";
const isMarkdown = ["md", "markdown"].includes(fileExtension);
const isText = fileExtension === "txt";

const getFileUrlMutation = api.forms.getFileUrl.useMutation();

const getFileIcon = () => {
if (isPdf) return <FileText className="h-5 w-5" />;
if (isCsv) return <FileSpreadsheet className="h-5 w-5" />;
if (isJson || isMarkdown || isText) return <Code className="h-5 w-5" />;
return <File className="h-5 w-5" />;
};

const handleDownload = async () => {
if (isDownloading) return;

setIsDownloading(true);
try {
const result = await getFileUrlMutation.mutateAsync({ objectName });
if (result.viewUrl) {
const link = document.createElement("a");
link.href = result.viewUrl;
link.download = fileName;
link.target = "_blank";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
} catch {
toast.error("Failed to download file. Please try again.");
} finally {
setIsDownloading(false);
}
};

return (
<TableRow>
<TableCell>
{member ? `${member.firstName} ${member.lastName}` : "Anonymous"}
</TableCell>
<TableCell>{member?.email ?? "N/A"}</TableCell>
<TableCell className="max-w-[500px]">
<div className="flex items-center gap-2">
{getFileIcon()}
<span className="flex-1 truncate text-sm">{fileName}</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
View
</>
)}
</Button>
</div>
{getFileUrlMutation.isError && (
<p className="mt-1 text-xs text-destructive">
{getFileUrlMutation.error.message}
</p>
)}
</TableCell>
</TableRow>
);
}
Loading
Loading