feat: add initial seed data migration and form builder components

- Created migration 0001_seed_data.sql to insert minimal seed data for users, accounts, and roles.
- Added meta journal for migration tracking.
- Implemented FormBuilder component for dynamic form field creation and management.
- Developed FormFieldRenderer component to render various types of form fields based on user input.
- Introduced constants for trust levels and status configurations.
- Defined types for form fields and trial data structures to enhance type safety and clarity.
This commit is contained in:
2026-03-26 14:56:00 -04:00
parent 1c7f0297a6
commit 7c360dc860
29 changed files with 1551 additions and 4779 deletions
@@ -20,17 +20,16 @@ import {
Users,
CheckCircle,
Printer,
Download,
Pencil,
X,
FileDown,
} from "lucide-react";
import { Textarea } from "~/components/ui/textarea";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
@@ -42,26 +41,11 @@ import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const fieldTypes = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
import type { FormField, FormFieldType } from "~/lib/types/forms";
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
import { formStatusColors } from "~/lib/constants";
import { FormBuilder } from "~/components/forms/FormBuilder";
import { FormFieldRenderer } from "~/components/forms/FormFieldRenderer";
const formTypeIcons = {
consent: FileSignature,
@@ -69,12 +53,6 @@ const formTypeIcons = {
questionnaire: FileQuestion,
};
const statusColors = {
pending: "bg-yellow-100 text-yellow-700",
completed: "bg-green-100 text-green-700",
rejected: "bg-red-100 text-red-700",
};
interface FormViewPageProps {
params: Promise<{
id: string;
@@ -99,7 +77,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [fields, setFields] = useState<Field[]>([]);
const [fields, setFields] = useState<FormField[]>([]);
useEffect(() => {
const resolveParams = async () => {
@@ -213,7 +191,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
'<div style="margin-top: 4px;"><input type="radio" name="yn" /> Yes &nbsp; <input type="radio" name="yn" /> No</div>';
break;
case "rating":
const scale = field.settings?.scale || 5;
const scale = (field.settings?.scale as number) || 5;
inputField = `<div style="margin-top: 4px;">${Array.from(
{ length: scale },
(_, i) => `<input type="radio" name="rating" /> ${i + 1} `,
@@ -284,7 +262,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
if (form) {
setTitle(form.title);
setDescription(form.description || "");
setFields((form.fields as Field[]) || []);
setFields((form.fields as FormField[]) || []);
}
}, [form]);
@@ -307,10 +285,10 @@ export default function FormViewPage({ params }: FormViewPageProps) {
const responses = responsesData?.responses ?? [];
const addField = (type: string) => {
const newField: Field = {
const newField: FormField = {
id: crypto.randomUUID(),
type,
label: `New ${fieldTypes.find((f) => f.value === type)?.label || "Field"}`,
type: type as FormFieldType,
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
required: false,
options:
type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
@@ -322,7 +300,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
setFields(fields.filter((f) => f.id !== id));
};
const updateField = (id: string, updates: Partial<Field>) => {
const updateField = (id: string, updates: Partial<FormField>) => {
setFields(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
};
@@ -332,7 +310,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
title,
description,
fields,
settings: form.settings as Record<string, any>,
settings: form.settings as Record<string, unknown>,
});
};
@@ -415,7 +393,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<SelectValue placeholder="Add field..." />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
{FORM_FIELD_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="mr-2">{type.icon}</span>
{type.label}
@@ -444,11 +422,11 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{
fieldTypes.find((f) => f.value === field.type)
FORM_FIELD_TYPES.find((f) => f.value === field.type)
?.icon
}{" "}
{
fieldTypes.find((f) => f.value === field.type)
FORM_FIELD_TYPES.find((f) => f.value === field.type)
?.label
}
</Badge>
@@ -569,7 +547,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<p className="font-medium">{field.label}</p>
<p className="text-muted-foreground text-xs">
{
fieldTypes.find((f) => f.value === field.type)
FORM_FIELD_TYPES.find((f) => f.value === field.type)
?.label
}
{field.required && " • Required"}
@@ -646,7 +624,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
{field.type === "rating" && (
<div className="flex gap-2">
{Array.from(
{ length: field.settings?.scale || 5 },
{ length: (field.settings?.scale as number) || 5 },
(_, i) => (
<button
key={i}
@@ -831,7 +809,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
</SelectTrigger>
<SelectContent>
{Array.from(
{ length: field.settings?.scale || 5 },
{ length: (field.settings?.scale as number) || 5 },
(_, i) => (
<SelectItem key={i} value={String(i + 1)}>
{i + 1}
@@ -948,7 +926,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
</span>
</div>
<Badge
className={`text-xs ${statusColors[response.status as keyof typeof statusColors]}`}
className={`text-xs ${formStatusColors[response.status as keyof typeof formStatusColors]}`}
>
{response.status}
</Badge>
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation";
@@ -8,22 +8,17 @@ import Link from "next/link";
import {
FileText,
ArrowLeft,
Plus,
Trash2,
GripVertical,
Save,
LayoutTemplate,
FileSignature,
ClipboardList,
FileQuestion,
Save,
Copy,
LayoutTemplate,
} from "lucide-react";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
@@ -31,29 +26,11 @@ import {
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const fieldTypes = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
import type { FormField, FormType } from "~/lib/types/forms";
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
import { FormBuilder } from "~/components/forms/FormBuilder";
const formTypes = [
{ value: "consent", label: "Consent Form", icon: FileSignature, description: "Legal/IRB consent documents" },
@@ -65,14 +42,13 @@ export default function NewFormPage() {
const params = useParams();
const router = useRouter();
const { data: session } = useSession();
const utils = api.useUtils();
const studyId = typeof params.id === "string" ? params.id : "";
const [formType, setFormType] = useState<string>("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [fields, setFields] = useState<Field[]>([]);
const [fields, setFields] = useState<FormField[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: study } = api.studies.get.useQuery(
@@ -115,25 +91,6 @@ export default function NewFormPage() {
return notFound();
}
const addField = (type: string) => {
const newField: Field = {
id: crypto.randomUUID(),
type,
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
setFields([...fields, newField]);
};
const removeField = (id: string) => {
setFields(fields.filter(f => f.id !== id));
};
const updateField = (id: string, updates: Partial<Field>) => {
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -145,7 +102,7 @@ export default function NewFormPage() {
setIsSubmitting(true);
createForm.mutate({
studyId,
type: formType as "consent" | "survey" | "questionnaire",
type: formType as FormType,
title,
description,
fields,
@@ -266,12 +223,21 @@ export default function NewFormPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Form Fields</CardTitle>
<Select onValueChange={addField}>
<Select onValueChange={(type) => {
const newField: FormField = {
id: crypto.randomUUID(),
type: type as FormField["type"],
label: `New ${FORM_FIELD_TYPES.find(f => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
setFields([...fields, newField]);
}}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Add field..." />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
{FORM_FIELD_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="mr-2">{type.icon}</span>
{type.label}
@@ -281,117 +247,7 @@ export default function NewFormPage() {
</Select>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<FileText className="mb-2 h-8 w-8" />
<p>No fields added yet</p>
<p className="text-sm">Use the dropdown above to add fields</p>
</div>
) : (
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-start gap-3 rounded-lg border p-4"
>
<div className="flex cursor-grab items-center text-muted-foreground">
<GripVertical className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{fieldTypes.find(f => f.value === field.type)?.icon}{" "}
{fieldTypes.find(f => f.value === field.type)?.label}
</Badge>
<Input
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Field label"
className="flex-1"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(field.id, { required: e.target.checked })}
className="rounded border-gray-300"
/>
Required
</label>
</div>
{field.type === "multiple_choice" && (
<div className="space-y-2">
<Label className="text-xs">Options</Label>
{field.options?.map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={opt}
onChange={(e) => {
const newOptions = [...(field.options || [])];
newOptions[i] = e.target.value;
updateField(field.id, { options: newOptions });
}}
placeholder={`Option ${i + 1}`}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newOptions = field.options?.filter((_, idx) => idx !== i);
updateField(field.id, { options: newOptions });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`];
updateField(field.id, { options: newOptions });
}}
>
<Plus className="mr-1 h-4 w-4" />
Add Option
</Button>
</div>
)}
{field.type === "rating" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Scale:</span>
<Select
value={field.settings?.scale?.toString() || "5"}
onValueChange={(val) => updateField(field.id, { settings: { scale: parseInt(val) } })}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">1-5</SelectItem>
<SelectItem value="7">1-7</SelectItem>
<SelectItem value="10">1-10</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(field.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
<FormBuilder fields={fields} onFieldsChange={setFields} />
</CardContent>
</Card>
@@ -407,4 +263,4 @@ export default function NewFormPage() {
</form>
</div>
);
}
}
+41 -181
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
FileText,
@@ -22,18 +22,10 @@ import {
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
import type { FormField } from "~/lib/types/forms";
import { FormFieldRenderer } from "~/components/forms/FormFieldRenderer";
const formTypeIcons = {
consent: FileSignature,
@@ -47,7 +39,7 @@ export default function ParticipantFormPage() {
const formId = params.formId as string;
const [participantCode, setParticipantCode] = useState("");
const [formResponses, setFormResponses] = useState<Record<string, any>>({});
const [formResponses, setFormResponses] = useState<Record<string, unknown>>({});
const [hasSubmitted, setHasSubmitted] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
@@ -113,7 +105,7 @@ export default function ParticipantFormPage() {
const TypeIcon =
formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
const fields = (form.fields as Field[]) || [];
const fields = (form.fields as FormField[]) || [];
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
@@ -158,7 +150,7 @@ export default function ParticipantFormPage() {
});
};
const updateResponse = (fieldId: string, value: any) => {
const updateResponse = (fieldId: string, value: unknown) => {
setFormResponses({ ...formResponses, [fieldId]: value });
if (fieldErrors[fieldId]) {
const newErrors = { ...fieldErrors };
@@ -217,175 +209,21 @@ export default function ParticipantFormPage() {
<div className="border-t pt-6">
{fields.map((field, index) => (
<div key={field.id} className="mb-6 last:mb-0">
<Label
htmlFor={field.id}
className={
fieldErrors[field.id] ? "text-destructive" : ""
}
>
{index + 1}. {field.label}
{field.required && (
<span className="text-destructive"> *</span>
)}
</Label>
<FormFieldLabel
field={field}
index={index}
showIndex
/>
<div className="mt-2">
{field.type === "text" && (
<Input
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Enter your response..."
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "textarea" && (
<Textarea
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Enter your response..."
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "multiple_choice" && (
<div
className={`mt-2 space-y-2 ${fieldErrors[field.id] ? "border-destructive rounded-md border p-2" : ""}`}
>
{field.options?.map((opt, i) => (
<label
key={i}
className="flex cursor-pointer items-center gap-2"
>
<input
type="radio"
name={field.id}
value={opt}
checked={formResponses[field.id] === opt}
onChange={() => updateResponse(field.id, opt)}
className="h-4 w-4"
/>
<span className="text-sm">{opt}</span>
</label>
))}
</div>
)}
{field.type === "checkbox" && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id={field.id}
checked={formResponses[field.id] || false}
onChange={(e) =>
updateResponse(field.id, e.target.checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label
htmlFor={field.id}
className="cursor-pointer font-normal"
>
Yes, I agree
</Label>
</div>
)}
{field.type === "yes_no" && (
<div className="mt-2 flex gap-4">
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={field.id}
value="yes"
checked={formResponses[field.id] === "yes"}
onChange={() => updateResponse(field.id, "yes")}
className="h-4 w-4"
/>
<span className="text-sm">Yes</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={field.id}
value="no"
checked={formResponses[field.id] === "no"}
onChange={() => updateResponse(field.id, "no")}
className="h-4 w-4"
/>
<span className="text-sm">No</span>
</label>
</div>
)}
{field.type === "rating" && (
<div className="mt-2 flex flex-wrap gap-2">
{Array.from(
{ length: field.settings?.scale || 5 },
(_, i) => (
<label key={i} className="cursor-pointer">
<input
type="radio"
name={field.id}
value={String(i + 1)}
checked={formResponses[field.id] === i + 1}
onChange={() =>
updateResponse(field.id, i + 1)
}
className="peer sr-only"
/>
<span className="hover:bg-muted peer-checked:bg-primary peer-checked:text-primary-foreground flex h-10 w-10 items-center justify-center rounded-full border text-sm font-medium transition-colors">
{i + 1}
</span>
</label>
),
)}
</div>
)}
{field.type === "date" && (
<Input
type="date"
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "signature" && (
<div className="space-y-2">
<Input
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Type your full name as signature"
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
<p className="text-muted-foreground text-xs">
By entering your name above, you confirm that the
information provided is accurate.
</p>
</div>
)}
<FormFieldRenderer
field={field}
value={formResponses[field.id]}
onChange={(val) => updateResponse(field.id, val)}
mode="participant"
index={index}
error={fieldErrors[field.id]}
/>
</div>
{fieldErrors[field.id] && (
@@ -428,3 +266,25 @@ export default function ParticipantFormPage() {
</div>
);
}
function FormFieldLabel({
field,
index,
showIndex = true,
error,
}: {
field: FormField;
index: number;
showIndex?: boolean;
error?: string;
}) {
return (
<Label
className={error ? "text-destructive" : ""}
>
{showIndex && `${index + 1}. `}
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
);
}
-1
View File
@@ -138,7 +138,6 @@ export default function SignInPage() {
id="not-robot"
checked={notRobot}
onCheckedChange={(checked) => setNotRobot(checked === true)}
disabled={isLoading}
/>
<label
htmlFor="not-robot"