refactor: restructure study and participant forms into logical sections with separators and enhance EntityForm's layout flexibility for sidebar presence.

This commit is contained in:
2026-02-10 16:31:43 -05:00
parent a8c868ad3f
commit 85b951f742
4 changed files with 313 additions and 261 deletions

View File

@@ -256,160 +256,148 @@ export function ParticipantForm({
<> <>
<FormSection <FormSection
title="Participant Information" title="Participant Information"
description="Basic information about the research participant." description="Basic identity and study association."
> >
<FormField> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Label htmlFor="participantCode">Participant Code *</Label> <FormField>
<Input <Label htmlFor="participantCode">Participant Code *</Label>
id="participantCode" <Input
{...form.register("participantCode")} id="participantCode"
placeholder="e.g., P001, SUBJ_01, etc." {...form.register("participantCode")}
className={ placeholder="e.g., P001"
form.formState.errors.participantCode ? "border-red-500" : "" className={
} form.formState.errors.participantCode ? "border-red-500" : ""
/> }
{form.formState.errors.participantCode && ( />
<p className="text-sm text-red-600"> {form.formState.errors.participantCode && (
{form.formState.errors.participantCode.message} <p className="text-sm text-red-600">
</p> {form.formState.errors.participantCode.message}
)} </p>
<p className="text-muted-foreground text-xs"> )}
Unique identifier for this participant within the study </FormField>
</p>
</FormField>
<FormField> <FormField>
<Label htmlFor="name">Full Name</Label> <Label htmlFor="name">Full Name</Label>
<Input <Input
id="name" id="name"
{...form.register("name")} {...form.register("name")}
placeholder="Optional: Participant's full name" placeholder="Optional name"
className={form.formState.errors.name ? "border-red-500" : ""} className={form.formState.errors.name ? "border-red-500" : ""}
/> />
{form.formState.errors.name && ( {form.formState.errors.name && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
{form.formState.errors.name.message} {form.formState.errors.name.message}
</p> </p>
)} )}
<p className="text-muted-foreground text-xs"> </FormField>
Optional: Real name for contact purposes
</p>
</FormField>
<FormField> <FormField>
<Label htmlFor="email">Email Address</Label> <Label htmlFor="email">Email Address</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
{...form.register("email")} {...form.register("email")}
placeholder="participant@example.com" placeholder="participant@example.com"
className={form.formState.errors.email ? "border-red-500" : ""} className={form.formState.errors.email ? "border-red-500" : ""}
/> />
{form.formState.errors.email && ( {form.formState.errors.email && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
{form.formState.errors.email.message} {form.formState.errors.email.message}
</p> </p>
)} )}
<p className="text-muted-foreground text-xs"> </FormField>
Optional: For scheduling and communication </div>
</p>
</FormField>
<FormField>
<Label htmlFor="studyId">Study *</Label>
<Select
value={form.watch("studyId")}
onValueChange={(value) => form.setValue("studyId", value)}
disabled={studiesLoading || mode === "edit"}
>
<SelectTrigger
className={form.formState.errors.studyId ? "border-red-500" : ""}
>
<SelectValue
placeholder={
studiesLoading ? "Loading studies..." : "Select a study"
}
/>
</SelectTrigger>
<SelectContent>
{studiesData?.studies?.map((study) => (
<SelectItem key={study.id} value={study.id}>
{study.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.studyId && (
<p className="text-sm text-red-600">
{form.formState.errors.studyId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
Study cannot be changed after registration
</p>
)}
</FormField>
</FormSection> </FormSection>
<FormSection <div className="my-6" />
title="Demographics"
description="Optional demographic information for research purposes."
>
<FormField>
<Label htmlFor="age">Age</Label>
<Input
id="age"
type="number"
min="18"
max="120"
{...form.register("age", { valueAsNumber: true })}
placeholder="e.g., 25"
className={form.formState.errors.age ? "border-red-500" : ""}
/>
{form.formState.errors.age && (
<p className="text-sm text-red-600">
{form.formState.errors.age.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Optional: Age in years (minimum 18)
</p>
</FormField>
<FormField> <FormSection
<Label htmlFor="gender">Gender</Label> title="Demographics & Study"
<Select description="study association and demographic details."
value={form.watch("gender") ?? ""} >
onValueChange={(value) => <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
form.setValue( <FormField>
"gender", <Label htmlFor="studyId">Study *</Label>
value as <Select
| "male" value={form.watch("studyId")}
| "female" onValueChange={(value) => form.setValue("studyId", value)}
| "non_binary" disabled={studiesLoading || mode === "edit"}
| "prefer_not_to_say" >
| "other", <SelectTrigger
) className={
} form.formState.errors.studyId ? "border-red-500" : ""
> }
<SelectTrigger> >
<SelectValue placeholder="Select gender (optional)" /> <SelectValue
</SelectTrigger> placeholder={
<SelectContent> studiesLoading ? "Loading..." : "Select study"
<SelectItem value="male">Male</SelectItem> }
<SelectItem value="female">Female</SelectItem> />
<SelectItem value="non_binary">Non-binary</SelectItem> </SelectTrigger>
<SelectItem value="prefer_not_to_say"> <SelectContent>
Prefer not to say {studiesData?.studies?.map((study) => (
</SelectItem> <SelectItem key={study.id} value={study.id}>
<SelectItem value="other">Other</SelectItem> {study.name}
</SelectContent> </SelectItem>
</Select> ))}
<p className="text-muted-foreground text-xs"> </SelectContent>
Optional: Gender identity for demographic analysis </Select>
</p> {form.formState.errors.studyId && (
</FormField> <p className="text-sm text-red-600">
{form.formState.errors.studyId.message}
</p>
)}
</FormField>
<FormField>
<Label htmlFor="age">Age</Label>
<Input
id="age"
type="number"
min="18"
max="120"
{...form.register("age", { valueAsNumber: true })}
placeholder="e.g., 25"
className={form.formState.errors.age ? "border-red-500" : ""}
/>
{form.formState.errors.age && (
<p className="text-sm text-red-600">
{form.formState.errors.age.message}
</p>
)}
</FormField>
<FormField>
<Label htmlFor="gender">Gender</Label>
<Select
value={form.watch("gender") ?? ""}
onValueChange={(value) =>
form.setValue(
"gender",
value as
| "male"
| "female"
| "non_binary"
| "prefer_not_to_say"
| "other",
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select gender" />
</SelectTrigger>
<SelectContent>
<SelectItem value="male">Male</SelectItem>
<SelectItem value="female">Female</SelectItem>
<SelectItem value="non_binary">Non-binary</SelectItem>
<SelectItem value="prefer_not_to_say">
Prefer not to say
</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</FormField>
</div>
</FormSection> </FormSection>
{mode === "create" && ( {mode === "create" && (
@@ -505,8 +493,7 @@ export function ParticipantForm({
error={error} error={error}
onDelete={mode === "edit" ? onDelete : undefined} onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting} isDeleting={isDeleting}
sidebar={mode === "create" ? sidebar : undefined}
// sidebar={sidebar} // Removed for cleaner UI per user request
submitText={mode === "create" ? "Register Participant" : "Save Changes"} submitText={mode === "create" ? "Register Participant" : "Save Changes"}
> >
{formFields} {formFields}

View File

@@ -24,6 +24,12 @@ import {
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { useStudyContext } from "~/lib/study-context"; import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
export type Participant = { export type Participant = {
id: string; id: string;
@@ -101,16 +107,32 @@ export const columns: ColumnDef<Participant>[] = [
const name = row.getValue("name"); const name = row.getValue("name");
const email = row.original.email; const email = row.original.email;
return ( return (
<div> <TooltipProvider>
<div className="truncate font-medium"> <div>
{String(name) || "No name provided"} <div className="truncate font-medium max-w-[200px]">
</div> <Tooltip>
{email && ( <TooltipTrigger asChild>
<div className="text-muted-foreground truncate text-sm"> <span>{String(name) || "No name provided"}</span>
{email} </TooltipTrigger>
<TooltipContent>
<p>{String(name) || "No name provided"}</p>
</TooltipContent>
</Tooltip>
</div> </div>
)} {email && (
</div> <div className="text-muted-foreground truncate text-sm max-w-[200px]">
<Tooltip>
<TooltipTrigger asChild>
<span>{email}</span>
</TooltipTrigger>
<TooltipContent>
<p>{email}</p>
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</TooltipProvider>
); );
}, },
}, },
@@ -120,11 +142,30 @@ export const columns: ColumnDef<Participant>[] = [
cell: ({ row }) => { cell: ({ row }) => {
const consentGiven = row.getValue("consentGiven"); const consentGiven = row.getValue("consentGiven");
if (consentGiven) { return (
return <Badge className="bg-green-100 text-green-800">Consented</Badge>; <TooltipProvider>
} <Tooltip>
<TooltipTrigger>
return <Badge className="bg-red-100 text-red-800">Pending</Badge>; {consentGiven ? (
<Badge className="bg-green-100 text-green-800 hover:bg-green-200">
Consented
</Badge>
) : (
<Badge className="bg-red-100 text-red-800 hover:bg-red-200">
Pending
</Badge>
)}
</TooltipTrigger>
<TooltipContent>
<p>
{consentGiven
? "Participant has signed the consent form."
: "Consent form has not been recorded."}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}, },
}, },
{ {

View File

@@ -14,6 +14,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { import {
EntityForm, EntityForm,
@@ -165,103 +166,124 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
// Form fields // Form fields
const formFields = ( const formFields = (
<FormSection <div className="space-y-6">
title="Study Details" <FormSection
description="Basic information about your research study." title="Study Details"
> description="Basic information and status of your research study."
<FormField> >
<Label htmlFor="tour-study-name">Study Name *</Label> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Input <FormField>
id="tour-study-name" <Label htmlFor="tour-study-name">Study Name *</Label>
{...form.register("name")} <Input
placeholder="Enter study name..." id="tour-study-name"
className={form.formState.errors.name ? "border-red-500" : ""} {...form.register("name")}
/> placeholder="Enter study name..."
{form.formState.errors.name && ( className={form.formState.errors.name ? "border-red-500" : ""}
<p className="text-sm text-red-600"> />
{form.formState.errors.name.message} {form.formState.errors.name && (
</p> <p className="text-sm text-red-600">
)} {form.formState.errors.name.message}
</FormField> </p>
)}
</FormField>
<FormField> <FormField>
<Label htmlFor="tour-study-description">Description *</Label> <Label htmlFor="status">Status</Label>
<Textarea <Select
id="tour-study-description" value={form.watch("status")}
{...form.register("description")} onValueChange={(value) =>
placeholder="Describe the research objectives, methodology, and expected outcomes..." form.setValue(
rows={4} "status",
className={form.formState.errors.description ? "border-red-500" : ""} value as "draft" | "active" | "completed" | "archived",
/> )
{form.formState.errors.description && ( }
<p className="text-sm text-red-600"> >
{form.formState.errors.description.message} <SelectTrigger>
</p> <SelectValue placeholder="Select status" />
)} </SelectTrigger>
</FormField> <SelectContent>
<SelectItem value="draft">Draft - Study in preparation</SelectItem>
<SelectItem value="active">
Active - Currently recruiting/running
</SelectItem>
<SelectItem value="completed">
Completed - Data collection finished
</SelectItem>
<SelectItem value="archived">
Archived - Study concluded
</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField> <div className="md:col-span-2">
<Label htmlFor="institution">Institution *</Label> <FormField>
<Input <Label htmlFor="tour-study-description">Description *</Label>
id="institution" <Textarea
{...form.register("institution")} id="tour-study-description"
placeholder="e.g., University of Technology" {...form.register("description")}
className={form.formState.errors.institution ? "border-red-500" : ""} placeholder="Describe the research objectives, methodology, and expected outcomes..."
/> rows={4}
{form.formState.errors.institution && ( className={
<p className="text-sm text-red-600"> form.formState.errors.description ? "border-red-500" : ""
{form.formState.errors.institution.message} }
</p> />
)} {form.formState.errors.description && (
</FormField> <p className="text-sm text-red-600">
{form.formState.errors.description.message}
</p>
)}
</FormField>
</div>
</div>
</FormSection>
<FormField> <Separator />
<Label htmlFor="irbProtocolNumber">IRB Protocol Number</Label>
<Input
id="irbProtocolNumber"
{...form.register("irbProtocolNumber")}
placeholder="e.g., IRB-2024-001"
className={
form.formState.errors.irbProtocolNumber ? "border-red-500" : ""
}
/>
{form.formState.errors.irbProtocolNumber && (
<p className="text-sm text-red-600">
{form.formState.errors.irbProtocolNumber.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Optional: Institutional Review Board protocol number if applicable
</p>
</FormField>
<FormField> <FormSection
<Label htmlFor="status">Status</Label> title="Configuration"
<Select description="Institutional details and ethics approval."
value={form.watch("status")} >
onValueChange={(value) => <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
form.setValue( <FormField>
"status", <Label htmlFor="institution">Institution *</Label>
value as "draft" | "active" | "completed" | "archived", <Input
) id="institution"
} {...form.register("institution")}
> placeholder="e.g., University of Technology"
<SelectTrigger> className={
<SelectValue placeholder="Select status" /> form.formState.errors.institution ? "border-red-500" : ""
</SelectTrigger> }
<SelectContent> />
<SelectItem value="draft">Draft - Study in preparation</SelectItem> {form.formState.errors.institution && (
<SelectItem value="active"> <p className="text-sm text-red-600">
Active - Currently recruiting/running {form.formState.errors.institution.message}
</SelectItem> </p>
<SelectItem value="completed"> )}
Completed - Data collection finished </FormField>
</SelectItem>
<SelectItem value="archived">Archived - Study concluded</SelectItem> <FormField>
</SelectContent> <Label htmlFor="irbProtocolNumber">IRB Protocol Number</Label>
</Select> <Input
</FormField> id="irbProtocolNumber"
</FormSection> {...form.register("irbProtocolNumber")}
placeholder="e.g., IRB-2024-001"
className={
form.formState.errors.irbProtocolNumber ? "border-red-500" : ""
}
/>
{form.formState.errors.irbProtocolNumber && (
<p className="text-sm text-red-600">
{form.formState.errors.irbProtocolNumber.message}
</p>
)}
<p className="text-muted-foreground text-xs">
Optional: Institutional Review Board protocol number if applicable
</p>
</FormField>
</div>
</FormSection>
</div>
); );
// Sidebar content // Sidebar content
@@ -324,7 +346,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
error={error} error={error}
onDelete={mode === "edit" ? onDelete : undefined} onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting} isDeleting={isDeleting}
sidebar={sidebar} sidebar={mode === "create" ? sidebar : undefined}
submitButtonId="tour-study-submit" submitButtonId="tour-study-submit"
extraActions={ extraActions={
<Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}> <Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}>

View File

@@ -127,12 +127,14 @@ export function EntityForm<T extends FieldValues = FieldValues>({
<div <div
className={cn( className={cn(
"grid gap-8 w-full", "grid gap-8 w-full",
layout === "default" && "grid-cols-1 lg:grid-cols-3", // Keep the column split but remove max-width // If sidebar exists, use 2-column layout. If not, use full width (max-w-7xl centered).
layout === "full-width" && "grid-cols-1", sidebar && layout === "default"
? "grid-cols-1 lg:grid-cols-3"
: "grid-cols-1 max-w-7xl mx-auto",
)} )}
> >
{/* Main Form */} {/* Main Form */}
<div className={layout === "default" ? "lg:col-span-2" : "col-span-1"}> <div className={sidebar && layout === "default" ? "lg:col-span-2" : "col-span-1"}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>