feat: Add skeleton loaders for improved loading states in studies and participants pages

- Implemented skeleton components for studies list, study details, and participants table
- Enhanced loading experience by replacing simple "Loading..." placeholders with detailed skeleton loaders
- Created reusable skeleton components in `src/components/ui/skeleton.tsx`
- Updated studies and participants pages to use new skeleton loaders
This commit is contained in:
2025-02-12 00:03:24 -05:00
parent 6e3f2e1601
commit ec4d8db16e
6 changed files with 226 additions and 25 deletions

View File

@@ -7,6 +7,7 @@ import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content"; import { PageContent } from "~/components/layout/page-content";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { use } from "react"; import { use } from "react";
import { CardSkeleton } from "~/components/ui/skeleton";
export default function EditStudyPage({ params }: { params: Promise<{ id: string }> }) { export default function EditStudyPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter(); const router = useRouter();
@@ -29,7 +30,27 @@ export default function EditStudyPage({ params }: { params: Promise<{ id: string
} }
if (isLoadingStudy) { if (isLoadingStudy) {
return <div>Loading...</div>; return (
<>
<PageHeader
title="Edit Study"
description="Loading study details..."
/>
<PageContent className="max-w-2xl">
<Card>
<CardHeader>
<CardTitle>Study Details</CardTitle>
<CardDescription>
Please wait while we load the study information.
</CardDescription>
</CardHeader>
<CardContent>
<CardSkeleton />
</CardContent>
</Card>
</PageContent>
</>
);
} }
if (!study) { if (!study) {

View File

@@ -13,6 +13,7 @@ import { StudyParticipants } from "~/components/studies/study-participants";
import { StudyMembers } from "~/components/studies/study-members"; import { StudyMembers } from "~/components/studies/study-members";
import { StudyMetadata } from "~/components/studies/study-metadata"; import { StudyMetadata } from "~/components/studies/study-metadata";
import { StudyActivity } from "~/components/studies/study-activity"; import { StudyActivity } from "~/components/studies/study-activity";
import { StudyDetailsSkeleton } from "~/components/ui/skeleton";
export default function StudyPage({ params }: { params: Promise<{ id: string }> }) { export default function StudyPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter(); const router = useRouter();
@@ -24,7 +25,28 @@ export default function StudyPage({ params }: { params: Promise<{ id: string }>
const { data: study, isLoading: isLoadingStudy } = api.study.getById.useQuery({ id }); const { data: study, isLoading: isLoadingStudy } = api.study.getById.useQuery({ id });
if (isLoadingStudy) { if (isLoadingStudy) {
return <div>Loading...</div>; return (
<>
<PageHeader
title="Loading..."
description="Please wait while we load the study details"
/>
<PageContent>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="participants">Participants</TabsTrigger>
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="metadata">Metadata</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<StudyDetailsSkeleton />
</TabsContent>
</Tabs>
</PageContent>
</>
);
} }
if (!study) { if (!study) {

View File

@@ -9,6 +9,7 @@ import { ParticipantForm, type ParticipantFormValues } from "~/components/partic
import { use } from "react"; import { use } from "react";
import { useToast } from "~/hooks/use-toast"; import { useToast } from "~/hooks/use-toast";
import { ROLES } from "~/lib/permissions/constants"; import { ROLES } from "~/lib/permissions/constants";
import { CardSkeleton } from "~/components/ui/skeleton";
export default function EditParticipantPage({ export default function EditParticipantPage({
params, params,
@@ -50,7 +51,27 @@ export default function EditParticipantPage({
} }
if (isLoading) { if (isLoading) {
return <div>Loading...</div>; return (
<>
<PageHeader
title="Edit Participant"
description="Loading participant information..."
/>
<PageContent>
<Card>
<CardHeader>
<CardTitle>Participant Details</CardTitle>
<CardDescription>
Please wait while we load the participant information.
</CardDescription>
</CardHeader>
<CardContent>
<CardSkeleton />
</CardContent>
</Card>
</PageContent>
</>
);
} }
if (!study || !participant) { if (!study || !participant) {

View File

@@ -7,15 +7,12 @@ import { PageContent } from "~/components/layout/page-content";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Plus as PlusIcon } from "lucide-react"; import { Plus as PlusIcon } from "lucide-react";
import { StudyListSkeleton } from "~/components/ui/skeleton";
export default function StudiesPage() { export default function StudiesPage() {
const router = useRouter(); const router = useRouter();
const { data: studies, isLoading } = api.study.getMyStudies.useQuery(); const { data: studies, isLoading } = api.study.getMyStudies.useQuery();
if (isLoading) {
return <div>Loading...</div>;
}
return ( return (
<> <>
<PageHeader <PageHeader
@@ -31,8 +28,9 @@ export default function StudiesPage() {
</Button> </Button>
</PageHeader> </PageHeader>
<PageContent> <PageContent>
<div className="grid gap-6"> {isLoading ? (
{!studies || studies.length === 0 ? ( <StudyListSkeleton />
) : !studies || studies.length === 0 ? (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>No Studies</CardTitle> <CardTitle>No Studies</CardTitle>
@@ -42,7 +40,8 @@ export default function StudiesPage() {
</CardHeader> </CardHeader>
</Card> </Card>
) : ( ) : (
studies.map((study) => ( <div className="grid gap-6">
{studies.map((study) => (
<Card <Card
key={study.id} key={study.id}
className="hover:bg-muted/50 cursor-pointer transition-colors" className="hover:bg-muted/50 cursor-pointer transition-colors"
@@ -60,9 +59,9 @@ export default function StudiesPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)) ))}
)}
</div> </div>
)}
</PageContent> </PageContent>
</> </>
); );

View File

@@ -18,6 +18,7 @@ import { ROLES } from "~/lib/permissions/constants";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { useState } from "react"; import { useState } from "react";
import { TableSkeleton } from "~/components/ui/skeleton";
interface StudyParticipantsProps { interface StudyParticipantsProps {
studyId: number; studyId: number;
@@ -38,7 +39,21 @@ export function StudyParticipants({ studyId, role }: StudyParticipantsProps) {
.includes(role.toLowerCase()); .includes(role.toLowerCase());
if (isLoading) { if (isLoading) {
return <div>Loading...</div>; return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Study Participants</CardTitle>
<CardDescription>Loading participants...</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<TableSkeleton />
</CardContent>
</Card>
);
} }
return ( return (

View File

@@ -1,4 +1,5 @@
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
import { Card, CardContent, CardHeader } from "~/components/ui/card"
function Skeleton({ function Skeleton({
className, className,
@@ -6,10 +7,132 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) { }: React.HTMLAttributes<HTMLDivElement>) {
return ( return (
<div <div
className={cn("animate-pulse rounded-md bg-primary/10", className)} className={cn("animate-pulse rounded-md bg-muted", className)}
{...props} {...props}
/> />
) )
} }
export { Skeleton } function TableRowSkeleton() {
return (
<div className="flex space-x-4 p-4">
<Skeleton className="h-5 w-[20%]" />
<Skeleton className="h-5 w-[15%]" />
<Skeleton className="h-5 w-[30%]" />
<Skeleton className="h-5 w-[35%]" />
</div>
)
}
function TableSkeleton() {
return (
<div className="space-y-3">
<div className="flex space-x-4 p-4 border-b">
<Skeleton className="h-4 w-[20%]" />
<Skeleton className="h-4 w-[15%]" />
<Skeleton className="h-4 w-[30%]" />
<Skeleton className="h-4 w-[35%]" />
</div>
{[...Array(5)].map((_, i) => (
<TableRowSkeleton key={i} />
))}
</div>
)
}
function CardSkeleton() {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-7 w-[40%]" />
<Skeleton className="h-4 w-[60%]" />
<div className="pt-4">
<Skeleton className="h-4 w-[25%]" />
</div>
</div>
)
}
function StudyListSkeleton() {
return (
<div className="space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="rounded-lg border bg-card">
<CardSkeleton />
</div>
))}
</div>
)
}
function StudyDetailsSkeleton() {
return (
<div className="space-y-6">
{/* Overview Card */}
<Card>
<CardHeader>
<Skeleton className="h-7 w-[150px] mb-2" />
<Skeleton className="h-4 w-[250px]" />
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Skeleton className="h-4 w-[100px] mb-1" />
<Skeleton className="h-5 w-[150px]" />
</div>
<div>
<Skeleton className="h-4 w-[100px] mb-1" />
<Skeleton className="h-5 w-[200px]" />
</div>
</div>
</CardContent>
</Card>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-[120px]" />
<Skeleton className="h-4 w-4" />
</CardHeader>
<CardContent>
<Skeleton className="h-7 w-[60px] mb-1" />
<Skeleton className="h-4 w-[100px]" />
</CardContent>
</Card>
))}
</div>
{/* Activity Card */}
<Card>
<CardHeader>
<Skeleton className="h-7 w-[150px] mb-2" />
<Skeleton className="h-4 w-[200px]" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex gap-4">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-[150px]" />
<Skeleton className="h-4 w-[250px]" />
<Skeleton className="h-3 w-[100px]" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}
export {
Skeleton,
TableRowSkeleton,
TableSkeleton,
CardSkeleton,
StudyListSkeleton,
StudyDetailsSkeleton,
}