From 3959cf23f7acc46a16602ab07ce00c1c41eb603b Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Mon, 23 Mar 2026 11:07:02 -0400 Subject: [PATCH] feat(forms): add public form access and response submission for participants - Implemented public access to forms with `getPublic` procedure. - Added `submitPublic` procedure for participants to submit responses. - Created a new participant form page to handle form display and submission. - Enhanced form validation and error handling for required fields. - Introduced CSV export functionality for form responses. - Updated form listing and template creation procedures. - Added README for homepage screenshots. --- public/images/screenshots/README.md | 28 + .../studies/[id]/forms/[formId]/page.tsx | 581 ++++++++++++++++-- .../(dashboard)/studies/[id]/forms/page.tsx | 66 +- src/app/(public)/forms/[formId]/page.tsx | 430 +++++++++++++ src/app/page.tsx | 516 ++++++++++------ src/server/api/routers/forms.ts | 283 +++++++-- 6 files changed, 1591 insertions(+), 313 deletions(-) create mode 100644 public/images/screenshots/README.md create mode 100644 src/app/(public)/forms/[formId]/page.tsx diff --git a/public/images/screenshots/README.md b/public/images/screenshots/README.md new file mode 100644 index 0000000..56424ef --- /dev/null +++ b/public/images/screenshots/README.md @@ -0,0 +1,28 @@ +# Homepage Screenshots + +Add your app screenshots here. The homepage will display them automatically. + +## Required Screenshots + +1. **experiment-designer.png** - Visual experiment designer showing block-based workflow +2. **wizard-interface.png** - Wizard execution interface with trial controls +3. **dashboard.png** - Study dashboard showing experiments and trials + +## Recommended Size + +- Width: 1200px +- Format: PNG or WebP +- Quality: High (screenshot at 2x for retina displays) + +## Preview in Browser + +After adding screenshots, uncomment the `` component in `src/app/page.tsx`: + +```tsx +{screenshot.alt} +``` diff --git a/src/app/(dashboard)/studies/[id]/forms/[formId]/page.tsx b/src/app/(dashboard)/studies/[id]/forms/[formId]/page.tsx index 70760f2..4317d8a 100644 --- a/src/app/(dashboard)/studies/[id]/forms/[formId]/page.tsx +++ b/src/app/(dashboard)/studies/[id]/forms/[formId]/page.tsx @@ -19,6 +19,11 @@ import { Edit2, Users, CheckCircle, + Printer, + Download, + Pencil, + X, + FileDown, } from "lucide-react"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { Button } from "~/components/ui/button"; @@ -81,8 +86,16 @@ export default function FormViewPage({ params }: FormViewPageProps) { const { data: session } = useSession(); const router = useRouter(); const utils = api.useUtils(); - const [resolvedParams, setResolvedParams] = useState<{ id: string; formId: string } | null>(null); + const [resolvedParams, setResolvedParams] = useState<{ + id: string; + formId: string; + } | null>(null); const [isEditing, setIsEditing] = useState(false); + const [isEnteringData, setIsEnteringData] = useState(false); + const [selectedParticipantId, setSelectedParticipantId] = + useState(""); + const [formResponses, setFormResponses] = useState>({}); + const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); @@ -96,6 +109,11 @@ export default function FormViewPage({ params }: FormViewPageProps) { void resolveParams(); }, [params]); + const { data: participants } = api.participants.list.useQuery( + { studyId: resolvedParams?.id ?? "" }, + { enabled: !!resolvedParams?.id && isEnteringData }, + ); + const { data: study } = api.studies.get.useQuery( { id: resolvedParams?.id ?? "" }, { enabled: !!resolvedParams?.id }, @@ -125,6 +143,143 @@ export default function FormViewPage({ params }: FormViewPageProps) { }, }); + const submitResponse = api.forms.submitResponse.useMutation({ + onSuccess: () => { + toast.success("Response submitted successfully!"); + setIsEnteringData(false); + setSelectedParticipantId(""); + setFormResponses({}); + void utils.forms.getResponses.invalidate({ + formId: resolvedParams?.formId, + }); + }, + onError: (error) => { + toast.error("Failed to submit response", { description: error.message }); + }, + }); + + const exportCsv = api.forms.exportCsv.useQuery( + { formId: resolvedParams?.formId ?? "" }, + { enabled: !!resolvedParams?.formId && canManage }, + ); + + const handleExportCsv = () => { + if (exportCsv.data) { + const blob = new Blob([exportCsv.data.csv], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = exportCsv.data.filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success("CSV exported successfully!"); + } + }; + + const generatePdf = async () => { + if (!study || !form) return; + setIsGeneratingPdf(true); + const { downloadPdfFromHtml } = await import("~/lib/pdf-generator"); + + const fieldsHtml = fields + .map((field, index) => { + const requiredMark = field.required + ? '*' + : ""; + let inputField = ""; + + switch (field.type) { + case "text": + inputField = + ''; + break; + case "textarea": + inputField = + ''; + break; + case "multiple_choice": + inputField = `
${field.options + ?.map((opt) => `
${opt}
`) + .join("")}
`; + break; + case "checkbox": + inputField = + '
Yes
'; + break; + case "yes_no": + inputField = + '
Yes   No
'; + break; + case "rating": + const scale = field.settings?.scale || 5; + inputField = `
${Array.from( + { length: scale }, + (_, i) => ` ${i + 1} `, + ).join("")}
`; + break; + case "date": + inputField = + ''; + break; + case "signature": + inputField = + '
Signature: _________________________ Date: ____________
'; + break; + } + + return ` +
+

${index + 1}. ${field.label} ${requiredMark}

+ ${inputField} +
+ `; + }) + .join( + "
", + ); + + const html = ` +
+

${title}

+ ${description ? `

${description}

` : ""} +

+ Study: ${study?.name || ""}  |  + Form Type: ${form?.type}  |  + Version: ${form?.version} +

+
+ ${fieldsHtml} +
+

+ Generated by HRIStudio | ${new Date().toLocaleDateString()} +

+
+ `; + + await downloadPdfFromHtml(html, { + filename: `${title.replace(/\s+/g, "_")}_form.pdf`, + }); + setIsGeneratingPdf(false); + }; + + const handleDataEntry = () => { + if (!selectedParticipantId || !form) { + toast.error("Please select a participant"); + return; + } + const answers: Record = {}; + fields.forEach((field) => { + answers[field.id] = formResponses[field.id] ?? ""; + }); + submitResponse.mutate({ + formId: form.id, + participantId: selectedParticipantId, + responses: answers, + }); + }; + useEffect(() => { if (form) { setTitle(form.title); @@ -147,26 +302,28 @@ export default function FormViewPage({ params }: FormViewPageProps) { if (isLoading || !form) return
Loading...
; - const TypeIcon = formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText; + const TypeIcon = + formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText; const responses = responsesData?.responses ?? []; const addField = (type: string) => { const newField: Field = { id: crypto.randomUUID(), type, - label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`, + label: `New ${fieldTypes.find((f) => f.value === type)?.label || "Field"}`, required: false, - options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined, + options: + type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined, }; setFields([...fields, newField]); }; const removeField = (id: string) => { - setFields(fields.filter(f => f.id !== id)); + setFields(fields.filter((f) => f.id !== id)); }; const updateField = (id: string, updates: Partial) => { - setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f)); + setFields(fields.map((f) => (f.id === id ? { ...f, ...updates } : f))); }; const handleSave = () => { @@ -191,10 +348,12 @@ export default function FormViewPage({ params }: FormViewPageProps) {
- +

{form.title}

{form.active && ( - Active + + Active + )}

@@ -215,10 +374,20 @@ export default function FormViewPage({ params }: FormViewPageProps) { ) : ( - + <> + + + )}

)} @@ -228,6 +397,9 @@ export default function FormViewPage({ params }: FormViewPageProps) { Fields Preview + {canManage && ( + Data Entry + )} Responses ({responses.length}) @@ -254,7 +426,7 @@ export default function FormViewPage({ params }: FormViewPageProps) { {fields.length === 0 ? ( -
+

No fields added yet

@@ -265,18 +437,26 @@ export default function FormViewPage({ params }: FormViewPageProps) { key={field.id} className="flex items-start gap-3 rounded-lg border p-4" > -
+
- {fieldTypes.find(f => f.value === field.type)?.icon}{" "} - {fieldTypes.find(f => f.value === field.type)?.label} + { + fieldTypes.find((f) => f.value === field.type) + ?.icon + }{" "} + { + fieldTypes.find((f) => f.value === field.type) + ?.label + } updateField(field.id, { label: e.target.value })} + onChange={(e) => + updateField(field.id, { label: e.target.value }) + } placeholder="Field label" className="flex-1" /> @@ -284,7 +464,11 @@ export default function FormViewPage({ params }: FormViewPageProps) { updateField(field.id, { required: e.target.checked })} + onChange={(e) => + updateField(field.id, { + required: e.target.checked, + }) + } className="rounded border-gray-300" /> Required @@ -294,13 +478,20 @@ export default function FormViewPage({ params }: FormViewPageProps) {
{field.options?.map((opt, i) => ( -
+
{ - const newOptions = [...(field.options || [])]; + const newOptions = [ + ...(field.options || []), + ]; newOptions[i] = e.target.value; - updateField(field.id, { options: newOptions }); + updateField(field.id, { + options: newOptions, + }); }} placeholder={`Option ${i + 1}`} className="flex-1" @@ -310,8 +501,12 @@ export default function FormViewPage({ params }: FormViewPageProps) { variant="ghost" size="icon" onClick={() => { - const newOptions = field.options?.filter((_, idx) => idx !== i); - updateField(field.id, { options: newOptions }); + const newOptions = field.options?.filter( + (_, idx) => idx !== i, + ); + updateField(field.id, { + options: newOptions, + }); }} > @@ -323,8 +518,13 @@ export default function FormViewPage({ params }: FormViewPageProps) { variant="outline" size="sm" onClick={() => { - const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`]; - updateField(field.id, { options: newOptions }); + const newOptions = [ + ...(field.options || []), + `Option ${(field.options?.length || 0) + 1}`, + ]; + updateField(field.id, { + options: newOptions, + }); }} > @@ -339,7 +539,7 @@ export default function FormViewPage({ params }: FormViewPageProps) { size="icon" onClick={() => removeField(field.id)} > - +
))} @@ -358,16 +558,23 @@ export default function FormViewPage({ params }: FormViewPageProps) { ) : (
{fields.map((field, index) => ( -
- +
+ {index + 1}

{field.label}

- {fieldTypes.find(f => f.value === field.type)?.label} + { + fieldTypes.find((f) => f.value === field.type) + ?.label + } {field.required && " • Required"} - {field.type === "multiple_choice" && ` • ${field.options?.length} options`} + {field.type === "multiple_choice" && + ` • ${field.options?.length} options`}

@@ -387,7 +594,9 @@ export default function FormViewPage({ params }: FormViewPageProps) {

{title}

- {description &&

{description}

} + {description && ( +

{description}

+ )}
{fields.length === 0 ? (

No fields to preview

@@ -397,13 +606,18 @@ export default function FormViewPage({ params }: FormViewPageProps) {
{field.type === "text" && ( )} {field.type === "textarea" && ( -