ability to create trials added; form uploader cleaned up

This commit is contained in:
2024-10-03 16:35:19 -04:00
parent 93e2b0323b
commit 7ef9180026
19 changed files with 559 additions and 151 deletions

View File

@@ -2,6 +2,7 @@ import Image from 'next/image';
import { Card, CardContent, CardFooter } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Trash2 } from "lucide-react";
import { Button } from "~/components/ui/button";
interface FormCardProps {
form: {
@@ -23,7 +24,7 @@ export function FormCard({ form, onDelete }: FormCardProps) {
};
return (
<Card className="overflow-hidden cursor-pointer" onClick={handleCardClick}>
<Card className="overflow-hidden cursor-pointer" onClick={handleCardClick} style={{ backgroundColor: 'var(--primary-card-background)' }}>
<CardContent className="p-0 h-40 relative">
<Image
src={form.previewLocation}
@@ -36,16 +37,21 @@ export function FormCard({ form, onDelete }: FormCardProps) {
}}
/>
</CardContent>
<CardFooter className="flex flex-col items-start p-4">
<CardFooter className="flex flex-col items-start p-4" style={{ backgroundColor: 'var(--secondary-card-background)' }}>
<div className="flex items-center justify-between w-full">
<h3 className="font-semibold mb-2">{form.title}</h3>
<Trash2
className="h-4 w-4 text-destructive cursor-pointer"
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(form.id);
}}
/>
}}
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
<div className="flex flex-wrap gap-2 mb-2">
<Badge variant="secondary">{form.studyTitle}</Badge>

View File

@@ -0,0 +1 @@

View File

@@ -3,13 +3,17 @@ import { Sidebar } from "~/components/sidebar";
import { StudyHeader } from "~/components/study/StudyHeader";
import { Toaster } from "~/components/ui/toaster";
const Layout = ({ children }: PropsWithChildren) => {
interface LayoutProps {
pageTitle: string;
}
const Layout = ({ children, pageTitle }: PropsWithChildren<LayoutProps>) => {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-y-auto bg-gradient-to-b from-[hsl(var(--gradient-start))] to-[hsl(var(--gradient-end))]">
<div className="container mx-auto space-y-4 p-4 pt-16 lg:pt-4">
<StudyHeader />
<StudyHeader pageTitle={pageTitle} />
{children}
<Toaster />
</div>

View File

@@ -8,9 +8,17 @@ import { Participant } from '../../types/Participant';
import { CreateParticipantDialog } from './CreateParticipantDialog';
import { useToast } from '~/hooks/use-toast';
import { ParticipantCard } from './ParticipantCard';
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
interface ParticipantWithTrial {
id: number;
name: string;
latestTrialTimestamp: string | null;
createdAt: string;
}
export function Participants() {
const [participants, setParticipants] = useState<Participant[]>([]);
const [participants, setParticipants] = useState<ParticipantWithTrial[]>([]);
const { selectedStudy } = useStudyContext();
const { toast } = useToast();
@@ -22,9 +30,23 @@ export function Participants() {
const fetchParticipants = async () => {
if (!selectedStudy) return;
const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`);
const data = await response.json();
setParticipants(data);
try {
const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const text = await response.text();
try {
const data = JSON.parse(text);
setParticipants(data);
} catch (e) {
console.error('Failed to parse JSON:', text);
throw new Error('Invalid JSON in response');
}
} catch (error) {
console.error('Error fetching participants:', error);
// Handle the error appropriately, e.g., show a toast notification
}
};
const createParticipant = async (name: string) => {
@@ -41,33 +63,20 @@ export function Participants() {
const deleteParticipant = async (id: number) => {
if (!selectedStudy) return;
try {
console.log(`Attempting to delete participant with ID: ${id}`);
const response = await fetch(`/api/participants/${id}`, {
method: 'DELETE',
});
console.log('Delete response:', response);
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
const result = await response.json();
console.log('Delete result:', result);
if (!response.ok) {
throw new Error(result.error || `Failed to delete participant. Status: ${response.status}`);
}
setParticipants(participants.filter(p => p.id !== id));
toast({
title: "Success",
description: "Participant deleted successfully",
});
} else {
const text = await response.text();
console.error('Unexpected response:', text);
throw new Error(`Unexpected response from server. Status: ${response.status}`);
if (!response.ok) {
throw new Error('Failed to delete participant');
}
setParticipants(participants.filter(p => p.id !== id));
toast({
title: "Success",
description: "Participant deleted successfully",
});
} catch (error) {
console.error('Error deleting participant:', error);
toast({
title: "Error",
description: error instanceof Error ? error.message : 'Failed to delete participant',
@@ -81,16 +90,36 @@ export function Participants() {
}
return (
<Card>
<Card className="card-level-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-2xl font-bold">Participants for {selectedStudy.title}</CardTitle>
<CreateParticipantDialog onCreateParticipant={createParticipant} />
</CardHeader>
<CardContent>
{participants.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{participants.map(participant => (
<ParticipantCard key={participant.id} participant={participant} onDelete={deleteParticipant} />
<Card key={participant.id} className="card-level-2 p-3 flex items-center w-full">
<Avatar className="mr-4">
<AvatarFallback>{participant.name.split(' ').map(n => n[0]).join('')}</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="font-semibold">{participant.name}</h3>
<p className="text-sm text-muted-foreground">
{participant.latestTrialTimestamp
? `Last trial: ${new Date(participant.latestTrialTimestamp).toLocaleString()}`
: 'No trials yet'}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => deleteParticipant(participant.id)}
>
Delete
</Button>
</Card>
))}
</div>
) : (

View File

@@ -3,7 +3,8 @@
import { UserButton, useUser } from "@clerk/nextjs"
import {
BarChartIcon,
BeakerIcon,
UsersRoundIcon,
LandPlotIcon,
BotIcon,
FolderIcon,
FileTextIcon,
@@ -22,7 +23,8 @@ import { ThemeToggle } from "~/components/ThemeToggle"
const navItems = [
{ name: "Dashboard", href: "/dash", icon: LayoutDashboard },
{ name: "Studies", href: "/studies", icon: FolderIcon },
{ name: "Participants", href: "/participants", icon: BeakerIcon },
{ name: "Participants", href: "/participants", icon: UsersRoundIcon },
{ name: "Trials", href: "/trials", icon: LandPlotIcon },
{ name: "Forms", href: "/forms", icon: FileTextIcon },
{ name: "Data Analysis", href: "/analysis", icon: BarChartIcon },
{ name: "Settings", href: "/settings", icon: Settings },

View File

@@ -8,68 +8,72 @@ import { StudySelector } from './StudySelector';
import { CreateStudyDialog } from '~/components/study/CreateStudyDialog';
import { Study } from '~/types/Study';
export function StudyHeader() {
const { studies, selectedStudy, setSelectedStudy, validateAndSetSelectedStudy, fetchAndSetStudies } = useStudyContext();
interface StudyHeaderProps {
pageTitle: string;
}
useEffect(() => {
const savedStudyId = localStorage.getItem('selectedStudyId');
if (savedStudyId) {
validateAndSetSelectedStudy(parseInt(savedStudyId, 10));
}
}, [validateAndSetSelectedStudy]);
export const StudyHeader: React.FC<StudyHeaderProps> = ({ pageTitle }) => {
const { studies, selectedStudy, setSelectedStudy, validateAndSetSelectedStudy, fetchAndSetStudies } = useStudyContext();
const handleStudyChange = (studyId: string) => {
const study = studies.find(s => s.id.toString() === studyId);
if (study) {
setSelectedStudy(study);
localStorage.setItem('selectedStudyId', studyId);
}
};
useEffect(() => {
const savedStudyId = localStorage.getItem('selectedStudyId');
if (savedStudyId) {
validateAndSetSelectedStudy(parseInt(savedStudyId, 10));
}
}, [validateAndSetSelectedStudy]);
const createStudy = async (newStudy: Omit<Study, "id">) => {
const response = await fetch('/api/studies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newStudy),
});
if (!response.ok) {
throw new Error('Failed to create study');
}
const createdStudy = await response.json();
await fetchAndSetStudies();
return createdStudy;
};
const handleStudyChange = (studyId: string) => {
const study = studies.find(s => s.id.toString() === studyId);
if (study) {
setSelectedStudy(study);
localStorage.setItem('selectedStudyId', studyId);
}
};
const handleCreateStudy = async (newStudy: Omit<Study, "id">) => {
const createdStudy = await createStudy(newStudy);
setSelectedStudy(createdStudy);
localStorage.setItem('selectedStudyId', createdStudy.id.toString());
};
const createStudy = async (newStudy: Omit<Study, "id">) => {
const response = await fetch('/api/studies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newStudy),
});
if (!response.ok) {
throw new Error('Failed to create study');
}
const createdStudy = await response.json();
await fetchAndSetStudies();
return createdStudy;
};
return (
<Card className="mt-2 lg:mt-0">
<CardContent className="flex justify-between items-center p-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<h2 className="text-2xl font-bold truncate max-w-[200px]">
{selectedStudy ? selectedStudy.title : 'Select a Study'}
</h2>
</TooltipTrigger>
<TooltipContent>
<p>{selectedStudy ? selectedStudy.title : 'No study selected'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center space-x-2">
<StudySelector
studies={studies}
selectedStudy={selectedStudy}
onStudyChange={handleStudyChange}
/>
<CreateStudyDialog onCreateStudy={(study: Omit<Study, "id">) => handleCreateStudy(study as Study)} />
</div>
</CardContent>
</Card>
);
}
const handleCreateStudy = async (newStudy: Omit<Study, "id">) => {
const createdStudy = await createStudy(newStudy);
setSelectedStudy(createdStudy);
localStorage.setItem('selectedStudyId', createdStudy.id.toString());
};
return (
<Card className="mt-2 lg:mt-0">
<CardContent className="flex justify-between items-center p-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<h2 className="text-2xl font-bold truncate max-w-[200px]">
{pageTitle}
</h2>
</TooltipTrigger>
<TooltipContent>
<p>{selectedStudy ? selectedStudy.title : 'No study selected'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center space-x-2">
<StudySelector
studies={studies}
selectedStudy={selectedStudy}
onStudyChange={handleStudyChange}
/>
<CreateStudyDialog onCreateStudy={(study: Omit<Study, "id">) => handleCreateStudy(study as Study)} />
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
import { Label } from "~/components/ui/label";
interface CreateTrialDialogProps {
onCreateTrial: (title: string, participantIds: number[]) => void;
}
export function CreateTrialDialog({ onCreateTrial }: CreateTrialDialogProps) {
const [title, setTitle] = useState('');
const [participantIds, setParticipantIds] = useState<string>('');
const handleCreate = () => {
const ids = participantIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
if (title && ids.length > 0) {
onCreateTrial(title, ids);
setTitle('');
setParticipantIds('');
}
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Add Trial</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Trial</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="participants" className="text-right">Participant IDs (comma-separated)</Label>
<Input
id="participants"
value={participantIds}
onChange={(e) => setParticipantIds(e.target.value)}
className="col-span-3"
placeholder="e.g. 1, 2, 3"
/>
</div>
</div>
<Button onClick={handleCreate}>Add Trial</Button>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { useToast } from '~/hooks/use-toast';
import { CreateTrialDialog } from '~/components/trial/CreateTrialDialog';
interface Trial {
id: number;
title: string;
participantIds: number[];
createdAt: string;
}
export function Trials() {
const [trials, setTrials] = useState<Trial[]>([]);
const { toast } = useToast();
useEffect(() => {
fetchTrials();
}, []);
const fetchTrials = async () => {
const response = await fetch('/api/trials');
if (!response.ok) {
const errorText = await response.text();
console.error('Error fetching trials:', response.status, errorText);
return;
}
const data = await response.json();
if (!data || data.length === 0) {
console.warn('No trials found');
setTrials([]); // Set to an empty array if no trials are found
return;
}
setTrials(data);
};
const createTrial = async (title: string, participantIds: number[]) => {
const response = await fetch('/api/trials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, participantIds }),
});
const newTrial = await response.json();
setTrials([...trials, newTrial]);
toast({
title: "Success",
description: "Trial created successfully",
});
};
const deleteTrial = async (id: number) => {
const response = await fetch(`/api/trials/${id}`, {
method: 'DELETE',
});
if (response.ok) {
setTrials(trials.filter(trial => trial.id !== id));
toast({
title: "Success",
description: "Trial deleted successfully",
});
} else {
toast({
title: "Error",
description: "Failed to delete trial",
variant: "destructive",
});
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-2xl font-bold">Trials</CardTitle>
<CreateTrialDialog onCreateTrial={createTrial} />
</CardHeader>
<CardContent>
{trials.length > 0 ? (
<div className="grid grid-cols-1 gap-4">
{trials.map(trial => (
<Card key={trial.id} className="bg-gray-100 p-3 flex items-center justify-between">
<div>
<h3 className="font-semibold">{trial.title}</h3>
<p className="text-sm text-gray-500">Participants: {trial.participantIds.join(', ')}</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => deleteTrial(trial.id)}
>
Delete
</Button>
</Card>
))}
</div>
) : (
<p>No trials added yet.</p>
)}
</CardContent>
</Card>
);
}