diff --git a/apps/blade/src/app/admin/forms/[slug]/page.tsx b/apps/blade/src/app/admin/forms/[slug]/page.tsx index fff9cd54e..b1190a336 100644 --- a/apps/blade/src/app/admin/forms/[slug]/page.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/page.tsx @@ -35,7 +35,6 @@ export default async function FormEditorPage({ return ( <> -
{JSON.stringify(accessCheck)}
); diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/AllResponsesView.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/AllResponsesView.tsx new file mode 100644 index 000000000..685975182 --- /dev/null +++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/AllResponsesView.tsx @@ -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; + 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 */} +
+ {formData.questions.map((question) => { + // render pie chart for MULTIPLE_CHOICE or DROPDOWN questions + if ( + question.type === "MULTIPLE_CHOICE" || + question.type === "DROPDOWN" + ) { + return ( + + ); + } + + // render bar chart for LINEAR_SCALE or NUMBER questions + if (question.type === "LINEAR_SCALE" || question.type === "NUMBER") { + return ( + + ); + } + + // render horizontal bar chart for CHECKBOXES questions + if (question.type === "CHECKBOXES") { + return ( + + ); + } + + return null; + })} +
+ + {/* text responses section - for SHORT_ANSWER, PARAGRAPH, EMAIL, and PHONE questions */} + {/* renders a separate table for each text-based question */} +
+ {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 ( + + ); + } + + return null; + })} +
+ {/* date and time responses section - for DATE and TIME questions */} + {/* renders a separate table for each date/time question */} +
+ {formData.questions.map((question) => { + // render table for DATE or TIME questions + if (question.type === "DATE" || question.type === "TIME") { + return ( + + ); + } + + return null; + })} +
+
+ {formData.questions.map((question) => { + if (question.type === "FILE_UPLOAD") { + return ( + + ); + } + + return null; + })} +
+ + ); +} diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/FileUploadResponsesTable.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/FileUploadResponsesTable.tsx new file mode 100644 index 000000000..8158a785f --- /dev/null +++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/FileUploadResponsesTable.tsx @@ -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; + member: { + firstName: string; + lastName: string; + email: string; + id: string; + } | null; + }[]; +} + +export function FileUploadResponsesTable({ + question, + responses, +}: FileUploadResponsesTableProps) { + if (responses.length === 0) { + return ( + + + {question} + + +

+ No Responses yet. +

+
+
+ ); + } + + return ( + + + {question} +

+ {responses.length} {responses.length === 1 ? "response" : "responses"} +

+
+ +
+ + + + Name + Email + File + + + + {responses.map((response, responseIndex) => { + const objectName = response.responseData[question] as + | string + | undefined; + + if (!objectName || typeof objectName !== "string") { + return ( + + + {response.member + ? `${response.member.firstName} ${response.member.lastName}` + : "Anonymous"} + + {response.member?.email ?? "N/A"} + + + ); + } + + return ( + + ); + })} + +
+
+
+
+ ); +} + +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 ; + if (isCsv) return ; + if (isJson || isMarkdown || isText) return ; + return ; + }; + + 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 ( + + + {member ? `${member.firstName} ${member.lastName}` : "Anonymous"} + + {member?.email ?? "N/A"} + +
+ {getFileIcon()} + {fileName} + +
+ {getFileUrlMutation.isError && ( +

+ {getFileUrlMutation.error.message} +

+ )} +
+
+ ); +} diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/_components/PerUserResponsesView.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/_components/PerUserResponsesView.tsx new file mode 100644 index 000000000..0cefa6011 --- /dev/null +++ b/apps/blade/src/app/admin/forms/[slug]/responses/_components/PerUserResponsesView.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { useState } from "react"; +import { + ChevronLeft, + ChevronRight, + Code, + Download, + File, + FileSpreadsheet, + FileText, + Loader2, +} from "lucide-react"; + +import type { FormType } from "@forge/consts/knight-hacks"; +import { Button } from "@forge/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@forge/ui/card"; +import { Separator } from "@forge/ui/separator"; +import { toast } from "@forge/ui/toast"; + +import { api } from "~/trpc/react"; + +interface PerUserResponsesViewProps { + formData: FormType; + responses: { + submittedAt: Date; + responseData: Record; + member: { + firstName: string; + lastName: string; + email: string; + id: string; + } | null; + }[]; +} + +interface GroupedResponse { + member: { + firstName: string; + lastName: string; + email: string; + id: string; + }; + submittedAt: Date; + responseData: Record; +} + +export function PerUserResponsesView({ + formData, + responses, +}: PerUserResponsesViewProps) { + const groupedByUser = responses.reduce( + (acc, response) => { + if (!response.member) { + const anonymousKey = "anonymous"; + if (!acc[anonymousKey]) { + acc[anonymousKey] = []; + } + acc[anonymousKey].push({ + member: { + firstName: "Anonymous", + lastName: "", + email: "N/A", + id: anonymousKey, + }, + submittedAt: response.submittedAt, + responseData: response.responseData, + }); + return acc; + } + + const userId = response.member.id; + if (!acc[userId]) { + acc[userId] = []; + } + acc[userId].push({ + member: response.member, + submittedAt: response.submittedAt, + responseData: response.responseData, + }); + return acc; + }, + {} as Record, + ); + + const users = Object.values(groupedByUser).sort((a, b) => { + const nameA = `${a[0]?.member.firstName} ${a[0]?.member.lastName}`; + const nameB = `${b[0]?.member.firstName} ${b[0]?.member.lastName}`; + return nameA.localeCompare(nameB); + }); + + const [currentUserIndex, setCurrentUserIndex] = useState(0); + + if (users.length === 0) { + return ( +
+
+

+ No responses yet for this form. +

+

+ Once responses are submitted, they will appear here. +

+
+
+ ); + } + + const currentUserResponses = users[currentUserIndex] ?? []; + const currentUser = currentUserResponses[0]?.member; + + const formatResponseValue = (value: unknown): string => { + if (value === undefined || value === null) { + return "—"; + } + if (Array.isArray(value)) { + return value.join(", "); + } + if (typeof value === "string") { + return value; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return JSON.stringify(value, null, 2); + }; + + return ( +
+ + +
+
+ + {currentUser + ? `${currentUser.firstName} ${currentUser.lastName}` + : "Anonymous"} + +

+ {currentUser?.email} +

+

+ User {currentUserIndex + 1} of {users.length} +

+
+
+ + +
+
+
+
+ + {currentUserResponses.map((response, responseIndex) => ( + + + Response #{responseIndex + 1} +

+ Submitted: {new Date(response.submittedAt).toLocaleString()} +

+
+ + {formData.questions.map((question, questionIndex) => { + const answer = response.responseData[question.question]; + + return ( +
+
+

+ {question.question} + {!question.optional && ( + * + )} +

+ {question.type === "FILE_UPLOAD" && + typeof answer === "string" && + answer ? ( + + ) : ( +

+ {formatResponseValue(answer)} +

+ )} +
+ {questionIndex < formData.questions.length - 1 && ( + + )} +
+ ); + })} +
+
+ ))} +
+ ); +} + +function FileUploadDisplay({ objectName }: { objectName: string }) { + 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 ; + if (isCsv) return ; + if (isJson || isMarkdown || isText) return ; + return ; + }; + + const handleView = 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 view file. Please try again."); + } finally { + setIsDownloading(false); + } + }; + + return ( +
+ {getFileIcon()} + {fileName} + + {getFileUrlMutation.isError && ( +

+ {getFileUrlMutation.error.message} +

+ )} +
+ ); +} diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx index b3231bbd5..a76823e0e 100644 --- a/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx @@ -6,13 +6,12 @@ import { ArrowLeft } from "lucide-react"; import type { FormType } from "@forge/consts/knight-hacks"; import { auth } from "@forge/auth"; import { Button } from "@forge/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@forge/ui/tabs"; import { SIGN_IN_PATH } from "~/consts"; import { api, HydrateClient } from "~/trpc/server"; -import { ResponseBarChart } from "./_components/ResponseBarChart"; -import { ResponseHorizontalBarChart } from "./_components/ResponseHorizontalBarChart"; -import { ResponsePieChart } from "./_components/ResponsePieChart"; -import { ResponsesTable } from "./_components/ResponsesTable"; +import { AllResponsesView } from "./_components/AllResponsesView"; +import { PerUserResponsesView } from "./_components/PerUserResponsesView"; export const metadata: Metadata = { title: "Blade | Form Responses", @@ -112,95 +111,20 @@ export default async function FormResponsesPage({ ) : ( - <> - {/* 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 */} -
- {formData.questions.map((question) => { - // render pie chart for MULTIPLE_CHOICE or DROPDOWN questions - if ( - question.type === "MULTIPLE_CHOICE" || - question.type === "DROPDOWN" - ) { - return ( - - ); - } - - // render bar chart for LINEAR_SCALE or NUMBER questions - if ( - question.type === "LINEAR_SCALE" || - question.type === "NUMBER" - ) { - return ( - - ); - } - - // render horizontal bar chart for CHECKBOXES questions - if (question.type === "CHECKBOXES") { - return ( - - ); - } - - return null; - })} -
- - {/* text responses section - for SHORT_ANSWER and PARAGRAPH questions */} - {/* renders a separate table for each text-based question */} -
- {formData.questions.map((question) => { - // render table for SHORT_ANSWER or PARAGRAPH questions - if ( - question.type === "SHORT_ANSWER" || - question.type === "PARAGRAPH" - ) { - return ( - - ); - } - - return null; - })} -
- {/* date and time responses section - for DATE and TIME questions */} - {/* renders a separate table for each date/time question */} -
- {formData.questions.map((question) => { - // render table for DATE or TIME questions - if (question.type === "DATE" || question.type === "TIME") { - return ( - - ); - } - - return null; - })} + +
+ + All Responses + Per User +
- + + + + + + +
)} diff --git a/apps/blade/src/app/forms/[formName]/_components/form-responder-client.tsx b/apps/blade/src/app/forms/[formName]/_components/form-responder-client.tsx index 2a1a2b360..3db8951b0 100644 --- a/apps/blade/src/app/forms/[formName]/_components/form-responder-client.tsx +++ b/apps/blade/src/app/forms/[formName]/_components/form-responder-client.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { CheckCircle2, Loader2, XCircle } from "lucide-react"; +import { z } from "zod"; import { Button } from "@forge/ui/button"; import { Card } from "@forge/ui/card"; @@ -11,6 +12,9 @@ import { InstructionResponseCard } from "~/app/forms/[formName]/_components/inst import { QuestionResponseCard } from "~/app/forms/[formName]/_components/question-response-card"; import { api } from "~/trpc/react"; +const emailSchema = z.string().email("Invalid email address"); +const phoneSchema = z.string().regex(/^\+?\d{7,15}$/, "Invalid phone number"); + interface FormResponderClientProps { formName: string; userName: string; @@ -26,6 +30,7 @@ export function FormResponderClient({ const [responses, setResponses] = useState< Record >({}); + const [touchedFields, setTouchedFields] = useState>(new Set()); const [isSubmitted, setIsSubmitted] = useState(false); const [showCheckmark, setShowCheckmark] = useState(false); const [showText, setShowText] = useState(false); @@ -173,6 +178,10 @@ export function FormResponderClient({ })); }; + const handleFieldBlur = (questionText: string) => { + setTouchedFields((prev) => new Set(prev).add(questionText)); + }; + const handleSubmit = () => { // Build response data object const responseData: Record = {}; @@ -210,15 +219,78 @@ export function FormResponderClient({ handleCallbacks(responseData); }; + const getValidationError = (question: (typeof form.questions)[number]) => { + if (!touchedFields.has(question.question)) { + return null; + } + + const response = responses[question.question]; + + if (question.optional) { + if ( + !response || + response === "" || + (Array.isArray(response) && response.length === 0) + ) { + return null; + } + } else { + if (response === null || response === undefined || response === "") { + return null; + } + if (Array.isArray(response) && response.length === 0) return null; + } + + if (question.type === "EMAIL" && typeof response === "string") { + const result = emailSchema.safeParse(response); + if (!result.success) { + return "Please enter a valid email address"; + } + } + if (question.type === "PHONE" && typeof response === "string") { + const result = phoneSchema.safeParse(response); + if (!result.success) { + return "Please enter a valid phone number (7-15 digits, optional + prefix)"; + } + } + + return null; + }; + const isFormValid = () => { // Check if all required questions have responses return form.questions.every((question) => { - if (question.optional) return true; // Optional questions don't need validation + if (question.optional) { + const response = responses[question.question]; + if ( + !response || + response === "" || + (Array.isArray(response) && response.length === 0) + ) { + return true; + } + + if (question.type === "EMAIL" && typeof response === "string") { + return emailSchema.safeParse(response).success; + } + if (question.type === "PHONE" && typeof response === "string") { + return phoneSchema.safeParse(response).success; + } + return true; + } const response = responses[question.question]; if (response === null || response === undefined || response === "") return false; if (Array.isArray(response) && response.length === 0) return false; + + if (question.type === "EMAIL" && typeof response === "string") { + return emailSchema.safeParse(response).success; + } + if (question.type === "PHONE" && typeof response === "string") { + return phoneSchema.safeParse(response).success; + } + return true; }); }; @@ -299,6 +371,9 @@ export function FormResponderClient({ ) => { handleResponseChange(item.question, value); }} + onBlur={() => handleFieldBlur(item.question)} + formId={formQuery.data.id} + error={getValidationError(item)} /> )}
diff --git a/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx b/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx index d4d121516..bca4ab517 100644 --- a/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx +++ b/apps/blade/src/app/forms/[formName]/_components/question-response-card.tsx @@ -2,9 +2,12 @@ import type { z } from "zod"; import * as React from "react"; +import { useRef, useState } from "react"; import Image from "next/image"; +import { FileUp, Loader2, X } from "lucide-react"; import type { QuestionValidator } from "@forge/consts/knight-hacks"; +import { Button } from "@forge/ui/button"; import { Card } from "@forge/ui/card"; import { Checkbox } from "@forge/ui/checkbox"; import { DatePicker } from "@forge/ui/date-picker"; @@ -18,7 +21,11 @@ import { SelectTrigger, SelectValue, } from "@forge/ui/select"; +import { Slider } from "@forge/ui/slider"; import { TimePicker } from "@forge/ui/time-picker"; +import { toast } from "@forge/ui/toast"; + +import { api } from "~/trpc/react"; type FormQuestion = z.infer; @@ -26,14 +33,20 @@ interface QuestionResponseCardProps { question: FormQuestion; value?: string | string[] | number | Date | null; onChange: (value: string | string[] | number | Date | null) => void; + onBlur?: () => void; disabled?: boolean; + formId?: string; + error?: string | null; } export function QuestionResponseCard({ question, value, onChange, + onBlur, disabled = false, + formId, + error, }: QuestionResponseCardProps) { const isRequired = !question.optional; @@ -47,6 +60,7 @@ export function QuestionResponseCard({ {isRequired && *} + {error &&

{error}

} {question.image && (
@@ -78,12 +94,16 @@ function QuestionBody({ question, value, onChange, + onBlur, disabled = false, + formId, }: { question: FormQuestion; value?: string | string[] | number | Date | null; onChange: (value: string | string[] | number | Date | null) => void; + onBlur?: () => void; disabled?: boolean; + formId?: string; }) { switch (question.type) { case "SHORT_ANSWER": @@ -172,6 +192,7 @@ function QuestionBody({ placeholder="your.email@example.com" value={(value as string) || ""} onChange={(e) => onChange(e.target.value)} + onBlur={onBlur} disabled={disabled} className="rounded-none border-x-0 border-b border-t-0 border-gray-300 bg-transparent px-0 shadow-none outline-none focus-visible:border-b-2 focus-visible:border-primary focus-visible:ring-0" /> @@ -212,12 +233,33 @@ function QuestionBody({ placeholder="(123) 456-7890" value={(value as string) || ""} onChange={(e) => onChange(e.target.value)} + onBlur={onBlur} disabled={disabled} className="rounded-none border-x-0 border-b border-t-0 border-gray-300 bg-transparent px-0 shadow-none outline-none focus-visible:border-b-2 focus-visible:border-primary focus-visible:ring-0" /> ); + case "LINEAR_SCALE": + return ( + + ); + + case "FILE_UPLOAD": + return ( + + ); + default: return null; } @@ -338,3 +380,215 @@ function DropdownInput({ ); } + +function LinearScaleInput({ + question, + value, + onChange, + disabled = false, +}: { + question: FormQuestion; + value?: number; + onChange: (value: string | string[] | number | Date | null) => void; + disabled?: boolean; +}) { + const min = question.min ?? 0; + const max = question.max ?? 5; + + const defaultValue = Math.floor((min + max) / 2); + + const currentValue = + typeof value === "number" + ? Math.max(min, Math.min(max, value)) + : defaultValue; + + const handleValueChange = (values: number[]) => { + const newValue = values[0] ?? defaultValue; + onChange(newValue); + }; + + const scaleValues = Array.from({ length: max - min + 1 }, (_, i) => min + i); + + return ( +
+
+ +
+
+ {scaleValues.map((value) => ( + + {value} + + ))} +
+
+ ); +} + +function FileUploadInput({ + value, + onChange, + disabled = false, + formId, +}: { + value?: string | null; + onChange: (value: string | string[] | number | Date | null) => void; + disabled?: boolean; + formId?: string; +}) { + const [isUploading, setIsUploading] = useState(false); + const [fileName, setFileName] = useState( + value ? value.split("/").pop() || null : null, + ); + const fileInputRef = useRef(null); + + const getUploadUrlMutation = api.forms.getUploadUrl.useMutation(); + + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const allowedTypes = [ + "image/", + "video/", + "audio/", + "application/pdf", + "text/", + "application/json", + "application/csv", + "text/csv", + "text/markdown", + "text/plain", + ]; + const allowedExtensions = [ + ".pdf", + ".csv", + ".json", + ".md", + ".markdown", + ".txt", + ]; + + const isValidType = + allowedTypes.some((type) => file.type.startsWith(type)) || + allowedExtensions.some((ext) => file.name.toLowerCase().endsWith(ext)); + + if (!isValidType) { + toast.error( + "Invalid file type. Please upload an image, video, audio, PDF, CSV, JSON, Markdown, or text file.", + ); + return; + } + + if (file.size > 100 * 1024 * 1024) { + toast.error("File must be less than 100MB"); + return; + } + + if (!formId) { + toast.error("Form ID is required for file upload"); + return; + } + + let mediaType: "image" | "video" | "file" = "file"; + if (file.type.startsWith("image/")) { + mediaType = "image"; + } else if (file.type.startsWith("video/")) { + mediaType = "video"; + } + + setIsUploading(true); + + try { + const result = await getUploadUrlMutation.mutateAsync({ + fileName: file.name, + formId, + mediaType, + }); + + const uploadResponse = await fetch(result.uploadUrl, { + method: "PUT", + body: file, + headers: { + "Content-Type": file.type, + }, + }); + + if (!uploadResponse.ok) { + throw new Error(`Upload failed: ${uploadResponse.statusText}`); + } + + onChange(result.objectName); + setFileName(file.name); + toast.success("File uploaded successfully!"); + } catch { + toast.error("Failed to upload file. Please try again."); + } finally { + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const handleRemoveFile = () => { + onChange(null); + setFileName(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + return ( +
+ + {!fileName && ( + + )} + {fileName && ( +
+ {fileName} + +
+ )} +
+ ); +} diff --git a/apps/blade/src/components/admin/forms/question-edit-card.tsx b/apps/blade/src/components/admin/forms/question-edit-card.tsx index fbf09bff0..e9a184cc1 100644 --- a/apps/blade/src/components/admin/forms/question-edit-card.tsx +++ b/apps/blade/src/components/admin/forms/question-edit-card.tsx @@ -5,6 +5,7 @@ import type { z } from "zod"; import * as React from "react"; import { AlignLeft, + AtSign, Calendar, CheckSquare, ChevronDown, @@ -12,8 +13,12 @@ import { CircleDot, Clock, Copy, + FileUp, GripHorizontal, + Hash, + Phone, Pilcrow, + SlidersHorizontal, Trash, X, } from "lucide-react"; @@ -26,6 +31,7 @@ import { Card } from "@forge/ui/card"; import { Checkbox } from "@forge/ui/checkbox"; import { DatePicker } from "@forge/ui/date-picker"; import { Input } from "@forge/ui/input"; +import { Label } from "@forge/ui/label"; import { Select, SelectContent, @@ -33,6 +39,7 @@ import { SelectTrigger, SelectValue, } from "@forge/ui/select"; +import { Slider } from "@forge/ui/slider"; import { Textarea } from "@forge/ui/textarea"; import { TimePicker } from "@forge/ui/time-picker"; @@ -56,8 +63,13 @@ const QUESTION_ICONS: Record = { MULTIPLE_CHOICE: CircleDot, CHECKBOXES: CheckSquare, DROPDOWN: ChevronDown, + LINEAR_SCALE: SlidersHorizontal, DATE: Calendar, TIME: Clock, + EMAIL: AtSign, + NUMBER: Hash, + PHONE: Phone, + FILE_UPLOAD: FileUp, }; export function QuestionEditCard({ @@ -86,10 +98,28 @@ export function QuestionEditCard({ updatedQuestion.options = ["Option 1"]; } - if (["SHORT_ANSWER", "PARAGRAPH", "DATE", "TIME"].includes(newType)) { + if ( + [ + "SHORT_ANSWER", + "PARAGRAPH", + "DATE", + "TIME", + "EMAIL", + "NUMBER", + "PHONE", + "LINEAR_SCALE", + "FILE_UPLOAD", + ].includes(newType) + ) { updatedQuestion.options = undefined; } + if (newType === "LINEAR_SCALE" || newType === "NUMBER") { + if (question.min === undefined) updatedQuestion.min = 0; + if (question.max === undefined && newType === "LINEAR_SCALE") + updatedQuestion.max = 5; + } + onUpdate(updatedQuestion); // Trigger auto-save immediately on type change as requested onForceSave?.(); @@ -253,6 +283,52 @@ function QuestionBody({ ); + case "EMAIL": + return ( +
+ +
+ ); + case "NUMBER": + return ( +
+ +
+ ); + case "PHONE": + return ( +
+ +
+ ); + case "LINEAR_SCALE": + return ; + case "FILE_UPLOAD": + return ( +
+ + + File upload (images, videos, audio, PDFs) + +
+ ); default: return null; } @@ -285,6 +361,37 @@ function OptionList({ onUpdate({ ...question, options: newOptions }); }; + const handlePaste = ( + e: React.ClipboardEvent, + currentIndex: number, + ) => { + e.preventDefault(); + const pastedText = e.clipboardData.getData("text"); + + const lines = pastedText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (lines.length === 0) return; + + if (lines.length === 1) { + handleOptionChange(currentIndex, lines[0] ?? ""); + return; + } + + const firstLine = lines[0]; + const remainingLines = lines.slice(1); + + const newOptions = [...options]; + + newOptions[currentIndex] = firstLine ?? ""; + + newOptions.splice(currentIndex + 1, 0, ...remainingLines); + + onUpdate({ ...question, options: newOptions }); + }; + const Icon = question.type === "MULTIPLE_CHOICE" ? Circle @@ -305,6 +412,7 @@ function OptionList({ handleOptionChange(idx, e.target.value)} + onPaste={(e) => handlePaste(e, idx)} className="flex-1 rounded-none border-none px-0 hover:border-b hover:border-gray-200 focus:border-b-2 focus:border-blue-500 focus:ring-0" placeholder={`Option ${idx + 1}`} onKeyDown={(e) => { @@ -353,3 +461,78 @@ function OptionList({ ); } + +function LinearScaleEditor({ + question, + onUpdate, +}: { + question: FormQuestion & { id: string }; + onUpdate: (q: FormQuestion & { id: string }) => void; +}) { + const min = question.min ?? 0; + const max = question.max ?? 5; + + const handleMinChange = (newMin: number) => { + onUpdate({ ...question, min: newMin }); + }; + + const handleMaxChange = (newMax: number) => { + onUpdate({ ...question, max: newMax }); + }; + + const scaleValues = Array.from({ length: max - min + 1 }, (_, i) => min + i); + + return ( +
+
+
+ + handleMinChange(Number(e.target.value))} + className="w-20" + min={0} + /> +
+
+ + handleMaxChange(Number(e.target.value))} + className="w-20" + min={min + 1} + /> +
+
+
+
+ {scaleValues.map((value) => ( +
+ + {value} +
+ ))} +
+ +
+
+ ); +} diff --git a/packages/api/src/routers/forms.ts b/packages/api/src/routers/forms.ts index cdac7c952..c10b68e63 100644 --- a/packages/api/src/routers/forms.ts +++ b/packages/api/src/routers/forms.ts @@ -444,20 +444,35 @@ export const formsRouter = { }), // Generate presigned upload URL for direct MinIO upload - getUploadUrl: permProcedure + getUploadUrl: protectedProcedure .input( z.object({ fileName: z.string(), formId: z.string(), - mediaType: z.enum(["image", "video"]), + mediaType: z.enum(["image", "video", "file"]), }), ) - .mutation(async ({ input, ctx }) => { - controlPerms.or(["EDIT_FORMS"], ctx); + .mutation(async ({ input }) => { const { fileName, formId, mediaType } = input; + const form = await db.query.FormsSchemas.findFirst({ + where: (t, { eq }) => eq(t.id, formId), + }); + + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Form not found", + }); + } + const safeFileName = fileName.replace(/[^a-zA-Z0-9.\-_]/g, "_"); - const folder = mediaType === "image" ? "images" : "videos"; + const folder = + mediaType === "image" + ? "images" + : mediaType === "video" + ? "videos" + : "files"; const objectName = `${formId}/${folder}/${Date.now()}-${safeFileName}`; try { @@ -516,6 +531,32 @@ export const formsRouter = { } }), + getFileUrl: permProcedure + .input( + z.object({ + objectName: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); + const { objectName } = input; + + try { + const viewUrl = await minioClient.presignedGetObject( + FORM_ASSETS_BUCKET, + objectName, + PRESIGNED_URL_EXPIRY, + ); + return { viewUrl }; + } catch (e) { + console.error("getFileUrl error:", e); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to generate file URL", + }); + } + }), + getSections: permProcedure.query(async ({ ctx }) => { controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx); diff --git a/packages/api/src/utils.ts b/packages/api/src/utils.ts index 83bd53285..7f14a1f49 100644 --- a/packages/api/src/utils.ts +++ b/packages/api/src/utils.ts @@ -395,6 +395,9 @@ function createJsonSchemaValidator({ schema.type = "array"; schema.items = { type: "string", enum: options }; break; + case "FILE_UPLOAD": + schema.type = "string"; + break; default: schema.type = "string"; } diff --git a/packages/consts/src/knight-hacks.ts b/packages/consts/src/knight-hacks.ts index bb880f19e..0a8c76ae1 100644 --- a/packages/consts/src/knight-hacks.ts +++ b/packages/consts/src/knight-hacks.ts @@ -214,6 +214,7 @@ export const EVENT_POINTS: Record = { export const KNIGHTHACKS_S3_BUCKET_REGION = "us-east-1"; export const KNIGHTHACKS_MAX_RESUME_SIZE = 5 * 1000000; // 5MB export const FORM_ASSETS_BUCKET = "form-assets"; + export const PRESIGNED_URL_EXPIRY = 7 * 24 * 60 * 60; // 7 days export const KNIGHTHACKS_MAX_PROFILE_PICTURE_SIZE = 2 * 1024 * 1024; // 2MB @@ -6767,6 +6768,7 @@ export const QuestionValidator = z.object({ "EMAIL", "NUMBER", "PHONE", + "FILE_UPLOAD", ]), options: z.array(z.string()).optional(), optional: z.boolean().optional(), @@ -6807,6 +6809,11 @@ export const FORM_QUESTION_TYPES = [ { value: "MULTIPLE_CHOICE", label: "Multiple choice" }, { value: "CHECKBOXES", label: "Checkboxes" }, { value: "DROPDOWN", label: "Dropdown" }, + { value: "FILE_UPLOAD", label: "File upload" }, + { value: "LINEAR_SCALE", label: "Linear scale" }, { value: "DATE", label: "Date" }, { value: "TIME", label: "Time" }, + { value: "EMAIL", label: "Email" }, + { value: "NUMBER", label: "Number" }, + { value: "PHONE", label: "Phone" }, ] as const; diff --git a/packages/ui/src/slider.tsx b/packages/ui/src/slider.tsx index e25dc828f..39cc2a798 100644 --- a/packages/ui/src/slider.tsx +++ b/packages/ui/src/slider.tsx @@ -22,9 +22,7 @@ const Slider = React.forwardRef< - - {value?.[0] ?? 5} - + )); Slider.displayName = SliderPrimitive.Root.displayName;