mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-05-08 13:58:55 -04:00
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:
@@ -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 <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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,6 @@ export default function SignInPage() {
|
||||
id="not-robot"
|
||||
checked={notRobot}
|
||||
onCheckedChange={(checked) => setNotRobot(checked === true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="not-robot"
|
||||
|
||||
Reference in New Issue
Block a user