mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
Enhance HRIStudio with immersive experiment designer and comprehensive documentation updates
- Introduced a new immersive experiment designer using React Flow, providing a professional-grade visual flow editor for creating experiments. - Added detailed documentation for the flow designer connections and ordering system, emphasizing its advantages and implementation details. - Updated existing documentation to reflect the latest features and improvements, including a streamlined README and quick reference guide. - Consolidated participant type definitions into a new file for better organization and clarity. Features: - Enhanced user experience with a node-based interface for experiment design. - Comprehensive documentation supporting new features and development practices. Breaking Changes: None - existing functionality remains intact.
This commit is contained in:
@@ -1,52 +1,50 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Activity,
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Download,
|
||||
Filter,
|
||||
Download
|
||||
} from "lucide-react"
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { StudyGuard } from "~/components/dashboard/study-guard";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select"
|
||||
import { StudyGuard } from "~/components/dashboard/study-guard"
|
||||
} from "~/components/ui/select";
|
||||
|
||||
// Mock chart component - replace with actual charting library
|
||||
function MockChart({ title, data }: { title: string; data: number[] }) {
|
||||
const maxValue = Math.max(...data)
|
||||
|
||||
const maxValue = Math.max(...data);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{title}</h4>
|
||||
<div className="flex items-end space-x-1 h-32">
|
||||
<div className="flex h-32 items-end space-x-1">
|
||||
{data.map((value, index) => (
|
||||
<div
|
||||
<div
|
||||
key={index}
|
||||
className="bg-primary rounded-t flex-1 min-h-[4px]"
|
||||
className="bg-primary min-h-[4px] flex-1 rounded-t"
|
||||
style={{ height: `${(value / maxValue) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsOverview() {
|
||||
@@ -62,7 +60,7 @@ function AnalyticsOverview() {
|
||||
{
|
||||
title: "Avg Trial Duration",
|
||||
value: "24.5m",
|
||||
change: "-3%",
|
||||
change: "-3%",
|
||||
trend: "down",
|
||||
description: "vs last month",
|
||||
icon: Calendar,
|
||||
@@ -71,7 +69,7 @@ function AnalyticsOverview() {
|
||||
title: "Completion Rate",
|
||||
value: "94.2%",
|
||||
change: "+2.1%",
|
||||
trend: "up",
|
||||
trend: "up",
|
||||
description: "vs last month",
|
||||
icon: TrendingUp,
|
||||
},
|
||||
@@ -80,29 +78,33 @@ function AnalyticsOverview() {
|
||||
value: "87.3%",
|
||||
change: "+5.4%",
|
||||
trend: "up",
|
||||
description: "vs last month",
|
||||
description: "vs last month",
|
||||
icon: BarChart3,
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{metrics.map((metric) => (
|
||||
<Card key={metric.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{metric.title}</CardTitle>
|
||||
<metric.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{metric.title}
|
||||
</CardTitle>
|
||||
<metric.icon className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metric.value}</div>
|
||||
<div className="flex items-center space-x-2 text-xs text-muted-foreground">
|
||||
<span className={`flex items-center ${
|
||||
metric.trend === "up" ? "text-green-600" : "text-red-600"
|
||||
}`}>
|
||||
<div className="text-muted-foreground flex items-center space-x-2 text-xs">
|
||||
<span
|
||||
className={`flex items-center ${
|
||||
metric.trend === "up" ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{metric.trend === "up" ? (
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
<TrendingUp className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3 mr-1" />
|
||||
<TrendingDown className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{metric.change}
|
||||
</span>
|
||||
@@ -112,13 +114,13 @@ function AnalyticsOverview() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ChartsSection() {
|
||||
const trialData = [12, 19, 15, 27, 32, 28, 35, 42, 38, 41, 37, 44]
|
||||
const participantData = [8, 12, 10, 15, 18, 16, 20, 24, 22, 26, 23, 28]
|
||||
const completionData = [85, 88, 92, 89, 94, 91, 95, 92, 96, 94, 97, 94]
|
||||
const trialData = [12, 19, 15, 27, 32, 28, 35, 42, 38, 41, 37, 44];
|
||||
const participantData = [8, 12, 10, 15, 18, 16, 20, 24, 22, 26, 23, 28];
|
||||
const completionData = [85, 88, 92, 89, 94, 91, 95, 92, 96, 94, 97, 94];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
@@ -152,20 +154,22 @@ function ChartsSection() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function RecentInsights() {
|
||||
const insights = [
|
||||
{
|
||||
title: "Peak Performance Hours",
|
||||
description: "Participants show 23% better performance during 10-11 AM trials",
|
||||
description:
|
||||
"Participants show 23% better performance during 10-11 AM trials",
|
||||
type: "trend",
|
||||
severity: "info",
|
||||
},
|
||||
{
|
||||
title: "Attention Span Decline",
|
||||
description: "Average attention span has decreased by 8% over the last month",
|
||||
description:
|
||||
"Average attention span has decreased by 8% over the last month",
|
||||
type: "alert",
|
||||
severity: "warning",
|
||||
},
|
||||
@@ -178,23 +182,23 @@ function RecentInsights() {
|
||||
{
|
||||
title: "Equipment Utilization",
|
||||
description: "Robot interaction trials are at 85% capacity utilization",
|
||||
type: "info",
|
||||
type: "info",
|
||||
severity: "info",
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "success":
|
||||
return "bg-green-50 text-green-700 border-green-200"
|
||||
return "bg-green-50 text-green-700 border-green-200";
|
||||
case "warning":
|
||||
return "bg-yellow-50 text-yellow-700 border-yellow-200"
|
||||
return "bg-yellow-50 text-yellow-700 border-yellow-200";
|
||||
case "info":
|
||||
return "bg-blue-50 text-blue-700 border-blue-200"
|
||||
return "bg-blue-50 text-blue-700 border-blue-200";
|
||||
default:
|
||||
return "bg-gray-50 text-gray-700 border-gray-200"
|
||||
return "bg-gray-50 text-gray-700 border-gray-200";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -207,18 +211,18 @@ function RecentInsights() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{insights.map((insight, index) => (
|
||||
<div
|
||||
<div
|
||||
key={index}
|
||||
className={`p-4 rounded-lg border ${getSeverityColor(insight.severity)}`}
|
||||
className={`rounded-lg border p-4 ${getSeverityColor(insight.severity)}`}
|
||||
>
|
||||
<h4 className="font-medium mb-1">{insight.title}</h4>
|
||||
<h4 className="mb-1 font-medium">{insight.title}</h4>
|
||||
<p className="text-sm">{insight.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsContent() {
|
||||
@@ -292,7 +296,7 @@ function AnalyticsContent() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
@@ -301,4 +305,4 @@ export default function AnalyticsPage() {
|
||||
<AnalyticsContent />
|
||||
</StudyGuard>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,15 +20,36 @@ export default async function ExperimentDesignerPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<ExperimentDesignerClient
|
||||
experiment={{
|
||||
...experiment,
|
||||
description: experiment.description ?? "",
|
||||
}}
|
||||
/>
|
||||
<div className="fixed inset-0 z-50">
|
||||
<ExperimentDesignerClient
|
||||
experiment={{
|
||||
...experiment,
|
||||
description: experiment.description ?? "",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error loading experiment:", error);
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: ExperimentDesignerPageProps) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const experiment = await api.experiments.get({ id: resolvedParams.id });
|
||||
|
||||
return {
|
||||
title: `${experiment?.name} - Flow Designer | HRIStudio`,
|
||||
description: `Design experiment protocol for ${experiment?.name} using visual flow editor`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: "Experiment Flow Designer | HRIStudio",
|
||||
description: "Immersive visual experiment protocol designer",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
AlertCircle, ArrowLeft, Calendar, Edit, FileText, Mail, Play, Shield, Trash2, Users
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Edit,
|
||||
FileText,
|
||||
Mail,
|
||||
Play,
|
||||
Shield,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -8,11 +17,11 @@ import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { auth } from "~/server/auth";
|
||||
import { api } from "~/trpc/server";
|
||||
@@ -42,7 +51,7 @@ export default async function ParticipantDetailPage({
|
||||
|
||||
const userRole = session.user.roles?.[0]?.role ?? "observer";
|
||||
const canEdit = ["administrator", "researcher"].includes(userRole);
|
||||
const canDelete = ["administrator", "researcher"].includes(userRole);
|
||||
// canDelete removed - not used in component
|
||||
|
||||
// Get participant's trials
|
||||
const trials = await api.trials.list({
|
||||
@@ -70,7 +79,7 @@ export default async function ParticipantDetailPage({
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-foreground text-3xl font-bold">
|
||||
{participant.name || participant.participantCode}
|
||||
{participant.name ?? participant.participantCode}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{participant.name
|
||||
@@ -151,58 +160,59 @@ export default async function ParticipantDetailPage({
|
||||
</h4>
|
||||
<p className="text-sm">
|
||||
<Link
|
||||
href={`/studies/${(participant.study as any)?.id}`}
|
||||
href={`/studies/${participant.study?.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{(participant.study as any)?.name}
|
||||
{participant.study?.name}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{participant.demographics &&
|
||||
typeof participant.demographics === "object" &&
|
||||
Object.keys(participant.demographics).length > 0 ? (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
|
||||
Demographics
|
||||
</h4>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(participant.demographics as Record<string, any>)
|
||||
?.age && (
|
||||
<div>
|
||||
<span className="text-sm font-medium">Age:</span>{" "}
|
||||
<span className="text-sm">
|
||||
{String(
|
||||
(
|
||||
participant.demographics as Record<
|
||||
string,
|
||||
any
|
||||
>
|
||||
).age,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(participant.demographics as Record<string, any>)
|
||||
?.gender && (
|
||||
<div>
|
||||
<span className="text-sm font-medium">Gender:</span>{" "}
|
||||
<span className="text-sm">
|
||||
{String(
|
||||
(
|
||||
participant.demographics as Record<
|
||||
string,
|
||||
any
|
||||
>
|
||||
).gender,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
typeof participant.demographics === "object" &&
|
||||
participant.demographics !== null &&
|
||||
Object.keys(participant.demographics).length > 0 ? (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
|
||||
Demographics
|
||||
</h4>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(() => {
|
||||
const demo = participant.demographics as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return (
|
||||
<>
|
||||
{demo.age && (
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
Age:
|
||||
</span>{" "}
|
||||
<span className="text-sm">
|
||||
{typeof demo.age === "number"
|
||||
? demo.age.toString()
|
||||
: String(demo.age)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{demo.gender && (
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
Gender:
|
||||
</span>{" "}
|
||||
<span className="text-sm">
|
||||
{String(demo.gender)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Notes */}
|
||||
{participant.notes && (
|
||||
@@ -228,7 +238,9 @@ export default async function ParticipantDetailPage({
|
||||
</CardTitle>
|
||||
{canEdit && (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
|
||||
<Link
|
||||
href={`/trials/new?participantId=${resolvedParams.id}`}
|
||||
>
|
||||
Schedule Trial
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -270,13 +282,10 @@ export default async function ParticipantDetailPage({
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{(trial as any).scheduledAt
|
||||
? formatDistanceToNow(
|
||||
(trial as any).scheduledAt,
|
||||
{
|
||||
addSuffix: true,
|
||||
},
|
||||
)
|
||||
{trial.createdAt
|
||||
? formatDistanceToNow(new Date(trial.createdAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Not scheduled"}
|
||||
</span>
|
||||
{trial.duration && (
|
||||
@@ -293,11 +302,13 @@ export default async function ParticipantDetailPage({
|
||||
<Play className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 font-medium">No Trials Yet</h3>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
This participant hasn't been assigned to any trials.
|
||||
This participant hasn't been assigned to any trials.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<Button asChild>
|
||||
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
|
||||
<Link
|
||||
href={`/trials/new?participantId=${resolvedParams.id}`}
|
||||
>
|
||||
Schedule First Trial
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -399,7 +410,9 @@ export default async function ParticipantDetailPage({
|
||||
className="w-full justify-start"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
|
||||
<Link
|
||||
href={`/trials/new?participantId=${resolvedParams.id}`}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Schedule Trial
|
||||
</Link>
|
||||
@@ -427,7 +440,7 @@ export default async function ParticipantDetailPage({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return notFound();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,14 +252,14 @@ export default async function StudyDetailPage({
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{(member.user.name || member.user.email)
|
||||
{(member.user.name ?? member.user.email)
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{member.user.name || member.user.email}
|
||||
{member.user.name ?? member.user.email}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 capitalize">
|
||||
{member.role}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { ManagementPageLayout } from "~/components/ui/page-layout";
|
||||
import { ParticipantsTable } from "~/components/participants/ParticipantsTable";
|
||||
import { ManagementPageLayout } from "~/components/ui/page-layout";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
|
||||
export default function StudyParticipantsPage() {
|
||||
const params = useParams();
|
||||
const studyId = params.id as string;
|
||||
const studyId = typeof params.id === "string" ? params.id : "";
|
||||
const { setActiveStudy, activeStudy } = useActiveStudy();
|
||||
|
||||
// Set the active study if it doesn't match the current route
|
||||
@@ -25,7 +25,7 @@ export default function StudyParticipantsPage() {
|
||||
breadcrumb={[
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: activeStudy?.title || "Study", href: `/studies/${studyId}` },
|
||||
{ label: activeStudy?.title ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: "Participants" },
|
||||
]}
|
||||
createButton={{
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { ManagementPageLayout } from "~/components/ui/page-layout";
|
||||
import { TrialsTable } from "~/components/trials/TrialsTable";
|
||||
import { ManagementPageLayout } from "~/components/ui/page-layout";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
|
||||
export default function StudyTrialsPage() {
|
||||
const params = useParams();
|
||||
const studyId = params.id as string;
|
||||
const studyId = typeof params.id === "string" ? params.id : "";
|
||||
const { setActiveStudy, activeStudy } = useActiveStudy();
|
||||
|
||||
// Set the active study if it doesn't match the current route
|
||||
@@ -25,7 +25,7 @@ export default function StudyTrialsPage() {
|
||||
breadcrumb={[
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: activeStudy?.title || "Study", href: `/studies/${studyId}` },
|
||||
{ label: activeStudy?.title ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: "Trials" },
|
||||
]}
|
||||
createButton={{
|
||||
|
||||
@@ -2,8 +2,10 @@ import { eq } from "drizzle-orm";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
generateFileKey,
|
||||
getMimeType, uploadFile, validateFile
|
||||
generateFileKey,
|
||||
getMimeType,
|
||||
uploadFile,
|
||||
validateFile,
|
||||
} from "~/lib/storage/minio";
|
||||
import { auth } from "~/server/auth";
|
||||
import { db } from "~/server/db";
|
||||
@@ -38,7 +40,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Validate input
|
||||
const validationResult = uploadSchema.safeParse({
|
||||
trialId: trialId || undefined,
|
||||
trialId: trialId ?? undefined,
|
||||
category,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
@@ -111,7 +113,7 @@ export async function POST(request: NextRequest) {
|
||||
const mediaCapture = await db
|
||||
.insert(mediaCaptures)
|
||||
.values({
|
||||
trialId: validatedTrialId!, // Non-null assertion since it's validated above
|
||||
trialId: validatedTrialId!, // Non-null assertion since it's validated above
|
||||
format: file.type || getMimeType(file.name),
|
||||
fileSize: file.size,
|
||||
storagePath: fileKey,
|
||||
@@ -161,7 +163,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const filename = searchParams.get("filename");
|
||||
const contentType = searchParams.get("contentType");
|
||||
const category = searchParams.get("category") || "document";
|
||||
const category = searchParams.get("category") ?? "document";
|
||||
const trialId = searchParams.get("trialId");
|
||||
|
||||
if (!filename) {
|
||||
@@ -188,12 +190,12 @@ export async function GET(request: NextRequest) {
|
||||
category,
|
||||
filename,
|
||||
session.user.id,
|
||||
trialId || undefined,
|
||||
trialId ?? undefined,
|
||||
);
|
||||
|
||||
// Generate presigned URL for upload
|
||||
const { getUploadUrl } = await import("~/lib/storage/minio");
|
||||
const uploadUrl = await getUploadUrl(fileKey, contentType || undefined);
|
||||
const uploadUrl = await getUploadUrl(fileKey, contentType ?? undefined);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -242,9 +244,7 @@ function validateFileByCategory(
|
||||
return validateFile(file.name, file.size, types, maxSize);
|
||||
}
|
||||
|
||||
function getCaptureType(
|
||||
category: string,
|
||||
): "video" | "audio" | "image" {
|
||||
function getCaptureType(category: string): "video" | "audio" | "image" {
|
||||
switch (category) {
|
||||
case "video":
|
||||
return "video";
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type NextRequest } from "next/server";
|
||||
import { type WebSocketServer } from "ws";
|
||||
import { auth } from "~/server/auth";
|
||||
import { db } from "~/server/db";
|
||||
import { trialEvents, trials } from "~/server/db/schema";
|
||||
|
||||
// Store active WebSocket connections
|
||||
const connections = new Map<string, Set<any>>();
|
||||
const userConnections = new Map<
|
||||
string,
|
||||
{ userId: string; trialId: string; role: string }
|
||||
>();
|
||||
|
||||
// Create WebSocket server instance
|
||||
const wss: WebSocketServer | null = null;
|
||||
// Store active WebSocket connections (for external WebSocket server)
|
||||
// These would be used by a separate WebSocket implementation
|
||||
// const connections = new Map<string, Set<WebSocket>>();
|
||||
// const userConnections = new Map<
|
||||
// string,
|
||||
// { userId: string; trialId: string; role: string }
|
||||
// >();
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -48,347 +41,3 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// WebSocket connection handler (for external WebSocket server)
|
||||
export async function handleWebSocketConnection(ws: any, request: any) {
|
||||
try {
|
||||
const url = new URL(request.url, `http://${request.headers.host}`);
|
||||
const trialId = url.searchParams.get("trialId");
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
if (!trialId || !token) {
|
||||
ws.close(1008, "Missing required parameters");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
ws.close(1008, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify trial access
|
||||
const trial = await db
|
||||
.select()
|
||||
.from(trials)
|
||||
.where(eq(trials.id, trialId))
|
||||
.limit(1);
|
||||
|
||||
if (!trial.length) {
|
||||
ws.close(1008, "Trial not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const userRole = session.user.roles?.[0]?.role;
|
||||
if (
|
||||
!userRole ||
|
||||
!["administrator", "researcher", "wizard", "observer"].includes(userRole)
|
||||
) {
|
||||
ws.close(1008, "Insufficient permissions");
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = crypto.randomUUID();
|
||||
const userId = session.user.id;
|
||||
|
||||
// Store connection info
|
||||
userConnections.set(connectionId, {
|
||||
userId,
|
||||
trialId,
|
||||
role: userRole,
|
||||
});
|
||||
|
||||
// Add to trial connections
|
||||
if (!connections.has(trialId)) {
|
||||
connections.set(trialId, new Set());
|
||||
}
|
||||
connections.get(trialId)!.add(ws);
|
||||
|
||||
// Send initial connection confirmation
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connection_established",
|
||||
data: {
|
||||
connectionId,
|
||||
trialId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Send current trial status
|
||||
await sendTrialStatus(ws, trialId);
|
||||
|
||||
ws.on("message", async (data: Buffer) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
await handleWebSocketMessage(ws, connectionId, message);
|
||||
} catch (error) {
|
||||
console.error("Error handling WebSocket message:", error);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
data: {
|
||||
message: "Invalid message format",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log(`WebSocket disconnected: ${connectionId}`);
|
||||
|
||||
// Clean up connections
|
||||
const connectionInfo = userConnections.get(connectionId);
|
||||
if (connectionInfo) {
|
||||
const trialConnections = connections.get(connectionInfo.trialId);
|
||||
if (trialConnections) {
|
||||
trialConnections.delete(ws);
|
||||
if (trialConnections.size === 0) {
|
||||
connections.delete(connectionInfo.trialId);
|
||||
}
|
||||
}
|
||||
userConnections.delete(connectionId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error: Error) => {
|
||||
console.error(`WebSocket error for ${connectionId}:`, error);
|
||||
});
|
||||
|
||||
console.log(`WebSocket connected: ${connectionId} for trial ${trialId}`);
|
||||
} catch (error) {
|
||||
console.error("WebSocket setup error:", error);
|
||||
ws.close(1011, "Internal server error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebSocketMessage(
|
||||
ws: any,
|
||||
connectionId: string,
|
||||
message: any,
|
||||
) {
|
||||
const connectionInfo = userConnections.get(connectionId);
|
||||
if (!connectionInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { userId, trialId, role } = connectionInfo;
|
||||
|
||||
switch (message.type) {
|
||||
case "trial_action":
|
||||
if (["wizard", "researcher", "administrator"].includes(role)) {
|
||||
await handleTrialAction(trialId, userId, message.data);
|
||||
broadcastToTrial(trialId, {
|
||||
type: "trial_action_executed",
|
||||
data: {
|
||||
action: message.data,
|
||||
executedBy: userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "step_transition":
|
||||
if (["wizard", "researcher", "administrator"].includes(role)) {
|
||||
await handleStepTransition(trialId, userId, message.data);
|
||||
broadcastToTrial(trialId, {
|
||||
type: "step_changed",
|
||||
data: {
|
||||
...message.data,
|
||||
changedBy: userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "wizard_intervention":
|
||||
if (["wizard", "researcher", "administrator"].includes(role)) {
|
||||
await logTrialEvent(
|
||||
trialId,
|
||||
"wizard_intervention",
|
||||
message.data,
|
||||
userId,
|
||||
);
|
||||
broadcastToTrial(trialId, {
|
||||
type: "intervention_logged",
|
||||
data: {
|
||||
...message.data,
|
||||
interventionBy: userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "request_trial_status":
|
||||
await sendTrialStatus(ws, trialId);
|
||||
break;
|
||||
|
||||
case "heartbeat":
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "heartbeat_response",
|
||||
data: {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
data: {
|
||||
message: `Unknown message type: ${message.type}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTrialAction(
|
||||
trialId: string,
|
||||
userId: string,
|
||||
actionData: any,
|
||||
) {
|
||||
try {
|
||||
// Log the action as a trial event
|
||||
await logTrialEvent(trialId, "wizard_action", actionData, userId);
|
||||
|
||||
// Update trial status if needed
|
||||
if (actionData.actionType === "start_trial") {
|
||||
await db
|
||||
.update(trials)
|
||||
.set({
|
||||
status: "in_progress",
|
||||
startedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(trials.id, trialId));
|
||||
} else if (actionData.actionType === "complete_trial") {
|
||||
await db
|
||||
.update(trials)
|
||||
.set({
|
||||
status: "completed",
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(trials.id, trialId));
|
||||
} else if (actionData.actionType === "abort_trial") {
|
||||
await db
|
||||
.update(trials)
|
||||
.set({
|
||||
status: "aborted",
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(trials.id, trialId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling trial action:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStepTransition(
|
||||
trialId: string,
|
||||
userId: string,
|
||||
stepData: any,
|
||||
) {
|
||||
try {
|
||||
await logTrialEvent(trialId, "step_transition", stepData, userId);
|
||||
} catch (error) {
|
||||
console.error("Error handling step transition:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function logTrialEvent(
|
||||
trialId: string,
|
||||
eventType: string,
|
||||
data: any,
|
||||
userId: string,
|
||||
) {
|
||||
try {
|
||||
await db.insert(trialEvents).values({
|
||||
trialId,
|
||||
eventType: eventType as "trial_start" | "trial_end" | "step_start" | "step_end" | "wizard_intervention" | "error" | "custom",
|
||||
data,
|
||||
createdBy: userId,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error logging trial event:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTrialStatus(ws: any, trialId: string) {
|
||||
try {
|
||||
const trial = await db
|
||||
.select()
|
||||
.from(trials)
|
||||
.where(eq(trials.id, trialId))
|
||||
.limit(1);
|
||||
|
||||
if (trial.length > 0) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "trial_status",
|
||||
data: {
|
||||
trial: trial[0],
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending trial status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastToTrial(trialId: string, message: any) {
|
||||
const trialConnections = connections.get(trialId);
|
||||
if (trialConnections) {
|
||||
const messageStr = JSON.stringify(message);
|
||||
for (const ws of trialConnections) {
|
||||
if (ws.readyState === 1) {
|
||||
// WebSocket.OPEN
|
||||
ws.send(messageStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function to broadcast trial updates
|
||||
export function broadcastTrialUpdate(
|
||||
trialId: string,
|
||||
updateType: string,
|
||||
data: any,
|
||||
) {
|
||||
broadcastToTrial(trialId, {
|
||||
type: updateType,
|
||||
data: {
|
||||
...data,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup orphaned connections
|
||||
setInterval(() => {
|
||||
for (const [connectionId, info] of userConnections.entries()) {
|
||||
const trialConnections = connections.get(info.trialId);
|
||||
if (!trialConnections || trialConnections.size === 0) {
|
||||
userConnections.delete(connectionId);
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { AlertTriangle, Building, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -8,9 +10,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Building, AlertTriangle, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
|
||||
interface StudyGuardProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import {
|
||||
EntityForm,
|
||||
FormField,
|
||||
FormSection,
|
||||
NextSteps,
|
||||
Tips,
|
||||
} from "~/components/ui/entity-form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
@@ -15,16 +24,7 @@ import {
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
EntityForm,
|
||||
FormField,
|
||||
FormSection,
|
||||
NextSteps,
|
||||
Tips,
|
||||
} from "~/components/ui/entity-form";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
const experimentSchema = z.object({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { X, ArrowLeft } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import {
|
||||
ExperimentDesigner,
|
||||
type ExperimentDesign,
|
||||
} from "./ExperimentDesigner";
|
||||
FlowDesigner,
|
||||
type FlowDesign,
|
||||
type FlowStep,
|
||||
type StepType,
|
||||
} from "./FlowDesigner";
|
||||
|
||||
interface ExperimentDesignerClientProps {
|
||||
experiment: {
|
||||
@@ -25,6 +30,28 @@ export function ExperimentDesignerClient({
|
||||
experiment,
|
||||
}: ExperimentDesignerClientProps) {
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Set breadcrumbs for the designer
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{
|
||||
label: experiment.study?.name ?? "Study",
|
||||
href: `/studies/${experiment.studyId}`,
|
||||
},
|
||||
{
|
||||
label: "Experiments",
|
||||
href: `/studies/${experiment.studyId}`,
|
||||
},
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/experiments/${experiment.id}`,
|
||||
},
|
||||
{
|
||||
label: "Designer",
|
||||
href: `/experiments/${experiment.id}/designer`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Fetch the experiment's design data
|
||||
const { data: experimentSteps, isLoading } =
|
||||
@@ -35,17 +62,33 @@ export function ExperimentDesignerClient({
|
||||
const saveDesignMutation = api.experiments.saveDesign.useMutation({
|
||||
onSuccess: () => {
|
||||
setSaveError(null);
|
||||
toast.success("Experiment design saved successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
setSaveError(error.message);
|
||||
toast.error(`Failed to save design: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = async (design: ExperimentDesign) => {
|
||||
const handleSave = async (design: FlowDesign) => {
|
||||
try {
|
||||
await saveDesignMutation.mutateAsync({
|
||||
experimentId: experiment.id,
|
||||
steps: design.steps,
|
||||
steps: design.steps
|
||||
.filter((step) => step.type !== "start" && step.type !== "end") // Filter out start/end nodes
|
||||
.map((step) => ({
|
||||
id: step.id,
|
||||
type: step.type as "wizard" | "robot" | "parallel" | "conditional",
|
||||
name: step.name,
|
||||
order: Math.floor(step.position.x / 250) + 1, // Calculate order from position
|
||||
parameters: step.parameters,
|
||||
description: step.description,
|
||||
duration: step.duration,
|
||||
actions: step.actions,
|
||||
expanded: false,
|
||||
children: [],
|
||||
parentId: undefined,
|
||||
})),
|
||||
version: design.version,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -56,84 +99,105 @@ export function ExperimentDesignerClient({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex min-h-[600px] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
<p className="text-slate-600">Loading experiment designer...</p>
|
||||
<div className="border-primary mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||
<p className="text-muted-foreground">
|
||||
Loading experiment designer...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const initialDesign: ExperimentDesign = {
|
||||
// Convert backend steps to flow format
|
||||
const convertToFlowSteps = (steps: any[]): FlowStep[] => {
|
||||
return steps.map((step, index) => ({
|
||||
id: step.id,
|
||||
type: step.type as StepType,
|
||||
name: step.name,
|
||||
description: step.description ?? undefined,
|
||||
duration: step.duration ?? undefined,
|
||||
actions: [], // Actions will be loaded separately if needed
|
||||
parameters: step.parameters ?? {},
|
||||
position: {
|
||||
x: index * 250 + 100,
|
||||
y: 100,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const initialDesign: FlowDesign = {
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description,
|
||||
steps:
|
||||
experimentSteps?.map((step) => ({
|
||||
...step,
|
||||
type: step.type as "wizard" | "robot" | "parallel" | "conditional",
|
||||
description: step.description ?? undefined,
|
||||
duration: step.duration ?? undefined,
|
||||
actions: [], // Initialize with empty actions array
|
||||
parameters: step.parameters || {},
|
||||
expanded: false,
|
||||
})) || [],
|
||||
steps: experimentSteps ? convertToFlowSteps(experimentSteps) : [],
|
||||
version: 1,
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="bg-background flex h-screen flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b bg-white p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href={`/experiments/${experiment.id}`}
|
||||
className="flex items-center text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Back to Experiment
|
||||
</Link>
|
||||
<div className="h-4 w-px bg-slate-300" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-slate-900">
|
||||
{experiment.name}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600">Visual Protocol Designer</p>
|
||||
<div className="bg-background/95 supports-[backdrop-filter]:bg-background/60 relative border-b backdrop-blur">
|
||||
<div className="from-primary/5 to-accent/5 absolute inset-0 bg-gradient-to-r" />
|
||||
<div className="relative flex items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/experiments/${experiment.id}`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Experiment
|
||||
</Button>
|
||||
<div className="bg-border h-6 w-px" />
|
||||
<div className="bg-primary flex h-12 w-12 items-center justify-center rounded-xl shadow-lg">
|
||||
<span className="text-primary-foreground text-xl font-bold">
|
||||
F
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{experiment.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{experiment.description || "Visual Flow Designer"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="bg-muted rounded-lg px-3 py-1 text-sm">
|
||||
{experiment.study?.name ?? "Unknown Study"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/experiments/${experiment.id}`)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-slate-500">
|
||||
<span>Study: </span>
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}`}
|
||||
className="font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{experiment.study?.name || "Unknown Study"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{saveError && (
|
||||
<div className="border-l-4 border-red-400 bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">
|
||||
Failed to save experiment: {saveError}
|
||||
</p>
|
||||
<div className="border-destructive/50 bg-destructive/10 mx-6 mt-4 rounded-lg border p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-destructive font-medium">Save Error</h4>
|
||||
<p className="text-destructive/90 mt-1 text-sm">{saveError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Designer */}
|
||||
{/* Flow Designer */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ExperimentDesigner
|
||||
<FlowDesigner
|
||||
experimentId={experiment.id}
|
||||
initialDesign={initialDesign}
|
||||
onSave={handleSave}
|
||||
isSaving={saveDesignMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
906
src/components/experiments/designer/FlowDesigner.tsx
Normal file
906
src/components/experiments/designer/FlowDesigner.tsx
Normal file
@@ -0,0 +1,906 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
type NodeTypes,
|
||||
MarkerType,
|
||||
Panel,
|
||||
Handle,
|
||||
Position,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import "./flow-theme.css";
|
||||
import {
|
||||
Bot,
|
||||
Users,
|
||||
Shuffle,
|
||||
GitBranch,
|
||||
Play,
|
||||
Zap,
|
||||
Eye,
|
||||
Clock,
|
||||
Plus,
|
||||
Save,
|
||||
Undo,
|
||||
Redo,
|
||||
Download,
|
||||
Upload,
|
||||
Settings,
|
||||
Trash2,
|
||||
Copy,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "~/components/ui/sheet";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Types
|
||||
type StepType =
|
||||
| "wizard"
|
||||
| "robot"
|
||||
| "parallel"
|
||||
| "conditional"
|
||||
| "start"
|
||||
| "end";
|
||||
type ActionType =
|
||||
| "speak"
|
||||
| "move"
|
||||
| "gesture"
|
||||
| "look_at"
|
||||
| "wait"
|
||||
| "instruction"
|
||||
| "question"
|
||||
| "observe";
|
||||
|
||||
interface FlowAction {
|
||||
id: string;
|
||||
type: ActionType;
|
||||
name: string;
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface FlowStep {
|
||||
id: string;
|
||||
type: StepType;
|
||||
name: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
actions: FlowAction[];
|
||||
parameters: Record<string, unknown>;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface FlowDesign {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: FlowStep[];
|
||||
version: number;
|
||||
lastSaved: Date;
|
||||
}
|
||||
|
||||
// Step type configurations
|
||||
const stepTypeConfig = {
|
||||
start: {
|
||||
label: "Start",
|
||||
icon: Play,
|
||||
color: "#10b981",
|
||||
bgColor: "bg-green-500",
|
||||
lightColor: "bg-green-50 border-green-200",
|
||||
description: "Experiment starting point",
|
||||
},
|
||||
wizard: {
|
||||
label: "Wizard Action",
|
||||
icon: Users,
|
||||
color: "#3b82f6",
|
||||
bgColor: "bg-blue-500",
|
||||
lightColor: "bg-blue-50 border-blue-200",
|
||||
description: "Actions performed by human wizard",
|
||||
},
|
||||
robot: {
|
||||
label: "Robot Action",
|
||||
icon: Bot,
|
||||
color: "#8b5cf6",
|
||||
bgColor: "bg-purple-500",
|
||||
lightColor: "bg-purple-50 border-purple-200",
|
||||
description: "Actions performed by robot",
|
||||
},
|
||||
parallel: {
|
||||
label: "Parallel Steps",
|
||||
icon: Shuffle,
|
||||
color: "#f59e0b",
|
||||
bgColor: "bg-amber-500",
|
||||
lightColor: "bg-amber-50 border-amber-200",
|
||||
description: "Execute multiple steps simultaneously",
|
||||
},
|
||||
conditional: {
|
||||
label: "Conditional Branch",
|
||||
icon: GitBranch,
|
||||
color: "#ef4444",
|
||||
bgColor: "bg-red-500",
|
||||
lightColor: "bg-red-50 border-red-200",
|
||||
description: "Branching logic based on conditions",
|
||||
},
|
||||
end: {
|
||||
label: "End",
|
||||
icon: Play,
|
||||
color: "#6b7280",
|
||||
bgColor: "bg-gray-500",
|
||||
lightColor: "bg-gray-50 border-gray-200",
|
||||
description: "Experiment end point",
|
||||
},
|
||||
};
|
||||
|
||||
const actionTypeConfig = {
|
||||
speak: {
|
||||
label: "Speak",
|
||||
icon: Play,
|
||||
description: "Text-to-speech output",
|
||||
defaultParams: { text: "Hello, I'm ready to help!" },
|
||||
},
|
||||
move: {
|
||||
label: "Move",
|
||||
icon: Play,
|
||||
description: "Move to location or position",
|
||||
defaultParams: { x: 0, y: 0, speed: 1 },
|
||||
},
|
||||
gesture: {
|
||||
label: "Gesture",
|
||||
icon: Zap,
|
||||
description: "Physical gesture or animation",
|
||||
defaultParams: { gesture: "wave", duration: 2 },
|
||||
},
|
||||
look_at: {
|
||||
label: "Look At",
|
||||
icon: Eye,
|
||||
description: "Orient gaze or camera",
|
||||
defaultParams: { target: "participant" },
|
||||
},
|
||||
wait: {
|
||||
label: "Wait",
|
||||
icon: Clock,
|
||||
description: "Pause for specified duration",
|
||||
defaultParams: { duration: 3 },
|
||||
},
|
||||
instruction: {
|
||||
label: "Instruction",
|
||||
icon: Settings,
|
||||
description: "Display instruction for wizard",
|
||||
defaultParams: { text: "Follow the protocol", allowSkip: true },
|
||||
},
|
||||
question: {
|
||||
label: "Question",
|
||||
icon: Plus,
|
||||
description: "Ask participant a question",
|
||||
defaultParams: { question: "How do you feel?", recordResponse: true },
|
||||
},
|
||||
observe: {
|
||||
label: "Observe",
|
||||
icon: Eye,
|
||||
description: "Observe and record behavior",
|
||||
defaultParams: { target: "participant", duration: 5, notes: "" },
|
||||
},
|
||||
};
|
||||
|
||||
// Custom Node Components
|
||||
interface StepNodeProps {
|
||||
data: {
|
||||
step: FlowStep;
|
||||
onEdit: (step: FlowStep) => void;
|
||||
onDelete: (stepId: string) => void;
|
||||
onDuplicate: (step: FlowStep) => void;
|
||||
isSelected: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function StepNode({ data }: StepNodeProps) {
|
||||
const { step, onEdit, onDelete, onDuplicate, isSelected } = data;
|
||||
const config = stepTypeConfig[step.type];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Connection Handles */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!bg-primary !border-background !h-3 !w-3 !border-2"
|
||||
id="input"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!bg-primary !border-background !h-3 !w-3 !border-2"
|
||||
id="output"
|
||||
/>
|
||||
|
||||
<Card
|
||||
className={`min-w-[200px] border transition-all duration-200 ${
|
||||
isSelected ? "ring-primary shadow-2xl ring-2" : "hover:shadow-lg"
|
||||
}`}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`${config.bgColor} flex h-8 w-8 shrink-0 items-center justify-center rounded shadow-lg`}
|
||||
>
|
||||
<config.icon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{step.name}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(step)}>
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
Edit Step
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onDuplicate(step)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(step.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Step
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{step.description && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<p className="text-muted-foreground text-xs">{step.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{step.actions.length > 0 && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{step.actions.length} action{step.actions.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{step.actions.slice(0, 3).map((action) => {
|
||||
const actionConfig = actionTypeConfig[action.type];
|
||||
return (
|
||||
<Badge
|
||||
key={action.id}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
<actionConfig.icon className="mr-1 h-3 w-3" />
|
||||
{actionConfig.label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{step.actions.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{step.actions.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Node types configuration
|
||||
const nodeTypes: NodeTypes = {
|
||||
stepNode: StepNode,
|
||||
};
|
||||
|
||||
// Main Flow Designer Component
|
||||
interface FlowDesignerProps {
|
||||
experimentId: string;
|
||||
initialDesign: FlowDesign;
|
||||
onSave?: (design: FlowDesign) => Promise<void>;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
export function FlowDesigner({
|
||||
experimentId,
|
||||
initialDesign,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
}: FlowDesignerProps) {
|
||||
const [design, setDesign] = useState<FlowDesign>(initialDesign);
|
||||
const [selectedStepId, setSelectedStepId] = useState<string>();
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [editingStep, setEditingStep] = useState<FlowStep | null>(null);
|
||||
|
||||
// React Flow state
|
||||
const [reactFlowInstance, setReactFlowInstance] = useState(null);
|
||||
|
||||
const selectedStep = useMemo(() => {
|
||||
return design.steps.find((step) => step.id === selectedStepId);
|
||||
}, [design.steps, selectedStepId]);
|
||||
|
||||
const createStep = useCallback(
|
||||
(type: StepType, position: { x: number; y: number }): FlowStep => {
|
||||
const config = stepTypeConfig[type];
|
||||
const stepNumber = design.steps.length + 1;
|
||||
|
||||
return {
|
||||
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type,
|
||||
name: `${config.label} ${stepNumber}`,
|
||||
actions: [],
|
||||
parameters: {},
|
||||
position,
|
||||
};
|
||||
},
|
||||
[design.steps.length],
|
||||
);
|
||||
|
||||
const handleStepTypeAdd = useCallback(
|
||||
(type: StepType) => {
|
||||
const newPosition = {
|
||||
x: design.steps.length * 250 + 100,
|
||||
y: 100,
|
||||
};
|
||||
const newStep = createStep(type, newPosition);
|
||||
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: [...prev.steps, newStep],
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
setSelectedStepId(newStep.id);
|
||||
toast.success(`Added ${stepTypeConfig[type].label}`);
|
||||
},
|
||||
[createStep, design.steps.length],
|
||||
);
|
||||
|
||||
const handleStepDelete = useCallback(
|
||||
(stepId: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.filter((step) => step.id !== stepId),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
|
||||
if (selectedStepId === stepId) {
|
||||
setSelectedStepId(undefined);
|
||||
}
|
||||
|
||||
toast.success("Step deleted");
|
||||
},
|
||||
[selectedStepId],
|
||||
);
|
||||
|
||||
const handleStepDuplicate = useCallback((step: FlowStep) => {
|
||||
const newStep: FlowStep = {
|
||||
...step,
|
||||
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: `${step.name} (Copy)`,
|
||||
position: {
|
||||
x: step.position.x + 250,
|
||||
y: step.position.y,
|
||||
},
|
||||
actions: step.actions.map((action) => ({
|
||||
...action,
|
||||
id: `action-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
})),
|
||||
};
|
||||
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: [...prev.steps, newStep],
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
toast.success("Step duplicated");
|
||||
}, []);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node) => {
|
||||
setSelectedStepId(node.id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Convert design steps to React Flow nodes
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
return design.steps.map((step) => ({
|
||||
id: step.id,
|
||||
type: "stepNode",
|
||||
position: step.position,
|
||||
data: {
|
||||
step,
|
||||
onEdit: setEditingStep,
|
||||
onDelete: handleStepDelete,
|
||||
onDuplicate: handleStepDuplicate,
|
||||
isSelected: selectedStepId === step.id,
|
||||
},
|
||||
}));
|
||||
}, [design.steps, selectedStepId, handleStepDelete, handleStepDuplicate]);
|
||||
|
||||
// Auto-connect sequential steps based on position
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
const sortedSteps = [...design.steps].sort(
|
||||
(a, b) => a.position.x - b.position.x,
|
||||
);
|
||||
const newEdges: Edge[] = [];
|
||||
|
||||
for (let i = 0; i < sortedSteps.length - 1; i++) {
|
||||
const sourceStep = sortedSteps[i];
|
||||
const targetStep = sortedSteps[i + 1];
|
||||
|
||||
if (sourceStep && targetStep) {
|
||||
// Only auto-connect if steps are reasonably close horizontally
|
||||
const distance = Math.abs(
|
||||
targetStep.position.x - sourceStep.position.x,
|
||||
);
|
||||
if (distance < 400) {
|
||||
newEdges.push({
|
||||
id: `${sourceStep.id}-${targetStep.id}`,
|
||||
source: sourceStep.id,
|
||||
sourceHandle: "output",
|
||||
target: targetStep.id,
|
||||
targetHandle: "input",
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newEdges;
|
||||
}, [design.steps]);
|
||||
|
||||
const handleNodesChange = useCallback((changes: any[]) => {
|
||||
// Update step positions when nodes are moved
|
||||
const positionChanges = changes.filter(
|
||||
(change) => change.type === "position" && change.position,
|
||||
);
|
||||
if (positionChanges.length > 0) {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((step) => {
|
||||
const positionChange = positionChanges.find(
|
||||
(change) => change.id === step.id,
|
||||
);
|
||||
if (positionChange && positionChange.position) {
|
||||
return { ...step, position: positionChange.position };
|
||||
}
|
||||
return step;
|
||||
}),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConnect = useCallback((params: Connection) => {
|
||||
if (!params.source || !params.target) return;
|
||||
|
||||
// Update the design to reflect the new connection order
|
||||
setDesign((prev) => {
|
||||
const sourceStep = prev.steps.find((s) => s.id === params.source);
|
||||
const targetStep = prev.steps.find((s) => s.id === params.target);
|
||||
|
||||
if (sourceStep && targetStep) {
|
||||
// Automatically adjust positions to create a logical flow
|
||||
const updatedSteps = prev.steps.map((step) => {
|
||||
if (step.id === params.target) {
|
||||
return {
|
||||
...step,
|
||||
position: {
|
||||
x: Math.max(sourceStep.position.x + 300, step.position.x),
|
||||
y: step.position.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
setHasUnsavedChanges(true);
|
||||
toast.success("Steps connected successfully");
|
||||
return { ...prev, steps: updatedSteps };
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave) return;
|
||||
|
||||
try {
|
||||
const updatedDesign = {
|
||||
...design,
|
||||
version: design.version + 1,
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
|
||||
await onSave(updatedDesign);
|
||||
setDesign(updatedDesign);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Save error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStepUpdate = useCallback((updatedStep: FlowStep) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((step) =>
|
||||
step.id === updatedStep.id ? updatedStep : step,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
setEditingStep(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="bg-background/95 supports-[backdrop-filter]:bg-background/60 border-b p-4 backdrop-blur">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="font-semibold">{design.name}</h2>
|
||||
{hasUnsavedChanges && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500 bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<Undo className="mr-2 h-4 w-4" />
|
||||
Undo
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<Redo className="mr-2 h-4 w-4" />
|
||||
Redo
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasUnsavedChanges}
|
||||
size="sm"
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Flow Area */}
|
||||
<div className="relative flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
connectionLineType={"smoothstep" as any}
|
||||
snapToGrid={true}
|
||||
snapGrid={[20, 20]}
|
||||
defaultEdgeOptions={{
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
style: { strokeWidth: 2 },
|
||||
}}
|
||||
className="[&_.react-flow\_\_background]:bg-background [&_.react-flow\_\_controls]:bg-background [&_.react-flow\_\_controls]:border-border [&_.react-flow\_\_controls-button]:bg-background [&_.react-flow\_\_controls-button]:border-border [&_.react-flow\_\_controls-button]:text-foreground [&_.react-flow\_\_controls-button:hover]:bg-accent [&_.react-flow\_\_minimap]:bg-background [&_.react-flow\_\_minimap]:border-border [&_.react-flow\_\_edge-path]:stroke-muted-foreground [&_.react-flow\_\_controls]:shadow-sm [&_.react-flow\_\_edge-path]:stroke-2"
|
||||
>
|
||||
<Background
|
||||
variant={"dots" as any}
|
||||
gap={20}
|
||||
size={1}
|
||||
className="[&>*]:fill-muted-foreground/20"
|
||||
/>
|
||||
<Controls className="bg-background border-border rounded-lg shadow-lg" />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const step = design.steps.find((s) => s.id === node.id);
|
||||
return step
|
||||
? stepTypeConfig[step.type].color
|
||||
: "hsl(var(--muted))";
|
||||
}}
|
||||
className="bg-background border-border rounded-lg shadow-lg"
|
||||
/>
|
||||
|
||||
{/* Step Library Panel */}
|
||||
<Panel
|
||||
position="top-left"
|
||||
className="bg-card/95 supports-[backdrop-filter]:bg-card/80 rounded-lg border p-4 shadow-lg backdrop-blur"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Add Step</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(stepTypeConfig).map(([type, config]) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto justify-start p-2"
|
||||
onClick={() => handleStepTypeAdd(type as StepType)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`${config.bgColor} flex h-6 w-6 shrink-0 items-center justify-center rounded shadow-lg`}
|
||||
>
|
||||
<config.icon className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<span className="text-xs">{config.label}</span>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Info Panel */}
|
||||
<Panel
|
||||
position="top-right"
|
||||
className="bg-card/95 supports-[backdrop-filter]:bg-card/80 rounded-lg border p-4 shadow-lg backdrop-blur"
|
||||
>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Steps:</span>
|
||||
<span className="font-medium">{design.steps.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Actions:</span>
|
||||
<span className="font-medium">
|
||||
{design.steps.reduce(
|
||||
(sum, step) => sum + step.actions.length,
|
||||
0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Version:</span>
|
||||
<span className="font-medium">v{design.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Properties Sheet */}
|
||||
{selectedStep && (
|
||||
<Sheet
|
||||
open={!!selectedStep}
|
||||
onOpenChange={() => setSelectedStepId(undefined)}
|
||||
>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Step Properties</SheetTitle>
|
||||
<SheetDescription>
|
||||
Configure the selected step and its actions
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6 px-4 pb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="step-name">Name</Label>
|
||||
<Input
|
||||
id="step-name"
|
||||
value={selectedStep.name}
|
||||
onChange={(e) => {
|
||||
const updatedStep = {
|
||||
...selectedStep,
|
||||
name: e.target.value,
|
||||
};
|
||||
handleStepUpdate(updatedStep);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="step-description">Description</Label>
|
||||
<Textarea
|
||||
id="step-description"
|
||||
value={selectedStep.description ?? ""}
|
||||
onChange={(e) => {
|
||||
const updatedStep = {
|
||||
...selectedStep,
|
||||
description: e.target.value,
|
||||
};
|
||||
handleStepUpdate(updatedStep);
|
||||
}}
|
||||
placeholder="Optional description..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Step Type</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`${stepTypeConfig[selectedStep.type].bgColor} flex h-8 w-8 shrink-0 items-center justify-center rounded shadow-lg`}
|
||||
>
|
||||
{React.createElement(
|
||||
stepTypeConfig[selectedStep.type].icon,
|
||||
{
|
||||
className: "h-4 w-4 text-white",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{stepTypeConfig[selectedStep.type].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Actions ({selectedStep.actions.length})</Label>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Action
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="mt-2 h-[200px]">
|
||||
<div className="space-y-2 pr-4">
|
||||
{selectedStep.actions.map((action) => {
|
||||
const actionConfig = actionTypeConfig[action.type];
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className="bg-muted/50 flex items-center gap-2 rounded border p-2"
|
||||
>
|
||||
<actionConfig.icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{action.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Step Edit Dialog */}
|
||||
{editingStep && (
|
||||
<Sheet open={!!editingStep} onOpenChange={() => setEditingStep(null)}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Edit Step</SheetTitle>
|
||||
<SheetDescription>
|
||||
Modify step properties and actions
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6 px-4 pb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-step-name">Name</Label>
|
||||
<Input
|
||||
id="edit-step-name"
|
||||
value={editingStep.name}
|
||||
onChange={(e) => {
|
||||
setEditingStep({ ...editingStep, name: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-step-description">Description</Label>
|
||||
<Textarea
|
||||
id="edit-step-description"
|
||||
value={editingStep.description ?? ""}
|
||||
onChange={(e) => {
|
||||
setEditingStep({
|
||||
...editingStep,
|
||||
description: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder="Optional description..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-6">
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleStepUpdate(editingStep);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditingStep(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { FlowDesign, FlowStep, FlowAction, StepType, ActionType };
|
||||
148
src/components/experiments/designer/flow-theme.css
Normal file
148
src/components/experiments/designer/flow-theme.css
Normal file
@@ -0,0 +1,148 @@
|
||||
/* React Flow Theme Integration with shadcn/ui */
|
||||
|
||||
.react-flow {
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.react-flow__background {
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.react-flow__controls {
|
||||
background-color: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.react-flow__controls-button {
|
||||
background-color: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.react-flow__controls-button:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
.react-flow__controls-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.react-flow__minimap {
|
||||
background-color: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.react-flow__minimap-mask {
|
||||
fill: hsl(var(--primary) / 0.2);
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.react-flow__minimap-node {
|
||||
fill: hsl(var(--muted));
|
||||
stroke: hsl(var(--border));
|
||||
}
|
||||
|
||||
.react-flow__edge-path {
|
||||
stroke: hsl(var(--muted-foreground));
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.react-flow__edge-text {
|
||||
fill: hsl(var(--foreground));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.react-flow__connection-line {
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5;
|
||||
}
|
||||
|
||||
.react-flow__node.selected {
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary));
|
||||
}
|
||||
|
||||
.react-flow__handle {
|
||||
background-color: hsl(var(--primary));
|
||||
border: 2px solid hsl(var(--background));
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.react-flow__handle.connectingfrom {
|
||||
background-color: hsl(var(--primary));
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.react-flow__handle.connectingto {
|
||||
background-color: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.react-flow__background pattern circle {
|
||||
fill: hsl(var(--muted-foreground) / 0.3);
|
||||
}
|
||||
|
||||
.react-flow__background pattern rect {
|
||||
fill: hsl(var(--muted-foreground) / 0.1);
|
||||
}
|
||||
|
||||
/* Custom node animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom edge animations */
|
||||
.react-flow__edge.animated path {
|
||||
stroke-dasharray: 5;
|
||||
animation: dash 0.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
to {
|
||||
stroke-dashoffset: -10;
|
||||
}
|
||||
}
|
||||
|
||||
/* Selection box */
|
||||
.react-flow__selection {
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
border: 1px solid hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Pane (canvas area) */
|
||||
.react-flow__pane {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.react-flow__pane:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Attribution */
|
||||
.react-flow__attribution {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.react-flow__attribution a {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.react-flow__attribution a:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
@@ -3,20 +3,22 @@
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Play,
|
||||
Copy,
|
||||
Edit,
|
||||
Eye,
|
||||
FlaskConical,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
TestTube,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,8 +27,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type Experiment = {
|
||||
id: string;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { FlaskConical, Plus } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Plus, FlaskConical } from "lucide-react";
|
||||
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import { ActionButton, PageHeader } from "~/components/ui/page-header";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -12,11 +14,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
import { experimentsColumns, type Experiment } from "./experiments-columns";
|
||||
import { api } from "~/trpc/react";
|
||||
import { experimentsColumns, type Experiment } from "./experiments-columns";
|
||||
|
||||
export function ExperimentsDataTable() {
|
||||
const { activeStudy } = useActiveStudy();
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Users } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import {
|
||||
EntityForm,
|
||||
FormField,
|
||||
FormSection,
|
||||
NextSteps,
|
||||
Tips,
|
||||
} from "~/components/ui/entity-form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
@@ -14,17 +24,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import {
|
||||
EntityForm,
|
||||
FormField,
|
||||
FormSection,
|
||||
NextSteps,
|
||||
Tips,
|
||||
} from "~/components/ui/entity-form";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
const participantSchema = z.object({
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Copy,
|
||||
User,
|
||||
Edit,
|
||||
Eye,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
TestTube,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,8 +27,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type Participant = {
|
||||
id: string;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Users } from "lucide-react";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Users, AlertCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { ActionButton, PageHeader } from "~/components/ui/page-header";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,10 +15,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { participantsColumns, type Participant } from "./participants-columns";
|
||||
import { api } from "~/trpc/react";
|
||||
import { participantsColumns, type Participant } from "./participants-columns";
|
||||
|
||||
export function ParticipantsDataTable() {
|
||||
const [consentFilter, setConsentFilter] = React.useState("all");
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
Copy,
|
||||
Edit,
|
||||
Eye,
|
||||
FlaskConical,
|
||||
MoreHorizontal,
|
||||
TestTube,
|
||||
Trash2,
|
||||
Users,
|
||||
FlaskConical,
|
||||
TestTube,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,9 +27,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type Study = {
|
||||
id: string;
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TestTube } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import {
|
||||
EntityForm,
|
||||
FormField,
|
||||
FormSection,
|
||||
NextSteps,
|
||||
Tips,
|
||||
} from "~/components/ui/entity-form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
@@ -15,16 +24,7 @@ import {
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
EntityForm,
|
||||
FormField,
|
||||
FormSection,
|
||||
NextSteps,
|
||||
Tips,
|
||||
} from "~/components/ui/entity-form";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
const trialSchema = z.object({
|
||||
|
||||
@@ -3,25 +3,26 @@
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Play,
|
||||
Pause,
|
||||
StopCircle,
|
||||
Copy,
|
||||
TestTube,
|
||||
User,
|
||||
FlaskConical,
|
||||
Calendar,
|
||||
BarChart3,
|
||||
Copy,
|
||||
Edit,
|
||||
Eye,
|
||||
FlaskConical,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
StopCircle,
|
||||
TestTube,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -30,8 +31,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type Trial = {
|
||||
id: string;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
@@ -171,11 +171,11 @@ function CommandShortcut({
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { type UseFormReturn, type FieldValues } from "react-hook-form";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft, type LucideIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { type ReactNode } from "react";
|
||||
import { type FieldValues, type UseFormReturn } from "react-hook-form";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -15,8 +13,9 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface EntityFormProps<T extends FieldValues = FieldValues> {
|
||||
// Mode
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
protectedProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import {
|
||||
studyMembers,
|
||||
systemRoleEnum,
|
||||
users,
|
||||
userSystemRoles,
|
||||
studyMembers,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
export const usersRouter = createTRPCRouter({
|
||||
|
||||
80
src/types/participant.ts
Normal file
80
src/types/participant.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Participant type definitions for HRIStudio
|
||||
|
||||
export interface ParticipantDemographics {
|
||||
age?: number;
|
||||
gender?: string;
|
||||
grade?: number;
|
||||
background?: string;
|
||||
education?: string;
|
||||
occupation?: string;
|
||||
experience?: string;
|
||||
notes?: string;
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface ParticipantWithStudy {
|
||||
id: string;
|
||||
studyId: string;
|
||||
participantCode: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
demographics: ParticipantDemographics | null;
|
||||
consentGiven: boolean;
|
||||
consentDate: Date | null;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
study: {
|
||||
id: string;
|
||||
name: string;
|
||||
institution: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CreateParticipantData {
|
||||
studyId: string;
|
||||
participantCode: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
demographics?: ParticipantDemographics;
|
||||
consentGiven?: boolean;
|
||||
consentDate?: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateParticipantData {
|
||||
participantCode?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
demographics?: ParticipantDemographics;
|
||||
consentGiven?: boolean;
|
||||
consentDate?: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ParticipantListItem {
|
||||
id: string;
|
||||
studyId: string;
|
||||
participantCode: string;
|
||||
name: string | null;
|
||||
consentGiven: boolean;
|
||||
consentDate: Date | null;
|
||||
createdAt: Date;
|
||||
studyName: string;
|
||||
}
|
||||
|
||||
export interface ParticipantTrialSummary {
|
||||
id: string;
|
||||
experimentName: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
}
|
||||
|
||||
export interface ParticipantDetailData extends ParticipantWithStudy {
|
||||
trials: ParticipantTrialSummary[];
|
||||
upcomingTrials: ParticipantTrialSummary[];
|
||||
completedTrials: ParticipantTrialSummary[];
|
||||
}
|
||||
Reference in New Issue
Block a user