mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
chore: commit full workspace changes (designer modularization, diagnostics fixes, docs updates, seed script cleanup)
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
@@ -28,55 +29,20 @@ import { api } from "~/trpc/react";
|
||||
|
||||
// Dashboard Overview Cards
|
||||
function OverviewCards() {
|
||||
const utils = api.useUtils();
|
||||
|
||||
// Auto-refresh overview data when component mounts to catch external changes
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void utils.studies.list.invalidate();
|
||||
void utils.experiments.getUserExperiments.invalidate();
|
||||
void utils.trials.getUserTrials.invalidate();
|
||||
}, 60000); // Refresh every minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [utils]);
|
||||
|
||||
const { data: studiesData } = api.studies.list.useQuery(
|
||||
{ page: 1, limit: 1 },
|
||||
{
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
);
|
||||
const { data: experimentsData } = api.experiments.getUserExperiments.useQuery(
|
||||
{ page: 1, limit: 1 },
|
||||
{
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
);
|
||||
const { data: trialsData } = api.trials.getUserTrials.useQuery(
|
||||
{ page: 1, limit: 1 },
|
||||
{
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
);
|
||||
// TODO: Fix participants API call - needs actual study ID
|
||||
const participantsData = { pagination: { total: 0 } };
|
||||
const { data: stats, isLoading } = api.dashboard.getStats.useQuery();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Active Studies",
|
||||
value: studiesData?.pagination?.total ?? 0,
|
||||
description: "Research studies in progress",
|
||||
value: stats?.totalStudies ?? 0,
|
||||
description: "Research studies you have access to",
|
||||
icon: Building,
|
||||
color: "text-blue-600",
|
||||
bg: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
title: "Experiments",
|
||||
value: experimentsData?.pagination?.total ?? 0,
|
||||
value: stats?.totalExperiments ?? 0,
|
||||
description: "Experiment protocols designed",
|
||||
icon: FlaskConical,
|
||||
color: "text-green-600",
|
||||
@@ -84,7 +50,7 @@ function OverviewCards() {
|
||||
},
|
||||
{
|
||||
title: "Participants",
|
||||
value: participantsData?.pagination?.total ?? 0,
|
||||
value: stats?.totalParticipants ?? 0,
|
||||
description: "Enrolled participants",
|
||||
icon: Users,
|
||||
color: "text-purple-600",
|
||||
@@ -92,14 +58,33 @@ function OverviewCards() {
|
||||
},
|
||||
{
|
||||
title: "Trials",
|
||||
value: trialsData?.pagination?.total ?? 0,
|
||||
description: "Completed trials",
|
||||
value: stats?.totalTrials ?? 0,
|
||||
description: "Total trials conducted",
|
||||
icon: TestTube,
|
||||
color: "text-orange-600",
|
||||
bg: "bg-orange-50",
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="bg-muted h-4 w-20 animate-pulse rounded" />
|
||||
<div className="bg-muted h-8 w-8 animate-pulse rounded" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-muted h-8 w-12 animate-pulse rounded" />
|
||||
<div className="bg-muted mt-2 h-3 w-24 animate-pulse rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
@@ -122,41 +107,10 @@ function OverviewCards() {
|
||||
|
||||
// Recent Activity Component
|
||||
function RecentActivity() {
|
||||
// Mock data - replace with actual API calls
|
||||
const activities = [
|
||||
{
|
||||
id: "1",
|
||||
type: "trial_completed",
|
||||
title: "Trial #142 completed",
|
||||
description: "Memory retention study - Participant P001",
|
||||
time: "2 hours ago",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "experiment_created",
|
||||
title: "New experiment protocol",
|
||||
description: "Social interaction study v2.1",
|
||||
time: "4 hours ago",
|
||||
status: "info",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "participant_enrolled",
|
||||
title: "New participant enrolled",
|
||||
description: "P045 added to cognitive study",
|
||||
time: "6 hours ago",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
type: "trial_started",
|
||||
title: "Trial #143 started",
|
||||
description: "Attention span experiment",
|
||||
time: "8 hours ago",
|
||||
status: "pending",
|
||||
},
|
||||
];
|
||||
const { data: activities = [], isLoading } =
|
||||
api.dashboard.getRecentActivity.useQuery({
|
||||
limit: 8,
|
||||
});
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -180,24 +134,46 @@ function RecentActivity() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-center space-x-4">
|
||||
{getStatusIcon(activity.status)}
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{activity.description}
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center space-x-4">
|
||||
<div className="bg-muted h-4 w-4 animate-pulse rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="bg-muted h-4 w-3/4 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-1/2 animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{activity.time}
|
||||
))}
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<AlertCircle className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
No recent activity
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-center space-x-4">
|
||||
{getStatusIcon(activity.status)}
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{activity.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(activity.time, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -262,33 +238,10 @@ function QuickActions() {
|
||||
|
||||
// Study Progress Component
|
||||
function StudyProgress() {
|
||||
// Mock data - replace with actual API calls
|
||||
const studies = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Cognitive Load Study",
|
||||
progress: 75,
|
||||
participants: 24,
|
||||
totalParticipants: 30,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Social Interaction Research",
|
||||
progress: 45,
|
||||
participants: 18,
|
||||
totalParticipants: 40,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Memory Retention Analysis",
|
||||
progress: 90,
|
||||
participants: 45,
|
||||
totalParticipants: 50,
|
||||
status: "completing",
|
||||
},
|
||||
];
|
||||
const { data: studies = [], isLoading } =
|
||||
api.dashboard.getStudyProgress.useQuery({
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
@@ -299,31 +252,62 @@ function StudyProgress() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{studies.map((study) => (
|
||||
<div key={study.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{study.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{study.participants}/{study.totalParticipants} participants
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="bg-muted h-4 w-32 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-24 animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-5 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
<Badge
|
||||
variant={study.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{study.status}
|
||||
</Badge>
|
||||
<div className="bg-muted h-2 w-full animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
<Progress value={study.progress} className="h-2" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{study.progress}% complete
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : studies.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<Building className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
No active studies found
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Create a study to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{studies.map((study) => (
|
||||
<div key={study.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{study.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{study.participants}/{study.totalParticipants} completed
|
||||
trials
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
study.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{study.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress value={study.progress} className="h-2" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{study.progress}% complete
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { EnhancedBlockDesigner } from "~/components/experiments/designer/EnhancedBlockDesigner";
|
||||
import type { ExperimentBlock } from "~/components/experiments/designer/EnhancedBlockDesigner";
|
||||
import { BlockDesigner } from "~/components/experiments/designer/BlockDesigner";
|
||||
import type { ExperimentStep } from "~/lib/experiment-designer/types";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
interface ExperimentDesignerPageProps {
|
||||
@@ -22,19 +22,19 @@ export default async function ExperimentDesignerPage({
|
||||
|
||||
// Parse existing visual design if available
|
||||
const existingDesign = experiment.visualDesign as {
|
||||
blocks?: unknown[];
|
||||
steps?: unknown[];
|
||||
version?: number;
|
||||
lastSaved?: string;
|
||||
} | null;
|
||||
|
||||
// Only pass initialDesign if there's existing visual design data
|
||||
const initialDesign =
|
||||
existingDesign?.blocks && existingDesign.blocks.length > 0
|
||||
existingDesign?.steps && existingDesign.steps.length > 0
|
||||
? {
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
blocks: existingDesign.blocks as ExperimentBlock[],
|
||||
steps: existingDesign.steps as ExperimentStep[],
|
||||
version: existingDesign.version ?? 1,
|
||||
lastSaved:
|
||||
typeof existingDesign.lastSaved === "string"
|
||||
@@ -44,7 +44,7 @@ export default async function ExperimentDesignerPage({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<EnhancedBlockDesigner
|
||||
<BlockDesigner
|
||||
experimentId={experiment.id}
|
||||
initialDesign={initialDesign}
|
||||
/>
|
||||
@@ -66,13 +66,13 @@ export async function generateMetadata({
|
||||
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`,
|
||||
title: `${experiment?.name} - Designer | HRIStudio`,
|
||||
description: `Design experiment protocol for ${experiment?.name} using step-based editor`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: "Experiment Flow Designer | HRIStudio",
|
||||
description: "Immersive visual experiment protocol designer",
|
||||
title: "Experiment Designer | HRIStudio",
|
||||
description: "Step-based experiment protocol designer",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ interface DashboardContentProps {
|
||||
completedToday: number;
|
||||
canControl: boolean;
|
||||
canManage: boolean;
|
||||
recentTrials: any[];
|
||||
_recentTrials: unknown[];
|
||||
}
|
||||
|
||||
export function DashboardContent({
|
||||
@@ -24,7 +24,7 @@ export function DashboardContent({
|
||||
completedToday,
|
||||
canControl,
|
||||
canManage,
|
||||
recentTrials,
|
||||
_recentTrials,
|
||||
}: DashboardContentProps) {
|
||||
const getWelcomeMessage = () => {
|
||||
switch (userRole) {
|
||||
@@ -105,7 +105,7 @@ export function DashboardContent({
|
||||
},
|
||||
];
|
||||
|
||||
const alerts: any[] = [];
|
||||
const alerts: never[] = [];
|
||||
|
||||
const recentActivity = null;
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
TestTube,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useSidebar } from "~/components/ui/sidebar";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -27,6 +29,12 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -44,6 +52,8 @@ import { Avatar, AvatarImage, AvatarFallback } from "~/components/ui/avatar";
|
||||
import { Logo } from "~/components/ui/logo";
|
||||
|
||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
// Navigation items
|
||||
const navigationItems = [
|
||||
@@ -103,9 +113,17 @@ export function AppSidebar({
|
||||
const { data: session } = useSession();
|
||||
const pathname = usePathname();
|
||||
const isAdmin = userRole === "administrator";
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const isCollapsed = sidebarState === "collapsed";
|
||||
const { selectedStudyId, userStudies, selectStudy, refreshStudyData } =
|
||||
useStudyManagement();
|
||||
|
||||
// Debug API call
|
||||
const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
|
||||
enabled: process.env.NODE_ENV === "development",
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
});
|
||||
|
||||
type Study = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -130,6 +148,11 @@ export function AppSidebar({
|
||||
await selectStudy(studyId);
|
||||
} catch (error) {
|
||||
console.error("Failed to select study:", error);
|
||||
// Handle auth errors first
|
||||
if (isAuthError(error)) {
|
||||
await handleAuthError(error, "Session expired while selecting study");
|
||||
return;
|
||||
}
|
||||
// If study selection fails (e.g., study not found), clear the selection
|
||||
await selectStudy(null);
|
||||
}
|
||||
@@ -139,6 +162,18 @@ export function AppSidebar({
|
||||
(study: Study) => study.id === selectedStudyId,
|
||||
);
|
||||
|
||||
// Debug logging for study data
|
||||
React.useEffect(() => {
|
||||
console.log("Sidebar debug - User studies:", {
|
||||
count: userStudies.length,
|
||||
studies: userStudies.map((s) => ({ id: s.id, name: s.name })),
|
||||
selectedStudyId,
|
||||
selectedStudy: selectedStudy
|
||||
? { id: selectedStudy.id, name: selectedStudy.name }
|
||||
: null,
|
||||
});
|
||||
}, [userStudies, selectedStudyId, selectedStudy]);
|
||||
|
||||
// If we have a selectedStudyId but can't find the study, clear the selection
|
||||
React.useEffect(() => {
|
||||
if (selectedStudyId && userStudies.length > 0 && !selectedStudy) {
|
||||
@@ -152,12 +187,24 @@ export function AppSidebar({
|
||||
// Auto-refresh studies list when component mounts to catch external changes
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void refreshStudyData();
|
||||
void (async () => {
|
||||
try {
|
||||
await refreshStudyData();
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh study data:", error);
|
||||
if (isAuthError(error)) {
|
||||
void handleAuthError(error, "Session expired during data refresh");
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshStudyData]);
|
||||
|
||||
// Show debug info in development
|
||||
const showDebug = process.env.NODE_ENV === "development";
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="sidebar" {...props}>
|
||||
<SidebarHeader>
|
||||
@@ -165,7 +212,7 @@ export function AppSidebar({
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link href="/dashboard">
|
||||
<Logo iconSize="md" showText={true} />
|
||||
<Logo iconSize="md" showText={!isCollapsed} />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -179,52 +226,110 @@ export function AppSidebar({
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="w-full">
|
||||
<Building className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="w-full">
|
||||
<Building className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedStudy?.name ?? "Select Study"}
|
||||
</span>
|
||||
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width]"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>Studies</DropdownMenuLabel>
|
||||
{userStudies.map((study: Study) => (
|
||||
<DropdownMenuItem
|
||||
key={study.id}
|
||||
onClick={() => handleStudySelect(study.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate" title={study.name}>
|
||||
{study.name}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{selectedStudyId && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await selectStudy(null);
|
||||
}}
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 opacity-50" />
|
||||
Clear selection
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/studies/new">
|
||||
<Building className="mr-2 h-4 w-4" />
|
||||
Create study
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-sm">
|
||||
{selectedStudy?.name ?? "Select Study"}
|
||||
</span>
|
||||
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width]"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>Studies</DropdownMenuLabel>
|
||||
{userStudies.map((study: Study) => (
|
||||
<DropdownMenuItem
|
||||
key={study.id}
|
||||
onClick={() => handleStudySelect(study.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate" title={study.name}>
|
||||
{study.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="w-full">
|
||||
<Building className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedStudy?.name ?? "Select Study"}
|
||||
</span>
|
||||
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width]"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>Studies</DropdownMenuLabel>
|
||||
{userStudies.map((study: Study) => (
|
||||
<DropdownMenuItem
|
||||
key={study.id}
|
||||
onClick={() => handleStudySelect(study.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate" title={study.name}>
|
||||
{study.name}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{selectedStudyId && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await selectStudy(null);
|
||||
}}
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 opacity-50" />
|
||||
Clear selection
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/studies/new">
|
||||
<Building className="mr-2 h-4 w-4" />
|
||||
Create study
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{selectedStudyId && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await selectStudy(null);
|
||||
}}
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 opacity-50" />
|
||||
Clear selection
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/studies/new">
|
||||
<Building className="mr-2 h-4 w-4" />
|
||||
Create study
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
@@ -240,14 +345,29 @@ export function AppSidebar({
|
||||
pathname === item.url ||
|
||||
(item.url !== "/dashboard" && pathname.startsWith(item.url));
|
||||
|
||||
const menuButton = (
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{menuButton}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-sm">
|
||||
{item.title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
menuButton
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
@@ -256,7 +376,7 @@ export function AppSidebar({
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Study-specific items hint */}
|
||||
{!selectedStudyId && (
|
||||
{!selectedStudyId && !isCollapsed && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">
|
||||
@@ -276,14 +396,31 @@ export function AppSidebar({
|
||||
{adminItems.map((item) => {
|
||||
const isActive = pathname.startsWith(item.url);
|
||||
|
||||
const menuButton = (
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{menuButton}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-sm">
|
||||
{item.title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
menuButton
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
@@ -293,46 +430,135 @@ export function AppSidebar({
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
{/* Debug Info */}
|
||||
{showDebug && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Debug Info</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="text-muted-foreground space-y-1 px-3 py-2 text-xs">
|
||||
<div>Session: {session?.user?.email ?? "No session"}</div>
|
||||
<div>Role: {userRole ?? "No role"}</div>
|
||||
<div>Studies: {userStudies.length}</div>
|
||||
<div>Selected: {selectedStudy?.name ?? "None"}</div>
|
||||
<div>Auth: {session ? "✓" : "✗"}</div>
|
||||
{debugData && (
|
||||
<>
|
||||
<div>DB User: {debugData.user?.email ?? "None"}</div>
|
||||
<div>
|
||||
System Roles: {debugData.systemRoles.join(", ") || "None"}
|
||||
</div>
|
||||
<div>Memberships: {debugData.studyMemberships.length}</div>
|
||||
<div>All Studies: {debugData.allStudies.length}</div>
|
||||
<div>
|
||||
Session ID: {debugData.session.userId.slice(0, 8)}...
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="bg-slate-600 text-xs text-white">
|
||||
{(session?.user?.name ?? session?.user?.email ?? "U")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs">
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 border-2 border-slate-300">
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="bg-slate-600 text-xs text-white">
|
||||
{(
|
||||
session?.user?.name ??
|
||||
session?.user?.email ??
|
||||
"U"
|
||||
)
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs">
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 border-2 border-slate-300">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="bg-slate-600 text-xs text-white">
|
||||
{(
|
||||
session?.user?.name ??
|
||||
session?.user?.email ??
|
||||
"U"
|
||||
)
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs">
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Profile & Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-sm">
|
||||
{session?.user?.name ?? "User Menu"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
@@ -343,7 +569,7 @@ export function AppSidebar({
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
@@ -351,22 +577,53 @@ export function AppSidebar({
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Profile & Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 border-2 border-slate-300">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="bg-slate-600 text-xs text-white">
|
||||
{(session?.user?.name ?? session?.user?.email ?? "U")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs">
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Profile & Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
@@ -14,12 +14,12 @@ import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -228,7 +228,9 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
const date = row.getValue("createdAt");
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -306,20 +308,37 @@ export function ExperimentsTable() {
|
||||
const data: Experiment[] = React.useMemo(() => {
|
||||
if (!experimentsData) return [];
|
||||
|
||||
return experimentsData.map((exp: any) => ({
|
||||
interface RawExperiment {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status: Experiment["status"];
|
||||
version: number;
|
||||
estimatedDuration?: number | null;
|
||||
createdAt: string | Date;
|
||||
studyId: string;
|
||||
createdBy?: { name?: string | null; email?: string | null } | null;
|
||||
trialCount?: number | null;
|
||||
stepCount?: number | null;
|
||||
}
|
||||
|
||||
const adapt = (exp: RawExperiment): Experiment => ({
|
||||
id: exp.id,
|
||||
name: exp.name,
|
||||
description: exp.description,
|
||||
description: exp.description ?? "",
|
||||
status: exp.status,
|
||||
version: exp.version,
|
||||
estimatedDuration: exp.estimatedDuration,
|
||||
createdAt: exp.createdAt,
|
||||
estimatedDuration: exp.estimatedDuration ?? 0,
|
||||
createdAt:
|
||||
exp.createdAt instanceof Date ? exp.createdAt : new Date(exp.createdAt),
|
||||
studyId: exp.studyId,
|
||||
studyName: activeStudy?.title || "Unknown Study",
|
||||
createdByName: exp.createdBy?.name || exp.createdBy?.email || "Unknown",
|
||||
trialCount: exp.trialCount || 0,
|
||||
stepCount: exp.stepCount || 0,
|
||||
}));
|
||||
studyName: activeStudy?.title ?? "Unknown Study",
|
||||
createdByName: exp.createdBy?.name ?? exp.createdBy?.email ?? "Unknown",
|
||||
trialCount: exp.trialCount ?? 0,
|
||||
stepCount: exp.stepCount ?? 0,
|
||||
});
|
||||
|
||||
return experimentsData.map((e) => adapt(e as unknown as RawExperiment));
|
||||
}, [experimentsData, activeStudy]);
|
||||
|
||||
if (!activeStudy) {
|
||||
|
||||
236
src/components/experiments/designer/ActionLibrary.tsx
Normal file
236
src/components/experiments/designer/ActionLibrary.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
import {
|
||||
Plus,
|
||||
User,
|
||||
Bot,
|
||||
GitBranch,
|
||||
Eye,
|
||||
GripVertical,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
|
||||
// Local icon map (duplicated minimal map for isolation to avoid circular imports)
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Zap,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
};
|
||||
|
||||
interface DraggableActionProps {
|
||||
action: ActionDefinition;
|
||||
}
|
||||
|
||||
function DraggableAction({ action }: DraggableActionProps) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: `action-${action.id}`,
|
||||
data: { action },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const IconComponent = iconMap[action.icon] ?? Zap;
|
||||
|
||||
const categoryColors: Record<ActionDefinition["category"], string> = {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
"group hover:bg-accent/50 relative flex cursor-grab items-center gap-2 rounded-md border p-2 text-xs transition-colors",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
draggable={false}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-white",
|
||||
categoryColors[action.category],
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 truncate font-medium">
|
||||
{action.source.kind === "plugin" ? (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
|
||||
P
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
|
||||
C
|
||||
</span>
|
||||
)}
|
||||
{action.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{action.description ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
|
||||
{showTooltip && (
|
||||
<div className="bg-popover absolute top-0 left-full z-50 ml-2 max-w-xs rounded-md border p-2 text-xs shadow-md">
|
||||
<div className="font-medium">{action.name}</div>
|
||||
<div className="text-muted-foreground">{action.description}</div>
|
||||
<div className="mt-1 text-xs opacity-75">
|
||||
Category: {action.category} • ID: {action.id}
|
||||
</div>
|
||||
{action.parameters.length > 0 && (
|
||||
<div className="mt-1 text-xs opacity-75">
|
||||
Parameters: {action.parameters.map((p) => p.name).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ActionLibraryProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActionLibrary({ className }: ActionLibraryProps) {
|
||||
const registry = actionRegistry;
|
||||
const [activeCategory, setActiveCategory] =
|
||||
useState<ActionDefinition["category"]>("wizard");
|
||||
|
||||
const categories: Array<{
|
||||
key: ActionDefinition["category"];
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}> = [
|
||||
{
|
||||
key: "wizard",
|
||||
label: "Wizard",
|
||||
icon: User,
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
key: "robot",
|
||||
label: "Robot",
|
||||
icon: Bot,
|
||||
color: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
key: "control",
|
||||
label: "Control",
|
||||
icon: GitBranch,
|
||||
color: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
key: "observation",
|
||||
label: "Observe",
|
||||
icon: Eye,
|
||||
color: "bg-purple-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* Category tabs */}
|
||||
<div className="border-b p-2">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{categories.map((category) => {
|
||||
const IconComponent = category.icon;
|
||||
const isActive = activeCategory === category.key;
|
||||
return (
|
||||
<Button
|
||||
key={category.key}
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 justify-start text-xs",
|
||||
isActive && `${category.color} text-white hover:opacity-90`,
|
||||
)}
|
||||
onClick={() => setActiveCategory(category.key)}
|
||||
>
|
||||
<IconComponent className="mr-1 h-3 w-3" />
|
||||
{category.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions list */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{registry.getActionsByCategory(activeCategory).length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<Plus className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm">No actions available</p>
|
||||
<p className="text-xs">Check plugin configuration</p>
|
||||
</div>
|
||||
) : (
|
||||
registry
|
||||
.getActionsByCategory(activeCategory)
|
||||
.map((action) => <DraggableAction key={action.id} action={action} />)
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{registry.getAllActions().length} total
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{registry.getActionsByCategory(activeCategory).length} in view
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
450
src/components/experiments/designer/ActionRegistry.ts
Normal file
450
src/components/experiments/designer/ActionRegistry.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
"use client";
|
||||
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
|
||||
/**
|
||||
* ActionRegistry
|
||||
*
|
||||
* Central singleton for loading and serving action definitions from:
|
||||
* - Core system action JSON manifests (served from /hristudio-core/plugins/*.json)
|
||||
* - Study-installed plugin action definitions (ROS2 / REST / internal transports)
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Lazy, idempotent loading of core and plugin actions
|
||||
* - Provenance retention (core vs plugin, plugin id/version, robot id)
|
||||
* - Parameter schema → UI parameter mapping (primitive only for now)
|
||||
* - Fallback action population if core load fails (ensures minimal functionality)
|
||||
*
|
||||
* Notes:
|
||||
* - The registry is client-side only (designer runtime); server performs its own
|
||||
* validation & compilation using persisted action instances (never trusts client).
|
||||
* - Action IDs for plugins are namespaced: `${plugin.id}.${action.id}`.
|
||||
* - Core actions retain their base IDs (e.g., wait, wizard_speak) for clarity.
|
||||
*/
|
||||
export class ActionRegistry {
|
||||
private static instance: ActionRegistry;
|
||||
private actions = new Map<string, ActionDefinition>();
|
||||
private coreActionsLoaded = false;
|
||||
private pluginActionsLoaded = false;
|
||||
private loadedStudyId: string | null = null;
|
||||
|
||||
static getInstance(): ActionRegistry {
|
||||
if (!ActionRegistry.instance) {
|
||||
ActionRegistry.instance = new ActionRegistry();
|
||||
}
|
||||
return ActionRegistry.instance;
|
||||
}
|
||||
|
||||
/* ---------------- Core Actions ---------------- */
|
||||
|
||||
async loadCoreActions(): Promise<void> {
|
||||
if (this.coreActionsLoaded) return;
|
||||
|
||||
interface CoreBlockParam {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
value?: string | number | boolean;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
interface CoreBlock {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
parameters?: CoreBlockParam[];
|
||||
timeoutMs?: number;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
try {
|
||||
const coreActionSets = ["wizard-actions", "control-flow", "observation"];
|
||||
|
||||
for (const actionSetId of coreActionSets) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/hristudio-core/plugins/${actionSetId}.json`,
|
||||
);
|
||||
// Non-blocking skip if not found
|
||||
if (!response.ok) continue;
|
||||
|
||||
const rawActionSet = (await response.json()) as unknown;
|
||||
const actionSet = rawActionSet as { blocks?: CoreBlock[] };
|
||||
if (!actionSet.blocks || !Array.isArray(actionSet.blocks)) continue;
|
||||
|
||||
// Register each block as an ActionDefinition
|
||||
actionSet.blocks.forEach((block) => {
|
||||
if (!block.id || !block.name) return;
|
||||
|
||||
const actionDef: ActionDefinition = {
|
||||
id: block.id,
|
||||
type: block.id,
|
||||
name: block.name,
|
||||
description: block.description ?? "",
|
||||
category: this.mapBlockCategoryToActionCategory(block.category),
|
||||
icon: block.icon ?? "Zap",
|
||||
color: block.color ?? "#6b7280",
|
||||
parameters: (block.parameters ?? []).map((param) => ({
|
||||
id: param.id,
|
||||
name: param.name,
|
||||
type:
|
||||
(param.type as "text" | "number" | "select" | "boolean") ||
|
||||
"text",
|
||||
placeholder: param.placeholder,
|
||||
options: param.options,
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
value: param.value,
|
||||
required: param.required !== false,
|
||||
description: param.description,
|
||||
step: param.step,
|
||||
})),
|
||||
source: {
|
||||
kind: "core",
|
||||
baseActionId: block.id,
|
||||
},
|
||||
execution: {
|
||||
transport: "internal",
|
||||
timeoutMs: block.timeoutMs,
|
||||
retryable: block.retryable,
|
||||
},
|
||||
parameterSchemaRaw: {
|
||||
parameters: block.parameters ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
this.actions.set(actionDef.id, actionDef);
|
||||
});
|
||||
} catch (error) {
|
||||
// Non-fatal: we will fallback later
|
||||
console.warn(`Failed to load core action set ${actionSetId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.coreActionsLoaded = true;
|
||||
} catch (error) {
|
||||
console.error("Failed to load core actions:", error);
|
||||
this.loadFallbackActions();
|
||||
}
|
||||
}
|
||||
|
||||
private mapBlockCategoryToActionCategory(
|
||||
category: string,
|
||||
): ActionDefinition["category"] {
|
||||
switch (category) {
|
||||
case "wizard":
|
||||
case "event":
|
||||
return "wizard";
|
||||
case "robot":
|
||||
return "robot";
|
||||
case "control":
|
||||
return "control";
|
||||
case "sensor":
|
||||
case "observation":
|
||||
return "observation";
|
||||
default:
|
||||
return "wizard";
|
||||
}
|
||||
}
|
||||
|
||||
private loadFallbackActions(): void {
|
||||
const fallbackActions: ActionDefinition[] = [
|
||||
{
|
||||
id: "wizard_speak",
|
||||
type: "wizard_speak",
|
||||
name: "Wizard Says",
|
||||
description: "Wizard speaks to participant",
|
||||
category: "wizard",
|
||||
icon: "MessageSquare",
|
||||
color: "#3b82f6",
|
||||
parameters: [
|
||||
{
|
||||
id: "text",
|
||||
name: "Text to say",
|
||||
type: "text",
|
||||
placeholder: "Hello, participant!",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wizard_speak" },
|
||||
execution: { transport: "internal", timeoutMs: 30000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
required: ["text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
name: "Wait",
|
||||
description: "Wait for specified time",
|
||||
category: "control",
|
||||
icon: "Clock",
|
||||
color: "#f59e0b",
|
||||
parameters: [
|
||||
{
|
||||
id: "duration",
|
||||
name: "Duration (seconds)",
|
||||
type: "number",
|
||||
min: 0.1,
|
||||
max: 300,
|
||||
value: 2,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wait" },
|
||||
execution: { transport: "internal", timeoutMs: 60000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
duration: {
|
||||
type: "number",
|
||||
minimum: 0.1,
|
||||
maximum: 300,
|
||||
default: 2,
|
||||
},
|
||||
},
|
||||
required: ["duration"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "observe",
|
||||
type: "observe",
|
||||
name: "Observe",
|
||||
description: "Record participant behavior",
|
||||
category: "observation",
|
||||
icon: "Eye",
|
||||
color: "#8b5cf6",
|
||||
parameters: [
|
||||
{
|
||||
id: "behavior",
|
||||
name: "Behavior to observe",
|
||||
type: "select",
|
||||
options: ["facial_expression", "body_language", "verbal_response"],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "observe" },
|
||||
execution: { transport: "internal", timeoutMs: 120000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
behavior: {
|
||||
type: "string",
|
||||
enum: ["facial_expression", "body_language", "verbal_response"],
|
||||
},
|
||||
},
|
||||
required: ["behavior"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
fallbackActions.forEach((action) => this.actions.set(action.id, action));
|
||||
}
|
||||
|
||||
/* ---------------- Plugin Actions ---------------- */
|
||||
|
||||
loadPluginActions(
|
||||
studyId: string,
|
||||
studyPlugins: Array<{
|
||||
plugin: {
|
||||
id: string;
|
||||
robotId: string | null;
|
||||
version: string | null;
|
||||
actionDefinitions?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
timeout?: number;
|
||||
retryable?: boolean;
|
||||
parameterSchema?: unknown;
|
||||
ros2?: {
|
||||
topic?: string;
|
||||
messageType?: string;
|
||||
service?: string;
|
||||
action?: string;
|
||||
payloadMapping?: unknown;
|
||||
qos?: {
|
||||
reliability?: string;
|
||||
durability?: string;
|
||||
history?: string;
|
||||
depth?: number;
|
||||
};
|
||||
};
|
||||
rest?: {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>,
|
||||
): void {
|
||||
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
|
||||
|
||||
if (this.loadedStudyId !== studyId) {
|
||||
this.resetPluginActions();
|
||||
}
|
||||
|
||||
(studyPlugins ?? []).forEach((studyPlugin) => {
|
||||
const { plugin } = studyPlugin;
|
||||
const actionDefs = Array.isArray(plugin.actionDefinitions)
|
||||
? plugin.actionDefinitions
|
||||
: undefined;
|
||||
if (!actionDefs) return;
|
||||
|
||||
actionDefs.forEach((action) => {
|
||||
const category =
|
||||
(action.category as ActionDefinition["category"]) || "robot";
|
||||
|
||||
const execution = action.ros2
|
||||
? {
|
||||
transport: "ros2" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
ros2: {
|
||||
topic: action.ros2.topic,
|
||||
messageType: action.ros2.messageType,
|
||||
service: action.ros2.service,
|
||||
action: action.ros2.action,
|
||||
qos: action.ros2.qos,
|
||||
payloadMapping: action.ros2.payloadMapping,
|
||||
},
|
||||
}
|
||||
: action.rest
|
||||
? {
|
||||
transport: "rest" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
rest: {
|
||||
method: action.rest.method,
|
||||
path: action.rest.path,
|
||||
headers: action.rest.headers,
|
||||
},
|
||||
}
|
||||
: {
|
||||
transport: "internal" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
};
|
||||
|
||||
const actionDef: ActionDefinition = {
|
||||
id: `${plugin.id}.${action.id}`,
|
||||
type: `${plugin.id}.${action.id}`,
|
||||
name: action.name,
|
||||
description: action.description ?? "",
|
||||
category,
|
||||
icon: action.icon ?? "Bot",
|
||||
color: "#10b981",
|
||||
parameters: this.convertParameterSchemaToParameters(
|
||||
action.parameterSchema,
|
||||
),
|
||||
source: {
|
||||
kind: "plugin",
|
||||
pluginId: plugin.id,
|
||||
robotId: plugin.robotId,
|
||||
pluginVersion: plugin.version ?? undefined,
|
||||
baseActionId: action.id,
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: action.parameterSchema ?? undefined,
|
||||
};
|
||||
this.actions.set(actionDef.id, actionDef);
|
||||
});
|
||||
});
|
||||
|
||||
this.pluginActionsLoaded = true;
|
||||
this.loadedStudyId = studyId;
|
||||
}
|
||||
|
||||
private convertParameterSchemaToParameters(
|
||||
parameterSchema: unknown,
|
||||
): ActionDefinition["parameters"] {
|
||||
interface JsonSchemaProperty {
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
enum?: string[];
|
||||
default?: string | number | boolean;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
}
|
||||
interface JsonSchema {
|
||||
properties?: Record<string, JsonSchemaProperty>;
|
||||
required?: string[];
|
||||
}
|
||||
const schema = parameterSchema as JsonSchema | undefined;
|
||||
if (!schema?.properties) return [];
|
||||
|
||||
return Object.entries(schema.properties).map(([key, paramDef]) => {
|
||||
let type: "text" | "number" | "select" | "boolean" = "text";
|
||||
|
||||
if (paramDef.type === "number") {
|
||||
type = "number";
|
||||
} else if (paramDef.type === "boolean") {
|
||||
type = "boolean";
|
||||
} else if (paramDef.enum && Array.isArray(paramDef.enum)) {
|
||||
type = "select";
|
||||
}
|
||||
|
||||
return {
|
||||
id: key,
|
||||
name: paramDef.title ?? key.charAt(0).toUpperCase() + key.slice(1),
|
||||
type,
|
||||
value: paramDef.default,
|
||||
placeholder: paramDef.description,
|
||||
options: paramDef.enum,
|
||||
min: paramDef.minimum,
|
||||
max: paramDef.maximum,
|
||||
required: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private resetPluginActions(): void {
|
||||
this.pluginActionsLoaded = false;
|
||||
this.loadedStudyId = null;
|
||||
// Remove existing plugin actions (retain known core ids + fallback ids)
|
||||
const pluginActionIds = Array.from(this.actions.keys()).filter(
|
||||
(id) =>
|
||||
!id.startsWith("wizard_") &&
|
||||
!id.startsWith("wait") &&
|
||||
!id.startsWith("observe"),
|
||||
);
|
||||
pluginActionIds.forEach((id) => this.actions.delete(id));
|
||||
}
|
||||
|
||||
/* ---------------- Query Helpers ---------------- */
|
||||
|
||||
getActionsByCategory(
|
||||
category: ActionDefinition["category"],
|
||||
): ActionDefinition[] {
|
||||
return Array.from(this.actions.values()).filter(
|
||||
(action) => action.category === category,
|
||||
);
|
||||
}
|
||||
|
||||
getAllActions(): ActionDefinition[] {
|
||||
return Array.from(this.actions.values());
|
||||
}
|
||||
|
||||
getAction(id: string): ActionDefinition | undefined {
|
||||
return this.actions.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
export const actionRegistry = ActionRegistry.getInstance();
|
||||
670
src/components/experiments/designer/BlockDesigner.tsx
Normal file
670
src/components/experiments/designer/BlockDesigner.tsx
Normal file
@@ -0,0 +1,670 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* BlockDesigner (Modular Refactor)
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Own overall experiment design state (steps + actions)
|
||||
* - Coordinate drag & drop between ActionLibrary (source) and StepFlow (targets)
|
||||
* - Persist design via experiments.update mutation (optionally compiling execution graph)
|
||||
* - Trigger server-side validation (experiments.validateDesign) to obtain integrity hash
|
||||
* - Track & surface "hash drift" (design changed since last validation or mismatch with stored integrityHash)
|
||||
*
|
||||
* Extracted Modules:
|
||||
* - ActionRegistry -> ./ActionRegistry.ts
|
||||
* - ActionLibrary -> ./ActionLibrary.tsx
|
||||
* - StepFlow -> ./StepFlow.tsx
|
||||
* - PropertiesPanel -> ./PropertiesPanel.tsx
|
||||
*
|
||||
* Enhancements Added Here:
|
||||
* - Hash drift indicator logic (Validated / Drift / Unvalidated)
|
||||
* - Modular wiring replacing previous monolithic file
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Save, Download, Play, Plus } from "lucide-react";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
|
||||
import {
|
||||
type ExperimentDesign,
|
||||
type ExperimentStep,
|
||||
type ExperimentAction,
|
||||
type ActionDefinition,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { ActionLibrary } from "./ActionLibrary";
|
||||
import { StepFlow } from "./StepFlow";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utilities */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build a lightweight JSON string representing the current design for drift checks.
|
||||
* We include full steps & actions; param value churn will intentionally flag drift
|
||||
* (acceptable trade-off for now; can switch to structural signature if too noisy).
|
||||
*/
|
||||
function serializeDesignSteps(steps: ExperimentStep[]): string {
|
||||
return JSON.stringify(
|
||||
steps.map((s) => ({
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
conditionKeys: Object.keys(s.trigger.conditions).sort(),
|
||||
},
|
||||
actions: s.actions.map((a) => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
sourceKind: a.source.kind,
|
||||
pluginId: a.source.pluginId,
|
||||
pluginVersion: a.source.pluginVersion,
|
||||
transport: a.execution.transport,
|
||||
parameterKeys: Object.keys(a.parameters).sort(),
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Props */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface BlockDesignerProps {
|
||||
experimentId: string;
|
||||
initialDesign?: ExperimentDesign;
|
||||
onSave?: (design: ExperimentDesign) => void;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function BlockDesigner({
|
||||
experimentId,
|
||||
initialDesign,
|
||||
onSave,
|
||||
}: BlockDesignerProps) {
|
||||
/* ---------------------------- Experiment Query ---------------------------- */
|
||||
const { data: experiment } = api.experiments.get.useQuery({
|
||||
id: experimentId,
|
||||
});
|
||||
|
||||
/* ------------------------------ Local Design ------------------------------ */
|
||||
const [design, setDesign] = useState<ExperimentDesign>(() => {
|
||||
const defaultDesign: ExperimentDesign = {
|
||||
id: experimentId,
|
||||
name: "New Experiment",
|
||||
description: "",
|
||||
steps: [],
|
||||
version: 1,
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
return initialDesign ?? defaultDesign;
|
||||
});
|
||||
|
||||
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
|
||||
const [selectedActionId, setSelectedActionId] = useState<string | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
/* ------------------------- Validation / Drift Tracking -------------------- */
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [lastValidatedHash, setLastValidatedHash] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [lastValidatedDesignJson, setLastValidatedDesignJson] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
// Recompute drift conditions
|
||||
const currentDesignJson = useMemo(
|
||||
() => serializeDesignSteps(design.steps),
|
||||
[design.steps],
|
||||
);
|
||||
|
||||
const hasIntegrityHash = !!experiment?.integrityHash;
|
||||
const hashMismatch =
|
||||
hasIntegrityHash &&
|
||||
lastValidatedHash &&
|
||||
experiment?.integrityHash !== lastValidatedHash;
|
||||
const designChangedSinceValidation =
|
||||
!!lastValidatedDesignJson && lastValidatedDesignJson !== currentDesignJson;
|
||||
|
||||
const drift =
|
||||
hasIntegrityHash && (hashMismatch ? true : designChangedSinceValidation);
|
||||
|
||||
/* ---------------------------- Active Drag State --------------------------- */
|
||||
// Removed unused activeId state (drag overlay removed in modular refactor)
|
||||
|
||||
/* ------------------------------- tRPC Mutations --------------------------- */
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Experiment saved");
|
||||
setHasUnsavedChanges(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to save: ${err.message}`);
|
||||
},
|
||||
});
|
||||
const trpcUtils = api.useUtils();
|
||||
|
||||
/* ------------------------------- Plugins Load ----------------------------- */
|
||||
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
|
||||
{ studyId: experiment?.studyId ?? "" },
|
||||
{ enabled: !!experiment?.studyId },
|
||||
);
|
||||
|
||||
/* ---------------------------- Registry Loading ---------------------------- */
|
||||
useEffect(() => {
|
||||
actionRegistry.loadCoreActions().catch((err) => {
|
||||
console.error("Core actions load failed:", err);
|
||||
toast.error("Failed to load core action library");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (experiment?.studyId && (studyPlugins?.length ?? 0) > 0) {
|
||||
actionRegistry.loadPluginActions(
|
||||
experiment.studyId,
|
||||
(studyPlugins ?? []).map((sp) => ({
|
||||
plugin: {
|
||||
id: sp.plugin.id,
|
||||
robotId: sp.plugin.robotId,
|
||||
version: sp.plugin.version,
|
||||
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
|
||||
? sp.plugin.actionDefinitions
|
||||
: undefined,
|
||||
},
|
||||
})) ?? [],
|
||||
);
|
||||
}
|
||||
}, [experiment?.studyId, studyPlugins]);
|
||||
|
||||
/* ------------------------------ Breadcrumbs ------------------------------- */
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${experiment?.studyId}`,
|
||||
},
|
||||
{ label: "Experiments", href: `/studies/${experiment?.studyId}` },
|
||||
{ label: design.name, href: `/experiments/${experimentId}` },
|
||||
{ label: "Designer" },
|
||||
]);
|
||||
|
||||
/* ------------------------------ DnD Sensors ------------------------------- */
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((_event: DragStartEvent) => {
|
||||
// activeId tracking removed (drag overlay no longer used)
|
||||
}, []);
|
||||
|
||||
/* ------------------------------ Helpers ----------------------------------- */
|
||||
|
||||
const addActionToStep = useCallback(
|
||||
(stepId: string, def: ActionDefinition) => {
|
||||
const newAction: ExperimentAction = {
|
||||
id: `action_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
type: def.type,
|
||||
name: def.name,
|
||||
parameters: {},
|
||||
category: def.category,
|
||||
source: def.source,
|
||||
execution: def.execution ?? { transport: "internal" },
|
||||
parameterSchemaRaw: def.parameterSchemaRaw,
|
||||
};
|
||||
// Default param values
|
||||
def.parameters.forEach((p) => {
|
||||
if (p.value !== undefined) {
|
||||
newAction.parameters[p.id] = p.value;
|
||||
}
|
||||
});
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId ? { ...s, actions: [...s.actions, newAction] } : s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
toast.success(`Added ${def.name}`);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
// activeId reset removed (no longer tracked)
|
||||
if (!over) return;
|
||||
|
||||
const activeIdStr = active.id.toString();
|
||||
const overIdStr = over.id.toString();
|
||||
|
||||
// From library to step droppable
|
||||
if (activeIdStr.startsWith("action-") && overIdStr.startsWith("step-")) {
|
||||
const actionId = activeIdStr.replace("action-", "");
|
||||
const stepId = overIdStr.replace("step-", "");
|
||||
const def = actionRegistry.getAction(actionId);
|
||||
if (def) {
|
||||
addActionToStep(stepId, def);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step reorder (both plain ids of steps)
|
||||
if (
|
||||
!activeIdStr.startsWith("action-") &&
|
||||
!overIdStr.startsWith("step-") &&
|
||||
!overIdStr.startsWith("action-")
|
||||
) {
|
||||
const oldIndex = design.steps.findIndex((s) => s.id === activeIdStr);
|
||||
const newIndex = design.steps.findIndex((s) => s.id === overIdStr);
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: arrayMove(prev.steps, oldIndex, newIndex).map(
|
||||
(s, index) => ({ ...s, order: index }),
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Action reorder (within same step)
|
||||
if (
|
||||
!activeIdStr.startsWith("action-") &&
|
||||
!overIdStr.startsWith("step-") &&
|
||||
activeIdStr !== overIdStr
|
||||
) {
|
||||
// Identify which step these actions belong to
|
||||
const containingStep = design.steps.find((s) =>
|
||||
s.actions.some((a) => a.id === activeIdStr),
|
||||
);
|
||||
const targetStep = design.steps.find((s) =>
|
||||
s.actions.some((a) => a.id === overIdStr),
|
||||
);
|
||||
if (
|
||||
containingStep &&
|
||||
targetStep &&
|
||||
containingStep.id === targetStep.id
|
||||
) {
|
||||
const oldActionIndex = containingStep.actions.findIndex(
|
||||
(a) => a.id === activeIdStr,
|
||||
);
|
||||
const newActionIndex = containingStep.actions.findIndex(
|
||||
(a) => a.id === overIdStr,
|
||||
);
|
||||
if (
|
||||
oldActionIndex !== -1 &&
|
||||
newActionIndex !== -1 &&
|
||||
oldActionIndex !== newActionIndex
|
||||
) {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === containingStep.id
|
||||
? {
|
||||
...s,
|
||||
actions: arrayMove(
|
||||
s.actions,
|
||||
oldActionIndex,
|
||||
newActionIndex,
|
||||
),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[design.steps, addActionToStep],
|
||||
);
|
||||
|
||||
const addStep = useCallback(() => {
|
||||
const newStep: ExperimentStep = {
|
||||
id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: `Step ${design.steps.length + 1}`,
|
||||
description: "",
|
||||
type: "sequential",
|
||||
order: design.steps.length,
|
||||
trigger: {
|
||||
type: design.steps.length === 0 ? "trial_start" : "previous_step",
|
||||
conditions: {},
|
||||
},
|
||||
actions: [],
|
||||
expanded: true,
|
||||
};
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: [...prev.steps, newStep],
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}, [design.steps.length]);
|
||||
|
||||
const updateStep = useCallback(
|
||||
(stepId: string, updates: Partial<ExperimentStep>) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId ? { ...s, ...updates } : s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteStep = useCallback(
|
||||
(stepId: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.filter((s) => s.id !== stepId),
|
||||
}));
|
||||
if (selectedStepId === stepId) setSelectedStepId(null);
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[selectedStepId],
|
||||
);
|
||||
|
||||
const updateAction = useCallback(
|
||||
(stepId: string, actionId: string, updates: Partial<ExperimentAction>) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: s.actions.map((a) =>
|
||||
a.id === actionId ? { ...a, ...updates } : a,
|
||||
),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteAction = useCallback(
|
||||
(stepId: string, actionId: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: s.actions.filter((a) => a.id !== actionId),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
if (selectedActionId === actionId) setSelectedActionId(null);
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[selectedActionId],
|
||||
);
|
||||
|
||||
/* ------------------------------- Validation ------------------------------- */
|
||||
const runValidation = useCallback(async () => {
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const result = await trpcUtils.experiments.validateDesign.fetch({
|
||||
experimentId,
|
||||
visualDesign: { steps: design.steps },
|
||||
});
|
||||
|
||||
if (!result.valid) {
|
||||
toast.error(
|
||||
`Validation failed: ${result.issues.slice(0, 3).join(", ")}${
|
||||
result.issues.length > 3 ? "…" : ""
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.integrityHash) {
|
||||
setLastValidatedHash(result.integrityHash);
|
||||
setLastValidatedDesignJson(currentDesignJson);
|
||||
toast.success(
|
||||
`Validated • Hash: ${result.integrityHash.slice(0, 10)}…`,
|
||||
);
|
||||
} else {
|
||||
toast.success("Validated (no hash produced)");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Validation error: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [experimentId, design.steps, trpcUtils, currentDesignJson]);
|
||||
|
||||
/* --------------------------------- Saving --------------------------------- */
|
||||
const saveDesign = useCallback(() => {
|
||||
const visualDesign = {
|
||||
steps: design.steps,
|
||||
version: design.version,
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
updateExperiment.mutate({
|
||||
id: experimentId,
|
||||
visualDesign,
|
||||
createSteps: true,
|
||||
compileExecution: true,
|
||||
});
|
||||
const updatedDesign = { ...design, lastSaved: new Date() };
|
||||
setDesign(updatedDesign);
|
||||
onSave?.(updatedDesign);
|
||||
}, [design, experimentId, onSave, updateExperiment]);
|
||||
|
||||
/* --------------------------- Selection Resolution ------------------------- */
|
||||
const selectedStep = design.steps.find((s) => s.id === selectedStepId);
|
||||
const selectedAction = selectedStep?.actions.find(
|
||||
(a) => a.id === selectedActionId,
|
||||
);
|
||||
|
||||
/* ------------------------------- Header Badges ---------------------------- */
|
||||
const validationBadge = drift ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
title="Design has drifted since last validation or differs from stored hash"
|
||||
>
|
||||
Drift
|
||||
</Badge>
|
||||
) : lastValidatedHash ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-400 text-xs text-green-700 dark:text-green-400"
|
||||
title="Design matches last validated structure"
|
||||
>
|
||||
Validated
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs" title="Not yet validated">
|
||||
Unvalidated
|
||||
</Badge>
|
||||
);
|
||||
|
||||
/* ---------------------------------- Render -------------------------------- */
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={design.name}
|
||||
description="Design your experiment using steps and categorized actions"
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{validationBadge}
|
||||
{experiment?.integrityHash && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Hash: {experiment.integrityHash.slice(0, 10)}…
|
||||
</Badge>
|
||||
)}
|
||||
{experiment?.executionGraphSummary && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Exec: {experiment.executionGraphSummary.steps ?? 0}s /
|
||||
{experiment.executionGraphSummary.actions ?? 0}a
|
||||
</Badge>
|
||||
)}
|
||||
{Array.isArray(experiment?.pluginDependencies) &&
|
||||
experiment.pluginDependencies.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{experiment.pluginDependencies.length} plugins
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{design.steps.length} steps
|
||||
</Badge>
|
||||
{hasUnsavedChanges && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 text-orange-600"
|
||||
>
|
||||
Unsaved
|
||||
</Badge>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={saveDesign}
|
||||
disabled={!hasUnsavedChanges || updateExperiment.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{updateExperiment.isPending ? "Saving…" : "Save"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setHasUnsavedChanges(false); // immediate feedback
|
||||
void runValidation();
|
||||
}}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{isValidating ? "Validating…" : "Revalidate"}
|
||||
</ActionButton>
|
||||
<ActionButton variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Action Library */}
|
||||
<div className="col-span-3">
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Action Library
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ActionLibrary />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Flow */}
|
||||
<div className="col-span-6">
|
||||
<StepFlow
|
||||
steps={design.steps}
|
||||
selectedStepId={selectedStepId}
|
||||
selectedActionId={selectedActionId}
|
||||
onStepSelect={(id) => {
|
||||
setSelectedStepId(id);
|
||||
setSelectedActionId(null);
|
||||
}}
|
||||
onStepDelete={deleteStep}
|
||||
onStepUpdate={updateStep}
|
||||
onActionSelect={(actionId) => setSelectedActionId(actionId)}
|
||||
onActionDelete={deleteAction}
|
||||
emptyState={
|
||||
<div className="py-8 text-center">
|
||||
<Play className="text-muted-foreground/50 mx-auto h-8 w-8" />
|
||||
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Add your first step to begin designing
|
||||
</p>
|
||||
<Button className="mt-2" size="sm" onClick={addStep}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add First Step
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
headerRight={
|
||||
<Button size="sm" onClick={addStep} className="h-6 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add Step
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Properties */}
|
||||
<div className="col-span-3">
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
Properties
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ScrollArea className="h-full pr-1">
|
||||
<PropertiesPanel
|
||||
design={design}
|
||||
selectedStep={selectedStep}
|
||||
selectedAction={selectedAction}
|
||||
onActionUpdate={updateAction}
|
||||
onStepUpdate={updateStep}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
461
src/components/experiments/designer/PropertiesPanel.tsx
Normal file
461
src/components/experiments/designer/PropertiesPanel.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "~/components/ui/select";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Slider } from "~/components/ui/slider";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
TRIGGER_OPTIONS,
|
||||
type ExperimentAction,
|
||||
type ExperimentStep,
|
||||
type StepType,
|
||||
type TriggerType,
|
||||
type ExperimentDesign,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import {
|
||||
Settings,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* PropertiesPanel
|
||||
*
|
||||
* Extracted modular panel for editing either:
|
||||
* - Action properties (when an action is selected)
|
||||
* - Step properties (when a step is selected and no action selected)
|
||||
* - Empty instructional state otherwise
|
||||
*
|
||||
* Enhancements:
|
||||
* - Boolean parameters render as Switch
|
||||
* - Number parameters with min/max render as Slider (with live value)
|
||||
* - Number parameters without bounds fall back to numeric input
|
||||
* - Select and text remain standard controls
|
||||
* - Provenance + category badges retained
|
||||
*/
|
||||
|
||||
export interface PropertiesPanelProps {
|
||||
design: ExperimentDesign;
|
||||
selectedStep?: ExperimentStep;
|
||||
selectedAction?: ExperimentAction;
|
||||
onActionUpdate: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
updates: Partial<ExperimentAction>,
|
||||
) => void;
|
||||
onStepUpdate: (stepId: string, updates: Partial<ExperimentStep>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PropertiesPanel({
|
||||
design,
|
||||
selectedStep,
|
||||
selectedAction,
|
||||
onActionUpdate,
|
||||
onStepUpdate,
|
||||
className,
|
||||
}: PropertiesPanelProps) {
|
||||
const registry = actionRegistry;
|
||||
|
||||
// Find containing step for selected action (if any)
|
||||
const containingStep =
|
||||
selectedAction &&
|
||||
design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id));
|
||||
|
||||
/* -------------------------- Action Properties View -------------------------- */
|
||||
if (selectedAction && containingStep) {
|
||||
const def = registry.getAction(selectedAction.type);
|
||||
const categoryColors = {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
} as const;
|
||||
// Icon resolution uses statically imported lucide icons (no dynamic require)
|
||||
|
||||
// Icon resolution uses statically imported lucide icons (no dynamic require)
|
||||
const iconComponents: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
};
|
||||
const ResolvedIcon: React.ComponentType<{ className?: string }> =
|
||||
def?.icon && iconComponents[def.icon]
|
||||
? (iconComponents[def.icon] as React.ComponentType<{
|
||||
className?: string;
|
||||
}>)
|
||||
: Zap;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* Header / Metadata */}
|
||||
<div className="border-b pb-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{def && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded text-white",
|
||||
categoryColors[def.category],
|
||||
)}
|
||||
>
|
||||
<ResolvedIcon className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-medium">
|
||||
{selectedAction.name}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{def?.category} • {selectedAction.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
{selectedAction.source.kind === "plugin" ? "Plugin" : "Core"}
|
||||
</Badge>
|
||||
{selectedAction.source.pluginId && (
|
||||
<Badge variant="secondary" className="h-4 text-[10px]">
|
||||
{selectedAction.source.pluginId}
|
||||
{selectedAction.source.pluginVersion
|
||||
? `@${selectedAction.source.pluginVersion}`
|
||||
: ""}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
{selectedAction.execution.transport}
|
||||
</Badge>
|
||||
{selectedAction.execution.retryable && (
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
retryable
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{def?.description && (
|
||||
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
|
||||
{def.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* General Action Fields */}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">Display Name</Label>
|
||||
<Input
|
||||
value={selectedAction.name}
|
||||
onChange={(e) =>
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
{def?.parameters.length ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
Parameters
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{def.parameters.map((param) => {
|
||||
const rawValue = selectedAction.parameters[param.id];
|
||||
const commonLabel = (
|
||||
<Label className="flex items-center gap-2 text-xs">
|
||||
{param.name}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{param.type === "number" &&
|
||||
(param.min !== undefined || param.max !== undefined) &&
|
||||
typeof rawValue === "number" &&
|
||||
`( ${rawValue} )`}
|
||||
</span>
|
||||
</Label>
|
||||
);
|
||||
|
||||
/* ---- Handlers ---- */
|
||||
const updateParamValue = (value: unknown) => {
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
[param.id]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Control Rendering ---- */
|
||||
let control: React.ReactNode = null;
|
||||
|
||||
if (param.type === "text") {
|
||||
control = (
|
||||
<Input
|
||||
value={(rawValue as string) ?? ""}
|
||||
placeholder={param.placeholder}
|
||||
onChange={(e) => updateParamValue(e.target.value)}
|
||||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (param.type === "select") {
|
||||
control = (
|
||||
<Select
|
||||
value={(rawValue as string) ?? ""}
|
||||
onValueChange={(val) => updateParamValue(val)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||
<SelectValue placeholder="Select…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{param.options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else if (param.type === "boolean") {
|
||||
control = (
|
||||
<div className="mt-1 flex h-7 items-center">
|
||||
<Switch
|
||||
checked={Boolean(rawValue)}
|
||||
onCheckedChange={(val) => updateParamValue(val)}
|
||||
aria-label={param.name}
|
||||
/>
|
||||
<span className="text-muted-foreground ml-2 text-[11px]">
|
||||
{Boolean(rawValue) ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else if (param.type === "number") {
|
||||
const numericVal =
|
||||
typeof rawValue === "number"
|
||||
? rawValue
|
||||
: typeof param.value === "number"
|
||||
? param.value
|
||||
: (param.min ?? 0);
|
||||
|
||||
if (param.min !== undefined || param.max !== undefined) {
|
||||
const min = param.min ?? 0;
|
||||
const max =
|
||||
param.max ??
|
||||
Math.max(
|
||||
min + 1,
|
||||
Number.isFinite(numericVal) ? numericVal : min + 1,
|
||||
);
|
||||
// Step heuristic
|
||||
const range = max - min;
|
||||
const step =
|
||||
param.step ??
|
||||
(range <= 5
|
||||
? 0.1
|
||||
: range <= 50
|
||||
? 0.5
|
||||
: Math.max(1, Math.round(range / 100)));
|
||||
control = (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[Number(numericVal)]}
|
||||
onValueChange={(vals: number[]) =>
|
||||
updateParamValue(vals[0])
|
||||
}
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
||||
{step < 1
|
||||
? Number(numericVal).toFixed(2)
|
||||
: Number(numericVal).toString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
||||
<span>{min}</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
control = (
|
||||
<Input
|
||||
type="number"
|
||||
value={numericVal}
|
||||
onChange={(e) =>
|
||||
updateParamValue(parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={param.id} className="space-y-1">
|
||||
{commonLabel}
|
||||
{param.description && (
|
||||
<div className="text-muted-foreground text-[10px]">
|
||||
{param.description}
|
||||
</div>
|
||||
)}
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
No parameters for this action.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------- Step Properties View --------------------------- */
|
||||
if (selectedStep) {
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div className="border-b pb-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
<div
|
||||
className={cn("h-3 w-3 rounded", {
|
||||
"bg-blue-500": selectedStep.type === "sequential",
|
||||
"bg-emerald-500": selectedStep.type === "parallel",
|
||||
"bg-amber-500": selectedStep.type === "conditional",
|
||||
"bg-purple-500": selectedStep.type === "loop",
|
||||
})}
|
||||
/>
|
||||
Step Settings
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input
|
||||
value={selectedStep.name}
|
||||
onChange={(e) =>
|
||||
onStepUpdate(selectedStep.id, { name: e.target.value })
|
||||
}
|
||||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Input
|
||||
value={selectedStep.description ?? ""}
|
||||
placeholder="Optional step description"
|
||||
onChange={(e) =>
|
||||
onStepUpdate(selectedStep.id, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Select
|
||||
value={selectedStep.type}
|
||||
onValueChange={(val) =>
|
||||
onStepUpdate(selectedStep.id, { type: val as StepType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sequential">Sequential</SelectItem>
|
||||
<SelectItem value="parallel">Parallel</SelectItem>
|
||||
<SelectItem value="conditional">Conditional</SelectItem>
|
||||
<SelectItem value="loop">Loop</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Trigger</Label>
|
||||
<Select
|
||||
value={selectedStep.trigger.type}
|
||||
onValueChange={(val) =>
|
||||
onStepUpdate(selectedStep.id, {
|
||||
trigger: {
|
||||
...selectedStep.trigger,
|
||||
type: val as TriggerType,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRIGGER_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------- Empty State ------------------------------- */
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-24 items-center justify-center text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Settings className="text-muted-foreground/50 mx-auto mb-2 h-6 w-6" />
|
||||
<h3 className="mb-1 text-sm font-medium">Select Step or Action</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Click in the flow to edit properties
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
443
src/components/experiments/designer/StepFlow.tsx
Normal file
443
src/components/experiments/designer/StepFlow.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import {
|
||||
useSortable,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import {
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Icon Map (localized to avoid cross-file re-render dependencies) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Zap,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
GitBranch,
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* DroppableStep */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface DroppableStepProps {
|
||||
stepId: string;
|
||||
children: React.ReactNode;
|
||||
isEmpty?: boolean;
|
||||
}
|
||||
|
||||
function DroppableStep({ stepId, children, isEmpty }: DroppableStepProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `step-${stepId}`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"min-h-[60px] rounded border-2 border-dashed transition-colors",
|
||||
isOver
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
|
||||
: "border-transparent",
|
||||
isEmpty && "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex items-center justify-center p-4 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<Plus className="mx-auto mb-1 h-5 w-5" />
|
||||
<p className="text-xs">Drop actions here</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SortableAction */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface SortableActionProps {
|
||||
action: ExperimentAction;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function SortableAction({
|
||||
action,
|
||||
index,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: SortableActionProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: action.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const IconComponent = iconMap[def?.icon ?? "Zap"] ?? Zap;
|
||||
|
||||
const categoryColors = {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center justify-between rounded border p-2 text-xs transition-colors",
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950/30"
|
||||
: "hover:bg-accent/50",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
{...listeners}
|
||||
className="text-muted-foreground/80 hover:text-foreground cursor-grab rounded p-0.5"
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
{def && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-white",
|
||||
categoryColors[def.category],
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex items-center gap-1 truncate font-medium">
|
||||
{action.source.kind === "plugin" ? (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
|
||||
P
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
|
||||
C
|
||||
</span>
|
||||
)}
|
||||
{action.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="h-4 text-[10px] capitalize">
|
||||
{(action.type ?? "").replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SortableStep */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface SortableStepProps {
|
||||
step: ExperimentStep;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
selectedActionId: string | null;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdate: (updates: Partial<ExperimentStep>) => void;
|
||||
onActionSelect: (actionId: string) => void;
|
||||
onActionDelete: (actionId: string) => void;
|
||||
}
|
||||
|
||||
function SortableStep({
|
||||
step,
|
||||
index,
|
||||
isSelected,
|
||||
selectedActionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
onActionSelect,
|
||||
onActionDelete,
|
||||
}: SortableStepProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: step.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const stepTypeColors: Record<ExperimentStep["type"], string> = {
|
||||
sequential: "border-l-blue-500",
|
||||
parallel: "border-l-emerald-500",
|
||||
conditional: "border-l-amber-500",
|
||||
loop: "border-l-purple-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<Card
|
||||
className={cn(
|
||||
"border-l-4 transition-all",
|
||||
stepTypeColors[step.type],
|
||||
isSelected
|
||||
? "bg-blue-50/50 ring-2 ring-blue-500 dark:bg-blue-950/20 dark:ring-blue-400"
|
||||
: "",
|
||||
isDragging && "rotate-2 opacity-50 shadow-lg",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="cursor-pointer pb-2" onClick={() => onSelect()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdate({ expanded: !step.expanded });
|
||||
}}
|
||||
>
|
||||
{step.expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Badge variant="outline" className="h-5 text-xs">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{step.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{step.actions.length} actions • {step.type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<div {...listeners} className="cursor-grab p-1">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{step.expanded && (
|
||||
<CardContent className="pt-0">
|
||||
<DroppableStep stepId={step.id} isEmpty={step.actions.length === 0}>
|
||||
{step.actions.length > 0 && (
|
||||
<SortableContext
|
||||
items={step.actions.map((a) => a.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{step.actions.map((action, actionIndex) => (
|
||||
<SortableAction
|
||||
key={action.id}
|
||||
action={action}
|
||||
index={actionIndex}
|
||||
isSelected={selectedActionId === action.id}
|
||||
onSelect={() => onActionSelect(action.id)}
|
||||
onDelete={() => onActionDelete(action.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</DroppableStep>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* StepFlow (Scrollable Container of Steps) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface StepFlowProps {
|
||||
steps: ExperimentStep[];
|
||||
selectedStepId: string | null;
|
||||
selectedActionId: string | null;
|
||||
onStepSelect: (id: string) => void;
|
||||
onStepDelete: (id: string) => void;
|
||||
onStepUpdate: (id: string, updates: Partial<ExperimentStep>) => void;
|
||||
onActionSelect: (actionId: string) => void;
|
||||
onActionDelete: (stepId: string, actionId: string) => void;
|
||||
onActionUpdate?: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
updates: Partial<ExperimentAction>,
|
||||
) => void;
|
||||
emptyState?: React.ReactNode;
|
||||
headerRight?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function StepFlow({
|
||||
steps,
|
||||
selectedStepId,
|
||||
selectedActionId,
|
||||
onStepSelect,
|
||||
onStepDelete,
|
||||
onStepUpdate,
|
||||
onActionSelect,
|
||||
onActionDelete,
|
||||
emptyState,
|
||||
headerRight,
|
||||
}: StepFlowProps) {
|
||||
return (
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Experiment Flow
|
||||
</div>
|
||||
{headerRight}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{steps.length === 0 ? (
|
||||
(emptyState ?? (
|
||||
<div className="py-8 text-center">
|
||||
<GitBranch className="text-muted-foreground/50 mx-auto h-8 w-8" />
|
||||
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Add your first step to begin designing
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<SortableContext
|
||||
items={steps.map((s) => s.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id}>
|
||||
<SortableStep
|
||||
step={step}
|
||||
index={index}
|
||||
isSelected={selectedStepId === step.id}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelect={() => onStepSelect(step.id)}
|
||||
onDelete={() => onStepDelete(step.id)}
|
||||
onUpdate={(updates) => onStepUpdate(step.id, updates)}
|
||||
onActionSelect={onActionSelect}
|
||||
onActionDelete={(actionId) =>
|
||||
onActionDelete(step.id, actionId)
|
||||
}
|
||||
/>
|
||||
{index < steps.length - 1 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="bg-border h-2 w-px" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,12 @@ import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -164,7 +164,9 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
const date = row.getValue("createdAt");
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -238,25 +240,27 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
|
||||
// Refetch when active study changes
|
||||
useEffect(() => {
|
||||
if (activeStudy?.id || studyId) {
|
||||
refetch();
|
||||
void refetch();
|
||||
}
|
||||
}, [activeStudy?.id, studyId, refetch]);
|
||||
|
||||
const data: Participant[] = React.useMemo(() => {
|
||||
if (!participantsData?.participants) return [];
|
||||
|
||||
return participantsData.participants.map((p) => ({
|
||||
id: p.id,
|
||||
participantCode: p.participantCode,
|
||||
email: p.email,
|
||||
name: p.name,
|
||||
consentGiven: p.hasConsent,
|
||||
consentDate: p.latestConsent?.signedAt
|
||||
? new Date(p.latestConsent.signedAt as unknown as string)
|
||||
: null,
|
||||
createdAt: p.createdAt,
|
||||
trialCount: p.trialCount,
|
||||
}));
|
||||
return participantsData.participants.map(
|
||||
(p): Participant => ({
|
||||
id: p.id,
|
||||
participantCode: p.participantCode,
|
||||
email: p.email,
|
||||
name: p.name,
|
||||
consentGiven: p.hasConsent,
|
||||
consentDate: p.latestConsent?.signedAt
|
||||
? new Date(p.latestConsent.signedAt as unknown as string)
|
||||
: null,
|
||||
createdAt: p.createdAt,
|
||||
trialCount: p.trialCount,
|
||||
}),
|
||||
);
|
||||
}, [participantsData]);
|
||||
|
||||
if (!studyId && !activeStudy) {
|
||||
|
||||
@@ -212,7 +212,6 @@ export function PluginStoreBrowse() {
|
||||
data: availablePlugins,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.robots.plugins.list.useQuery(
|
||||
{
|
||||
status:
|
||||
@@ -227,10 +226,14 @@ export function PluginStoreBrowse() {
|
||||
},
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const installPluginMutation = api.robots.plugins.install.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Plugin installed successfully!");
|
||||
void refetch();
|
||||
// Invalidate both plugin queries to refresh the UI
|
||||
void utils.robots.plugins.list.invalidate();
|
||||
void utils.robots.plugins.getStudyPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to install plugin");
|
||||
@@ -426,7 +429,10 @@ export function PluginStoreBrowse() {
|
||||
{error.message ||
|
||||
"An error occurred while loading the plugin store."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
<Button
|
||||
onClick={() => void utils.robots.plugins.list.refetch()}
|
||||
variant="outline"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export type Plugin = {
|
||||
plugin: {
|
||||
@@ -85,18 +87,45 @@ const statusConfig = {
|
||||
};
|
||||
|
||||
function PluginActionsCell({ plugin }: { plugin: Plugin }) {
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const uninstallMutation = api.robots.plugins.uninstall.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Plugin uninstalled successfully");
|
||||
// Invalidate plugin queries to refresh the UI
|
||||
void utils.robots.plugins.getStudyPlugins.invalidate();
|
||||
void utils.robots.plugins.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to uninstall plugin");
|
||||
},
|
||||
});
|
||||
|
||||
const isCorePlugin = plugin.plugin.name === "HRIStudio Core System";
|
||||
|
||||
const handleUninstall = async () => {
|
||||
if (isCorePlugin) {
|
||||
toast.error(
|
||||
"Cannot uninstall the core system plugin - it's required for experiment design",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
window.confirm(
|
||||
`Are you sure you want to uninstall "${plugin.plugin.name}"?`,
|
||||
`Are you sure you want to uninstall "${plugin.plugin.name}"? This will remove all plugin blocks from experiments in this study.`,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement uninstall mutation
|
||||
toast.success("Plugin uninstalled successfully");
|
||||
} catch {
|
||||
toast.error("Failed to uninstall plugin");
|
||||
if (!selectedStudyId) {
|
||||
toast.error("No study selected");
|
||||
return;
|
||||
}
|
||||
|
||||
uninstallMutation.mutate({
|
||||
studyId: selectedStudyId,
|
||||
pluginId: plugin.plugin.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,10 +174,17 @@ function PluginActionsCell({ plugin }: { plugin: Plugin }) {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleUninstall}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
disabled={uninstallMutation.isPending || isCorePlugin}
|
||||
className={
|
||||
isCorePlugin ? "text-gray-400" : "text-red-600 focus:text-red-600"
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Uninstall
|
||||
{isCorePlugin
|
||||
? "Core Plugin"
|
||||
: uninstallMutation.isPending
|
||||
? "Uninstalling..."
|
||||
: "Uninstall"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
63
src/components/ui/slider.tsx
Normal file
63
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max],
|
||||
);
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
|
||||
|
||||
/**
|
||||
* Custom hook for centralized study management across the platform.
|
||||
@@ -52,6 +53,15 @@ export function useStudyManagement() {
|
||||
|
||||
return newStudy;
|
||||
} catch (error) {
|
||||
console.error("Failed to create study:", error);
|
||||
// Handle auth errors
|
||||
if (isAuthError(error)) {
|
||||
await handleAuthError(
|
||||
error,
|
||||
"Authentication failed while creating study",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to create study";
|
||||
toast.error(message);
|
||||
@@ -90,6 +100,15 @@ export function useStudyManagement() {
|
||||
|
||||
return updatedStudy;
|
||||
} catch (error) {
|
||||
console.error("Failed to update study:", error);
|
||||
// Handle auth errors
|
||||
if (isAuthError(error)) {
|
||||
await handleAuthError(
|
||||
error,
|
||||
"Authentication failed while updating study",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to update study";
|
||||
toast.error(message);
|
||||
@@ -121,6 +140,15 @@ export function useStudyManagement() {
|
||||
// Navigate to studies list
|
||||
router.push("/studies");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete study:", error);
|
||||
// Handle auth errors
|
||||
if (isAuthError(error)) {
|
||||
await handleAuthError(
|
||||
error,
|
||||
"Authentication failed while deleting study",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to delete study";
|
||||
toast.error(message);
|
||||
@@ -253,6 +281,12 @@ export function useStudyManagement() {
|
||||
enabled: !!selectedStudyId,
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
retry: (failureCount, error) => {
|
||||
console.log("Selected study query error:", error);
|
||||
// Handle auth errors first
|
||||
if (isAuthError(error)) {
|
||||
void handleAuthError(error, "Session expired while loading study");
|
||||
return false;
|
||||
}
|
||||
// Don't retry if study not found (404-like errors)
|
||||
if (
|
||||
error.message?.includes("not found") ||
|
||||
@@ -290,6 +324,15 @@ export function useStudyManagement() {
|
||||
{
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
retry: (failureCount, error) => {
|
||||
console.log("Studies query error:", error);
|
||||
// Handle auth errors
|
||||
if (isAuthError(error)) {
|
||||
void handleAuthError(error, "Session expired while loading studies");
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
181
src/lib/auth-error-handler.ts
Normal file
181
src/lib/auth-error-handler.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
import { toast } from "sonner";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
/**
|
||||
* Auth error codes that should trigger automatic logout
|
||||
*/
|
||||
const AUTH_ERROR_CODES = [
|
||||
"UNAUTHORIZED",
|
||||
"FORBIDDEN",
|
||||
"UNAUTHENTICATED",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Auth error messages that should trigger automatic logout
|
||||
*/
|
||||
const AUTH_ERROR_MESSAGES = [
|
||||
"unauthorized",
|
||||
"unauthenticated",
|
||||
"forbidden",
|
||||
"invalid token",
|
||||
"token expired",
|
||||
"session expired",
|
||||
"authentication failed",
|
||||
"access denied",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Checks if an error is an authentication/authorization error that should trigger logout
|
||||
*/
|
||||
export function isAuthError(error: unknown): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
// Check TRPC errors
|
||||
if (error instanceof TRPCClientError) {
|
||||
// Check error code
|
||||
const trpcErrorData = error.data as
|
||||
| { code?: string; httpStatus?: number }
|
||||
| undefined;
|
||||
const errorCode = trpcErrorData?.code;
|
||||
if (
|
||||
errorCode &&
|
||||
AUTH_ERROR_CODES.includes(errorCode as (typeof AUTH_ERROR_CODES)[number])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check HTTP status codes
|
||||
const httpStatus = trpcErrorData?.httpStatus;
|
||||
if (httpStatus === 401 || httpStatus === 403) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check error message
|
||||
const message = error.message?.toLowerCase() ?? "";
|
||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||
}
|
||||
|
||||
// Check generic errors
|
||||
if (error instanceof Error) {
|
||||
const message = error.message?.toLowerCase() || "";
|
||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||
}
|
||||
|
||||
// Check error objects with message property
|
||||
if (typeof error === "object" && error !== null) {
|
||||
if ("message" in error) {
|
||||
const errorObj = error as { message: unknown };
|
||||
const message = String(errorObj.message).toLowerCase();
|
||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||
}
|
||||
|
||||
// Check for status codes in error objects
|
||||
if ("status" in error) {
|
||||
const statusObj = error as { status: unknown };
|
||||
const status = statusObj.status as number;
|
||||
return status === 401 || status === 403;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles authentication errors by logging out the user
|
||||
*/
|
||||
export async function handleAuthError(
|
||||
error: unknown,
|
||||
customMessage?: string,
|
||||
): Promise<void> {
|
||||
if (!isAuthError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn("Authentication error detected, logging out user:", error);
|
||||
|
||||
// Show user-friendly message
|
||||
const message = customMessage ?? "Session expired. Please log in again.";
|
||||
toast.error(message);
|
||||
|
||||
// Small delay to let the toast show
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await signOut({
|
||||
callbackUrl: "/",
|
||||
redirect: true,
|
||||
});
|
||||
} catch (signOutError) {
|
||||
console.error("Error during sign out:", signOutError);
|
||||
// Force redirect if signOut fails
|
||||
window.location.href = "/";
|
||||
}
|
||||
})();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query error handler that automatically handles auth errors
|
||||
*/
|
||||
export function createAuthErrorHandler(customMessage?: string) {
|
||||
return (error: unknown) => {
|
||||
void handleAuthError(error, customMessage);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* tRPC error handler that automatically handles auth errors
|
||||
*/
|
||||
export function handleTRPCError(error: unknown, customMessage?: string): void {
|
||||
void handleAuthError(error, customMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic error handler for any error type
|
||||
*/
|
||||
export function handleGenericError(
|
||||
error: unknown,
|
||||
customMessage?: string,
|
||||
): void {
|
||||
void handleAuthError(error, customMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook-style error handler for use in React components
|
||||
*/
|
||||
export function useAuthErrorHandler() {
|
||||
return {
|
||||
handleAuthError: (error: unknown, customMessage?: string) => {
|
||||
void handleAuthError(error, customMessage);
|
||||
},
|
||||
isAuthError,
|
||||
createErrorHandler: createAuthErrorHandler,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order function to wrap API calls with automatic auth error handling
|
||||
*/
|
||||
export function withAuthErrorHandling<
|
||||
T extends (...args: unknown[]) => Promise<unknown>,
|
||||
>(fn: T, customMessage?: string): T {
|
||||
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
|
||||
try {
|
||||
return (await fn(...args)) as ReturnType<T>;
|
||||
} catch (error) {
|
||||
await handleAuthError(error, customMessage);
|
||||
throw error; // Re-throw so calling code can handle it too
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to check if current error should show a generic error message
|
||||
* (i.e., it's not an auth error that will auto-logout)
|
||||
*/
|
||||
export function shouldShowGenericError(error: unknown): boolean {
|
||||
return !isAuthError(error);
|
||||
}
|
||||
160
src/lib/experiment-designer/block-converter.ts
Normal file
160
src/lib/experiment-designer/block-converter.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
ExecutionDescriptor,
|
||||
} from "./types";
|
||||
|
||||
// Convert step-based design to database records
|
||||
export function convertStepsToDatabase(
|
||||
steps: ExperimentStep[],
|
||||
): ConvertedStep[] {
|
||||
return steps.map((step, index) => ({
|
||||
name: step.name,
|
||||
description: step.description,
|
||||
type: mapStepTypeToDatabase(step.type),
|
||||
orderIndex: index,
|
||||
durationEstimate: calculateStepDuration(step.actions),
|
||||
required: true,
|
||||
conditions: step.trigger.conditions,
|
||||
actions: step.actions.map((action, actionIndex) =>
|
||||
convertActionToDatabase(action, actionIndex),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
// Map designer step types to database step types
|
||||
function mapStepTypeToDatabase(
|
||||
stepType: ExperimentStep["type"],
|
||||
): "wizard" | "robot" | "parallel" | "conditional" {
|
||||
switch (stepType) {
|
||||
case "sequential":
|
||||
return "wizard"; // Default to wizard for sequential
|
||||
case "parallel":
|
||||
return "parallel";
|
||||
case "conditional":
|
||||
case "loop":
|
||||
return "conditional";
|
||||
default:
|
||||
return "wizard";
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate step duration from actions
|
||||
function calculateStepDuration(actions: ExperimentAction[]): number {
|
||||
let total = 0;
|
||||
|
||||
for (const action of actions) {
|
||||
switch (action.type) {
|
||||
case "wizard_speak":
|
||||
case "robot_speak":
|
||||
// Estimate based on text length if available
|
||||
const text = action.parameters.text as string;
|
||||
if (text) {
|
||||
total += Math.max(2, text.length / 10); // ~10 chars per second
|
||||
} else {
|
||||
total += 3;
|
||||
}
|
||||
break;
|
||||
case "wait":
|
||||
total += (action.parameters.duration as number) || 2;
|
||||
break;
|
||||
case "robot_move":
|
||||
total += 5; // Movement takes longer
|
||||
break;
|
||||
case "wizard_gesture":
|
||||
total += 2;
|
||||
break;
|
||||
case "observe":
|
||||
total += (action.parameters.duration as number) || 5;
|
||||
break;
|
||||
default:
|
||||
total += 2; // Default duration
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(1, Math.round(total));
|
||||
}
|
||||
|
||||
// Estimate action timeout
|
||||
function estimateActionTimeout(action: ExperimentAction): number {
|
||||
switch (action.type) {
|
||||
case "wizard_speak":
|
||||
case "robot_speak":
|
||||
return 30;
|
||||
case "robot_move":
|
||||
return 60;
|
||||
case "wait": {
|
||||
const duration = action.parameters.duration as number | undefined;
|
||||
return (duration ?? 2) + 10; // Add buffer
|
||||
}
|
||||
case "observe":
|
||||
return 120; // Observation can take longer
|
||||
default:
|
||||
return 30;
|
||||
}
|
||||
}
|
||||
|
||||
// Database conversion types (same as before)
|
||||
export interface ConvertedStep {
|
||||
name: string;
|
||||
description?: string;
|
||||
type: "wizard" | "robot" | "parallel" | "conditional";
|
||||
orderIndex: number;
|
||||
durationEstimate?: number;
|
||||
required: boolean;
|
||||
conditions: Record<string, unknown>;
|
||||
actions: ConvertedAction[];
|
||||
}
|
||||
|
||||
export interface ConvertedAction {
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
orderIndex: number;
|
||||
parameters: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
// Provenance & execution metadata (flattened for now)
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
transport?: ExecutionDescriptor["transport"];
|
||||
ros2?: ExecutionDescriptor["ros2"];
|
||||
rest?: ExecutionDescriptor["rest"];
|
||||
retryable?: boolean;
|
||||
parameterSchemaRaw?: unknown;
|
||||
sourceKind?: "core" | "plugin";
|
||||
category?: string;
|
||||
}
|
||||
|
||||
// Deprecated legacy compatibility function removed to eliminate unsafe any usage.
|
||||
// If needed, implement a proper migration path elsewhere.
|
||||
|
||||
/**
|
||||
* Convert a single experiment action into a ConvertedAction (DB shape),
|
||||
* preserving provenance and execution metadata for reproducibility.
|
||||
*/
|
||||
export function convertActionToDatabase(
|
||||
action: ExperimentAction,
|
||||
orderIndex: number,
|
||||
): ConvertedAction {
|
||||
return {
|
||||
name: action.name,
|
||||
description: `${action.type} action`,
|
||||
type: action.type,
|
||||
orderIndex,
|
||||
parameters: action.parameters,
|
||||
timeout: estimateActionTimeout(action),
|
||||
pluginId: action.source.pluginId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
robotId: action.source.robotId,
|
||||
baseActionId: action.source.baseActionId,
|
||||
transport: action.execution.transport,
|
||||
ros2: action.execution.ros2,
|
||||
rest: action.execution.rest,
|
||||
retryable: action.execution.retryable,
|
||||
parameterSchemaRaw: action.parameterSchemaRaw,
|
||||
sourceKind: action.source.kind,
|
||||
category: action.category,
|
||||
};
|
||||
}
|
||||
314
src/lib/experiment-designer/execution-compiler.ts
Normal file
314
src/lib/experiment-designer/execution-compiler.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Execution Compiler Utilities
|
||||
*
|
||||
* Purpose:
|
||||
* - Produce a deterministic execution graph snapshot from the visual design
|
||||
* - Generate an integrity hash capturing provenance & structural identity
|
||||
* - Extract normalized plugin dependency list (pluginId@version)
|
||||
*
|
||||
* These utilities are used on the server prior to saving an experiment so that
|
||||
* trial execution can rely on an immutable compiled artifact. This helps ensure
|
||||
* reproducibility by decoupling future plugin updates from already designed
|
||||
* experiment protocols.
|
||||
*
|
||||
* NOTE:
|
||||
* - This module intentionally performs only pure / synchronous operations.
|
||||
* - Any plugin resolution or database queries should happen in a higher layer
|
||||
* before invoking these functions.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExperimentDesign,
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
ExecutionDescriptor,
|
||||
} from "./types";
|
||||
|
||||
/* ---------- Public Types ---------- */
|
||||
|
||||
export interface CompiledExecutionGraph {
|
||||
version: number;
|
||||
generatedAt: string; // ISO timestamp
|
||||
steps: CompiledExecutionStep[];
|
||||
pluginDependencies: string[];
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface CompiledExecutionStep {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
type: ExperimentStep["type"];
|
||||
trigger: {
|
||||
type: string;
|
||||
conditions: Record<string, unknown>;
|
||||
};
|
||||
actions: CompiledExecutionAction[];
|
||||
estimatedDuration?: number;
|
||||
}
|
||||
|
||||
export interface CompiledExecutionAction {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
category: string;
|
||||
provenance: {
|
||||
sourceKind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
};
|
||||
execution: ExecutionDescriptor;
|
||||
parameters: Record<string, unknown>;
|
||||
parameterSchemaRaw?: unknown;
|
||||
timeout?: number;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
/* ---------- Compile Entry Point ---------- */
|
||||
|
||||
/**
|
||||
* Compile an ExperimentDesign into a reproducible execution graph + hash.
|
||||
*/
|
||||
export function compileExecutionDesign(
|
||||
design: ExperimentDesign,
|
||||
opts: { hashAlgorithm?: "sha256" | "sha1" } = {},
|
||||
): CompiledExecutionGraph {
|
||||
const pluginDependencies = collectPluginDependencies(design);
|
||||
|
||||
const compiledSteps: CompiledExecutionStep[] = design.steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((step) => compileStep(step));
|
||||
|
||||
const structuralSignature = buildStructuralSignature(
|
||||
design,
|
||||
compiledSteps,
|
||||
pluginDependencies,
|
||||
);
|
||||
|
||||
const hash = stableHash(structuralSignature, opts.hashAlgorithm ?? "sha256");
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
steps: compiledSteps,
|
||||
pluginDependencies,
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- Step / Action Compilation ---------- */
|
||||
|
||||
function compileStep(step: ExperimentStep): CompiledExecutionStep {
|
||||
const compiledActions: CompiledExecutionAction[] = step.actions.map(
|
||||
(action, index) => compileAction(action, index),
|
||||
);
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
order: step.order,
|
||||
type: step.type,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
conditions: step.trigger.conditions ?? {},
|
||||
},
|
||||
actions: compiledActions,
|
||||
estimatedDuration: step.estimatedDuration,
|
||||
};
|
||||
}
|
||||
|
||||
function compileAction(
|
||||
action: ExperimentAction,
|
||||
_index: number, // index currently unused (reserved for future ordering diagnostics)
|
||||
): CompiledExecutionAction {
|
||||
return {
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
type: action.type,
|
||||
category: action.category,
|
||||
provenance: {
|
||||
sourceKind: action.source.kind,
|
||||
pluginId: action.source.pluginId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
robotId: action.source.robotId,
|
||||
baseActionId: action.source.baseActionId,
|
||||
},
|
||||
execution: action.execution,
|
||||
parameters: action.parameters,
|
||||
parameterSchemaRaw: action.parameterSchemaRaw,
|
||||
timeout: action.execution.timeoutMs,
|
||||
retryable: action.execution.retryable,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- Plugin Dependency Extraction ---------- */
|
||||
|
||||
export function collectPluginDependencies(design: ExperimentDesign): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const step of design.steps) {
|
||||
for (const action of step.actions) {
|
||||
if (action.source.kind === "plugin" && action.source.pluginId) {
|
||||
const versionPart = action.source.pluginVersion
|
||||
? `@${action.source.pluginVersion}`
|
||||
: "";
|
||||
set.add(`${action.source.pluginId}${versionPart}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
/* ---------- Integrity Hash Generation ---------- */
|
||||
|
||||
/**
|
||||
* Build a minimal, deterministic JSON-serializable representation capturing:
|
||||
* - Step ordering, ids, types, triggers
|
||||
* - Action ordering, ids, types, provenance, execution transport, parameters (keys only for hash)
|
||||
* - Plugin dependency list
|
||||
*
|
||||
* Parameter values are not fully included (only key presence) to avoid hash churn
|
||||
* on mutable text fields while preserving structural identity. If full parameter
|
||||
* value hashing is desired, adjust `summarizeParametersForHash`.
|
||||
*/
|
||||
function buildStructuralSignature(
|
||||
design: ExperimentDesign,
|
||||
steps: CompiledExecutionStep[],
|
||||
pluginDependencies: string[],
|
||||
): unknown {
|
||||
return {
|
||||
experimentId: design.id,
|
||||
version: design.version,
|
||||
steps: steps.map((s) => ({
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
// Include condition keys only for stability
|
||||
conditionKeys: Object.keys(s.trigger.conditions).sort(),
|
||||
},
|
||||
actions: s.actions.map((a) => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
category: a.category,
|
||||
provenance: a.provenance,
|
||||
transport: a.execution.transport,
|
||||
timeout: a.timeout,
|
||||
retryable: a.retryable ?? false,
|
||||
parameterKeys: summarizeParametersForHash(a.parameters),
|
||||
})),
|
||||
})),
|
||||
pluginDependencies,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeParametersForHash(params: Record<string, unknown>): string[] {
|
||||
return Object.keys(params).sort();
|
||||
}
|
||||
|
||||
/* ---------- Stable Hash Implementation ---------- */
|
||||
|
||||
/**
|
||||
* Simple stable hash using built-in Web Crypto if available; falls back
|
||||
* to a lightweight JS implementation (FNV-1a) for environments without
|
||||
* crypto.subtle (e.g. some test runners).
|
||||
*
|
||||
* This is synchronous; if crypto.subtle is present it still uses
|
||||
* a synchronous wrapper by blocking on the Promise with deasync style
|
||||
* simulation (not implemented) so we default to FNV-1a here for portability.
|
||||
*/
|
||||
function stableHash(value: unknown, algorithm: "sha256" | "sha1"): string {
|
||||
// Use a deterministic JSON stringify
|
||||
const json = JSON.stringify(value);
|
||||
// FNV-1a 64-bit (represented as hex)
|
||||
let hashHigh = 0xcbf29ce4;
|
||||
let hashLow = 0x84222325; // Split 64-bit for simple JS accumulation
|
||||
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
const c = json.charCodeAt(i);
|
||||
// XOR low part
|
||||
hashLow ^= c;
|
||||
// 64-bit FNV prime: 1099511628211 -> split multiply
|
||||
// (hash * prime) mod 2^64
|
||||
// Multiply low
|
||||
let low =
|
||||
(hashLow & 0xffff) * 0x1b3 +
|
||||
(((hashLow >>> 16) * 0x1b3) & 0xffff) * 0x10000;
|
||||
// Include high
|
||||
low +=
|
||||
((hashHigh & 0xffff) * 0x1b3 +
|
||||
(((hashHigh >>> 16) * 0x1b3) & 0xffff) * 0x10000) &
|
||||
0xffffffff;
|
||||
// Rotate values (approximate 64-bit handling)
|
||||
hashHigh ^= low >>> 13;
|
||||
hashHigh &= 0xffffffff;
|
||||
hashLow = low & 0xffffffff;
|
||||
}
|
||||
|
||||
// Combine into hex; algorithm param reserved for future (differing strategies)
|
||||
const highHex = (hashHigh >>> 0).toString(16).padStart(8, "0");
|
||||
const lowHex = (hashLow >>> 0).toString(16).padStart(8, "0");
|
||||
return `${algorithm}-${highHex}${lowHex}`;
|
||||
}
|
||||
|
||||
/* ---------- Validation Helpers (Optional Use) ---------- */
|
||||
|
||||
/**
|
||||
* Lightweight structural sanity checks prior to compilation.
|
||||
* Returns array of issues; empty array means pass.
|
||||
*/
|
||||
export function validateDesignStructure(design: ExperimentDesign): string[] {
|
||||
const issues: string[] = [];
|
||||
if (!design.steps.length) {
|
||||
issues.push("No steps defined");
|
||||
}
|
||||
const seenStepIds = new Set<string>();
|
||||
for (const step of design.steps) {
|
||||
if (seenStepIds.has(step.id)) {
|
||||
issues.push(`Duplicate step id: ${step.id}`);
|
||||
} else {
|
||||
seenStepIds.add(step.id);
|
||||
}
|
||||
if (!step.actions.length) {
|
||||
issues.push(`Step "${step.name}" has no actions`);
|
||||
}
|
||||
const seenActionIds = new Set<string>();
|
||||
for (const action of step.actions) {
|
||||
if (seenActionIds.has(action.id)) {
|
||||
issues.push(`Duplicate action id in step "${step.name}": ${action.id}`);
|
||||
} else {
|
||||
seenActionIds.add(action.id);
|
||||
}
|
||||
if (!action.type) {
|
||||
issues.push(`Action "${action.id}" missing type`);
|
||||
}
|
||||
if (!action.source?.kind) {
|
||||
issues.push(`Action "${action.id}" missing provenance`);
|
||||
}
|
||||
if (!action.execution?.transport) {
|
||||
issues.push(`Action "${action.id}" missing execution transport`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level convenience wrapper: validate + compile; throws on issues.
|
||||
*/
|
||||
export function validateAndCompile(
|
||||
design: ExperimentDesign,
|
||||
): CompiledExecutionGraph {
|
||||
const issues = validateDesignStructure(design);
|
||||
if (issues.length) {
|
||||
const error = new Error(
|
||||
`Design validation failed:\n- ${issues.join("\n- ")}`,
|
||||
);
|
||||
(error as { issues?: string[] }).issues = issues;
|
||||
throw error;
|
||||
}
|
||||
return compileExecutionDesign(design);
|
||||
}
|
||||
166
src/lib/experiment-designer/types.ts
Normal file
166
src/lib/experiment-designer/types.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// Core experiment designer types
|
||||
|
||||
export type StepType = "sequential" | "parallel" | "conditional" | "loop";
|
||||
|
||||
export type ActionCategory = "wizard" | "robot" | "observation" | "control";
|
||||
|
||||
export type ActionType =
|
||||
| "wizard_speak"
|
||||
| "wizard_gesture"
|
||||
| "robot_move"
|
||||
| "robot_speak"
|
||||
| "wait"
|
||||
| "observe"
|
||||
| "collect_data"
|
||||
// Namespaced plugin action types will use pattern: pluginId.actionId
|
||||
| (string & {});
|
||||
|
||||
export type TriggerType =
|
||||
| "trial_start"
|
||||
| "participant_action"
|
||||
| "timer"
|
||||
| "previous_step";
|
||||
|
||||
export interface ActionParameter {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "number" | "select" | "boolean";
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
value?: string | number | boolean;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
step?: number; // numeric increment if relevant
|
||||
}
|
||||
|
||||
export interface ActionDefinition {
|
||||
id: string;
|
||||
type: ActionType;
|
||||
name: string;
|
||||
description: string;
|
||||
category: ActionCategory;
|
||||
icon: string;
|
||||
color: string;
|
||||
parameters: ActionParameter[];
|
||||
source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string; // original internal action id inside plugin repo
|
||||
};
|
||||
execution?: ExecutionDescriptor;
|
||||
parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit
|
||||
}
|
||||
|
||||
export interface ExperimentAction {
|
||||
id: string;
|
||||
type: ActionType;
|
||||
name: string;
|
||||
parameters: Record<string, unknown>;
|
||||
duration?: number;
|
||||
category: ActionCategory;
|
||||
source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
};
|
||||
execution: ExecutionDescriptor;
|
||||
parameterSchemaRaw?: unknown;
|
||||
}
|
||||
|
||||
export interface StepTrigger {
|
||||
type: TriggerType;
|
||||
conditions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExperimentStep {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: StepType;
|
||||
order: number;
|
||||
trigger: StepTrigger;
|
||||
actions: ExperimentAction[];
|
||||
estimatedDuration?: number;
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export interface ExperimentDesign {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
steps: ExperimentStep[];
|
||||
version: number;
|
||||
lastSaved: Date;
|
||||
compiledAt?: Date; // when an execution plan was compiled
|
||||
integrityHash?: string; // hash of action definitions for reproducibility
|
||||
}
|
||||
|
||||
// Trigger options for UI
|
||||
export const TRIGGER_OPTIONS = [
|
||||
{ value: "trial_start" as const, label: "Trial starts" },
|
||||
{ value: "participant_action" as const, label: "Participant acts" },
|
||||
{ value: "timer" as const, label: "After timer" },
|
||||
{ value: "previous_step" as const, label: "Previous step completes" },
|
||||
];
|
||||
|
||||
// Step type options for UI
|
||||
export const STEP_TYPE_OPTIONS = [
|
||||
{
|
||||
value: "sequential" as const,
|
||||
label: "Sequential",
|
||||
description: "Actions run one after another",
|
||||
},
|
||||
{
|
||||
value: "parallel" as const,
|
||||
label: "Parallel",
|
||||
description: "Actions run at the same time",
|
||||
},
|
||||
{
|
||||
value: "conditional" as const,
|
||||
label: "Conditional",
|
||||
description: "Actions run if condition is met",
|
||||
},
|
||||
{
|
||||
value: "loop" as const,
|
||||
label: "Loop",
|
||||
description: "Actions repeat multiple times",
|
||||
},
|
||||
];
|
||||
|
||||
// Execution descriptors (appended)
|
||||
|
||||
export interface ExecutionDescriptor {
|
||||
transport: "ros2" | "rest" | "internal";
|
||||
timeoutMs?: number;
|
||||
retryable?: boolean;
|
||||
ros2?: Ros2Execution;
|
||||
rest?: RestExecution;
|
||||
}
|
||||
|
||||
export interface Ros2Execution {
|
||||
topic?: string;
|
||||
messageType?: string;
|
||||
service?: string;
|
||||
action?: string;
|
||||
qos?: {
|
||||
reliability?: string;
|
||||
durability?: string;
|
||||
history?: string;
|
||||
depth?: number;
|
||||
};
|
||||
payloadMapping?: unknown; // mapping definition retained for transform at runtime
|
||||
}
|
||||
|
||||
export interface RestExecution {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string | number | boolean>;
|
||||
bodyTemplate?: unknown;
|
||||
}
|
||||
326
src/lib/experiment-designer/visual-design-guard.ts
Normal file
326
src/lib/experiment-designer/visual-design-guard.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
StepType,
|
||||
TriggerType,
|
||||
ActionCategory,
|
||||
ExecutionDescriptor,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Visual Design Guard
|
||||
*
|
||||
* Provides a robust Zod-based parsing/normalization pipeline that:
|
||||
* - Accepts a loosely-typed visualDesign.steps payload coming from the client
|
||||
* - Normalizes and validates it into strongly typed arrays for internal processing
|
||||
* - Strips unknown fields (preserves only what we rely on)
|
||||
* - Ensures provenance + execution descriptors are structurally sound
|
||||
*
|
||||
* This replaces ad-hoc runtime filtering in the experiments update mutation.
|
||||
*
|
||||
* Usage:
|
||||
* const { steps, issues } = parseVisualDesignSteps(rawSteps);
|
||||
* if (issues.length) -> reject request
|
||||
* else -> steps (ExperimentStep[]) is now safe for conversion & compilation
|
||||
*/
|
||||
|
||||
// Enumerations (reuse domain model semantics without hard binding to future expansions)
|
||||
const stepTypeEnum = z.enum(["sequential", "parallel", "conditional", "loop"]);
|
||||
const triggerTypeEnum = z.enum([
|
||||
"trial_start",
|
||||
"participant_action",
|
||||
"timer",
|
||||
"previous_step",
|
||||
]);
|
||||
const actionCategoryEnum = z.enum([
|
||||
"wizard",
|
||||
"robot",
|
||||
"observation",
|
||||
"control",
|
||||
]);
|
||||
|
||||
// Provenance
|
||||
const actionSourceSchema = z
|
||||
.object({
|
||||
kind: z.enum(["core", "plugin"]),
|
||||
pluginId: z.string().min(1).optional(),
|
||||
pluginVersion: z.string().min(1).optional(),
|
||||
robotId: z.string().min(1).nullable().optional(),
|
||||
baseActionId: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Execution descriptor
|
||||
const executionDescriptorSchema = z
|
||||
.object({
|
||||
transport: z.enum(["ros2", "rest", "internal"]),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
ros2: z
|
||||
.object({
|
||||
topic: z.string().min(1).optional(),
|
||||
messageType: z.string().min(1).optional(),
|
||||
service: z.string().min(1).optional(),
|
||||
action: z.string().min(1).optional(),
|
||||
qos: z
|
||||
.object({
|
||||
reliability: z.string().optional(),
|
||||
durability: z.string().optional(),
|
||||
history: z.string().optional(),
|
||||
depth: z.number().int().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
payloadMapping: z.unknown().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
rest: z
|
||||
.object({
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
path: z.string().min(1),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Action parameter snapshot is a free-form structure retained for audit
|
||||
const parameterSchemaRawSchema = z.unknown().optional();
|
||||
|
||||
// Action schema (loose input → normalized internal)
|
||||
const visualActionInputSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
category: actionCategoryEnum.optional(),
|
||||
parameters: z.record(z.string(), z.unknown()).default({}),
|
||||
source: actionSourceSchema.optional(),
|
||||
execution: executionDescriptorSchema.optional(),
|
||||
parameterSchemaRaw: parameterSchemaRawSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Trigger schema
|
||||
const triggerSchema = z
|
||||
.object({
|
||||
type: triggerTypeEnum,
|
||||
conditions: z.record(z.string(), z.unknown()).default({}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Step schema
|
||||
const visualStepInputSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
type: stepTypeEnum,
|
||||
order: z.number().int().nonnegative().optional(),
|
||||
trigger: triggerSchema.optional(),
|
||||
actions: z.array(visualActionInputSchema),
|
||||
expanded: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Array schema root
|
||||
const visualDesignStepsSchema = z.array(visualStepInputSchema);
|
||||
|
||||
/**
|
||||
* Parse & normalize raw steps payload.
|
||||
*/
|
||||
export function parseVisualDesignSteps(raw: unknown): {
|
||||
steps: ExperimentStep[];
|
||||
issues: string[];
|
||||
} {
|
||||
const issues: string[] = [];
|
||||
|
||||
const parsed = visualDesignStepsSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
const zodErr = parsed.error;
|
||||
issues.push(
|
||||
...zodErr.issues.map(
|
||||
(issue) =>
|
||||
`steps${
|
||||
issue.path.length ? "." + issue.path.join(".") : ""
|
||||
}: ${issue.message} (code=${issue.code})`,
|
||||
),
|
||||
);
|
||||
return { steps: [], issues };
|
||||
}
|
||||
|
||||
// Normalize to internal ExperimentStep[] shape
|
||||
const inputSteps = parsed.data;
|
||||
|
||||
const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
|
||||
const actions: ExperimentAction[] = s.actions.map((a) => {
|
||||
// Default provenance
|
||||
const source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
} = a.source
|
||||
? {
|
||||
kind: a.source.kind,
|
||||
pluginId: a.source.pluginId,
|
||||
pluginVersion: a.source.pluginVersion,
|
||||
robotId: a.source.robotId ?? null,
|
||||
baseActionId: a.source.baseActionId,
|
||||
}
|
||||
: { kind: "core" };
|
||||
|
||||
// Default execution
|
||||
const execution: ExecutionDescriptor = a.execution
|
||||
? {
|
||||
transport: a.execution.transport,
|
||||
timeoutMs: a.execution.timeoutMs,
|
||||
retryable: a.execution.retryable,
|
||||
ros2: a.execution.ros2,
|
||||
rest: a.execution.rest
|
||||
? {
|
||||
method: a.execution.rest.method,
|
||||
path: a.execution.rest.path,
|
||||
headers: a.execution.rest.headers
|
||||
? Object.fromEntries(
|
||||
Object.entries(a.execution.rest.headers).filter(
|
||||
(kv): kv is [string, string] =>
|
||||
typeof kv[1] === "string",
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: { transport: "internal" };
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
type: a.type, // dynamic (pluginId.actionId)
|
||||
name: a.name,
|
||||
parameters: a.parameters ?? {},
|
||||
duration: undefined,
|
||||
category: (a.category ?? "wizard") as ActionCategory,
|
||||
source: {
|
||||
kind: source.kind,
|
||||
pluginId: source.kind === "plugin" ? source.pluginId : undefined,
|
||||
pluginVersion:
|
||||
source.kind === "plugin" ? source.pluginVersion : undefined,
|
||||
robotId: source.kind === "plugin" ? (source.robotId ?? null) : null,
|
||||
baseActionId:
|
||||
source.kind === "plugin" ? source.baseActionId : undefined,
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: a.parameterSchemaRaw,
|
||||
};
|
||||
});
|
||||
|
||||
// Construct step
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
type: s.type as StepType,
|
||||
order: typeof s.order === "number" ? s.order : idx,
|
||||
trigger: {
|
||||
type: (s.trigger?.type ?? "previous_step") as TriggerType,
|
||||
conditions: s.trigger?.conditions ?? {},
|
||||
},
|
||||
actions,
|
||||
estimatedDuration: undefined,
|
||||
expanded: s.expanded ?? true,
|
||||
};
|
||||
});
|
||||
|
||||
// Basic structural checks
|
||||
const seenStepIds = new Set<string>();
|
||||
for (const st of normalized) {
|
||||
if (seenStepIds.has(st.id)) {
|
||||
issues.push(`Duplicate step id: ${st.id}`);
|
||||
}
|
||||
seenStepIds.add(st.id);
|
||||
if (!st.actions.length) {
|
||||
issues.push(`Step "${st.name}" has no actions`);
|
||||
}
|
||||
const seenActionIds = new Set<string>();
|
||||
for (const act of st.actions) {
|
||||
if (seenActionIds.has(act.id)) {
|
||||
issues.push(`Duplicate action id in step "${st.name}": ${act.id}`);
|
||||
}
|
||||
seenActionIds.add(act.id);
|
||||
if (!act.source.kind) {
|
||||
issues.push(`Action "${act.id}" missing source.kind`);
|
||||
}
|
||||
if (!act.execution.transport) {
|
||||
issues.push(`Action "${act.id}" missing execution transport`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { steps: normalized, issues };
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate aggregate duration (in seconds) from normalized steps.
|
||||
* Uses simple additive heuristic: sum each step's summed action durations
|
||||
* if present; falls back to rough defaults for certain action patterns.
|
||||
*/
|
||||
export function estimateDesignDurationSeconds(steps: ExperimentStep[]): number {
|
||||
let total = 0;
|
||||
for (const step of steps) {
|
||||
let stepSum = 0;
|
||||
for (const action of step.actions) {
|
||||
const t = classifyDuration(action);
|
||||
stepSum += t;
|
||||
}
|
||||
total += stepSum;
|
||||
}
|
||||
return Math.max(1, Math.round(total));
|
||||
}
|
||||
|
||||
function classifyDuration(action: ExperimentAction): number {
|
||||
// Heuristic mapping (could be evolved to plugin-provided estimates)
|
||||
switch (true) {
|
||||
case action.type.startsWith("wizard_speak"):
|
||||
case action.type.startsWith("robot_speak"): {
|
||||
const text = action.parameters.text as string | undefined;
|
||||
if (text && text.length > 0) {
|
||||
return Math.max(2, Math.round(text.length / 10));
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
case action.type.startsWith("wait"): {
|
||||
const d = action.parameters.duration as number | undefined;
|
||||
return d && d > 0 ? d : 2;
|
||||
}
|
||||
case action.type.startsWith("robot_move"):
|
||||
return 5;
|
||||
case action.type.startsWith("wizard_gesture"):
|
||||
return 2;
|
||||
case action.type.startsWith("observe"): {
|
||||
const d = action.parameters.duration as number | undefined;
|
||||
return d && d > 0 ? d : 5;
|
||||
}
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: validates, returns steps or throws with issues attached.
|
||||
*/
|
||||
export function assertVisualDesignSteps(raw: unknown): ExperimentStep[] {
|
||||
const { steps, issues } = parseVisualDesignSteps(raw);
|
||||
if (issues.length) {
|
||||
const err = new Error(
|
||||
`Visual design validation failed:\n- ${issues.join("\n- ")}`,
|
||||
);
|
||||
(err as { issues?: string[] }).issues = issues;
|
||||
throw err;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { adminRouter } from "~/server/api/routers/admin";
|
||||
import { analyticsRouter } from "~/server/api/routers/analytics";
|
||||
import { authRouter } from "~/server/api/routers/auth";
|
||||
import { collaborationRouter } from "~/server/api/routers/collaboration";
|
||||
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
||||
import { experimentsRouter } from "~/server/api/routers/experiments";
|
||||
import { mediaRouter } from "~/server/api/routers/media";
|
||||
import { participantsRouter } from "~/server/api/routers/participants";
|
||||
@@ -28,6 +29,7 @@ export const appRouter = createTRPCRouter({
|
||||
analytics: analyticsRouter,
|
||||
collaboration: collaborationRouter,
|
||||
admin: adminRouter,
|
||||
dashboard: dashboardRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
312
src/server/api/routers/dashboard.ts
Normal file
312
src/server/api/routers/dashboard.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { and, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
activityLogs,
|
||||
experiments,
|
||||
participants,
|
||||
studies,
|
||||
studyMembers,
|
||||
trials,
|
||||
users,
|
||||
userSystemRoles,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
export const dashboardRouter = createTRPCRouter({
|
||||
getRecentActivity: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().min(1).max(20).default(10),
|
||||
studyId: z.string().uuid().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get studies the user has access to
|
||||
const accessibleStudies = await ctx.db
|
||||
.select({ studyId: studyMembers.studyId })
|
||||
.from(studyMembers)
|
||||
.where(eq(studyMembers.userId, userId));
|
||||
|
||||
const studyIds = accessibleStudies.map((s) => s.studyId);
|
||||
|
||||
// If no accessible studies, return empty
|
||||
if (studyIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build where conditions
|
||||
const whereConditions = input.studyId
|
||||
? eq(activityLogs.studyId, input.studyId)
|
||||
: inArray(activityLogs.studyId, studyIds);
|
||||
|
||||
// Get recent activity logs
|
||||
const activities = await ctx.db
|
||||
.select({
|
||||
id: activityLogs.id,
|
||||
action: activityLogs.action,
|
||||
description: activityLogs.description,
|
||||
createdAt: activityLogs.createdAt,
|
||||
user: {
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
},
|
||||
study: {
|
||||
name: studies.name,
|
||||
},
|
||||
})
|
||||
.from(activityLogs)
|
||||
.innerJoin(users, eq(activityLogs.userId, users.id))
|
||||
.innerJoin(studies, eq(activityLogs.studyId, studies.id))
|
||||
.where(whereConditions)
|
||||
.orderBy(desc(activityLogs.createdAt))
|
||||
.limit(input.limit);
|
||||
|
||||
return activities.map((activity) => ({
|
||||
id: activity.id,
|
||||
type: activity.action,
|
||||
title: activity.description,
|
||||
description: `${activity.study.name} - ${activity.user.name}`,
|
||||
time: activity.createdAt,
|
||||
status: "info" as const,
|
||||
}));
|
||||
}),
|
||||
|
||||
getStudyProgress: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().min(1).max(10).default(5),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get studies the user has access to with participant counts
|
||||
const studyProgress = await ctx.db
|
||||
.select({
|
||||
id: studies.id,
|
||||
name: studies.name,
|
||||
status: studies.status,
|
||||
createdAt: studies.createdAt,
|
||||
totalParticipants: count(participants.id),
|
||||
})
|
||||
.from(studies)
|
||||
.innerJoin(studyMembers, eq(studies.id, studyMembers.studyId))
|
||||
.leftJoin(participants, eq(studies.id, participants.studyId))
|
||||
.where(
|
||||
and(eq(studyMembers.userId, userId), eq(studies.status, "active")),
|
||||
)
|
||||
.groupBy(studies.id, studies.name, studies.status, studies.createdAt)
|
||||
.orderBy(desc(studies.createdAt))
|
||||
.limit(input.limit);
|
||||
|
||||
// Get trial completion counts for each study
|
||||
const studyIds = studyProgress.map((s) => s.id);
|
||||
|
||||
const trialCounts =
|
||||
studyIds.length > 0
|
||||
? await ctx.db
|
||||
.select({
|
||||
studyId: experiments.studyId,
|
||||
completedTrials: count(trials.id),
|
||||
})
|
||||
.from(experiments)
|
||||
.innerJoin(trials, eq(experiments.id, trials.experimentId))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "completed"),
|
||||
),
|
||||
)
|
||||
.groupBy(experiments.studyId)
|
||||
: [];
|
||||
|
||||
const trialCountMap = new Map(
|
||||
trialCounts.map((tc) => [tc.studyId, tc.completedTrials]),
|
||||
);
|
||||
|
||||
return studyProgress.map((study) => {
|
||||
const completedTrials = trialCountMap.get(study.id) ?? 0;
|
||||
const totalParticipants = study.totalParticipants;
|
||||
|
||||
// Calculate progress based on completed trials vs participants
|
||||
// If no participants, progress is 0; if trials >= participants, progress is 100%
|
||||
const progress =
|
||||
totalParticipants > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round((completedTrials / totalParticipants) * 100),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
id: study.id,
|
||||
name: study.name,
|
||||
progress,
|
||||
participants: completedTrials, // Using completed trials as active participants
|
||||
totalParticipants,
|
||||
status: study.status,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get studies the user has access to
|
||||
const accessibleStudies = await ctx.db
|
||||
.select({ studyId: studyMembers.studyId })
|
||||
.from(studyMembers)
|
||||
.where(eq(studyMembers.userId, userId));
|
||||
|
||||
const studyIds = accessibleStudies.map((s) => s.studyId);
|
||||
|
||||
if (studyIds.length === 0) {
|
||||
return {
|
||||
totalStudies: 0,
|
||||
totalExperiments: 0,
|
||||
totalParticipants: 0,
|
||||
totalTrials: 0,
|
||||
activeTrials: 0,
|
||||
scheduledTrials: 0,
|
||||
completedToday: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get total counts
|
||||
const [studyCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(studies)
|
||||
.where(inArray(studies.id, studyIds));
|
||||
|
||||
const [experimentCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(experiments)
|
||||
.where(inArray(experiments.studyId, studyIds));
|
||||
|
||||
const [participantCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(participants)
|
||||
.where(inArray(participants.studyId, studyIds));
|
||||
|
||||
const [trialCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(inArray(experiments.studyId, studyIds));
|
||||
|
||||
// Get active trials count
|
||||
const [activeTrialsCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "in_progress"),
|
||||
),
|
||||
);
|
||||
|
||||
// Get scheduled trials count
|
||||
const [scheduledTrialsCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "scheduled"),
|
||||
),
|
||||
);
|
||||
|
||||
// Get today's completed trials
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [completedTodayCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "completed"),
|
||||
gte(trials.completedAt, today),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
totalStudies: studyCount?.count ?? 0,
|
||||
totalExperiments: experimentCount?.count ?? 0,
|
||||
totalParticipants: participantCount?.count ?? 0,
|
||||
totalTrials: trialCount?.count ?? 0,
|
||||
activeTrials: activeTrialsCount?.count ?? 0,
|
||||
scheduledTrials: scheduledTrialsCount?.count ?? 0,
|
||||
completedToday: completedTodayCount?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
debug: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get user info
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
// Get user system roles
|
||||
const systemRoles = await ctx.db.query.userSystemRoles.findMany({
|
||||
where: eq(userSystemRoles.userId, userId),
|
||||
});
|
||||
|
||||
// Get user study memberships
|
||||
const studyMemberships = await ctx.db.query.studyMembers.findMany({
|
||||
where: eq(studyMembers.userId, userId),
|
||||
with: {
|
||||
study: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get all studies (admin view)
|
||||
const allStudies = await ctx.db.query.studies.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
createdBy: true,
|
||||
},
|
||||
where: sql`deleted_at IS NULL`,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
return {
|
||||
user: user
|
||||
? {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
}
|
||||
: null,
|
||||
systemRoles: systemRoles.map((r) => r.role),
|
||||
studyMemberships: studyMemberships.map((m) => ({
|
||||
studyId: m.studyId,
|
||||
role: m.role,
|
||||
study: m.study,
|
||||
})),
|
||||
allStudies,
|
||||
session: {
|
||||
userId: ctx.session.user.id,
|
||||
userEmail: ctx.session.user.email,
|
||||
userRole: ctx.session.user.roles?.[0]?.role ?? null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -468,6 +468,7 @@ export const robotsRouter = createTRPCRouter({
|
||||
repositoryUrl: plugins.repositoryUrl,
|
||||
trustLevel: plugins.trustLevel,
|
||||
status: plugins.status,
|
||||
actionDefinitions: plugins.actionDefinitions,
|
||||
createdAt: plugins.createdAt,
|
||||
updatedAt: plugins.updatedAt,
|
||||
},
|
||||
|
||||
@@ -4,8 +4,15 @@ import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
activityLogs, studies, studyMemberRoleEnum, studyMembers,
|
||||
studyStatusEnum, users, userSystemRoles
|
||||
activityLogs,
|
||||
plugins,
|
||||
studies,
|
||||
studyMemberRoleEnum,
|
||||
studyMembers,
|
||||
studyPlugins,
|
||||
studyStatusEnum,
|
||||
users,
|
||||
userSystemRoles,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
export const studiesRouter = createTRPCRouter({
|
||||
@@ -274,6 +281,20 @@ export const studiesRouter = createTRPCRouter({
|
||||
role: "owner",
|
||||
});
|
||||
|
||||
// Auto-install core plugin in new study
|
||||
const corePlugin = await ctx.db.query.plugins.findFirst({
|
||||
where: eq(plugins.name, "HRIStudio Core System"),
|
||||
});
|
||||
|
||||
if (corePlugin) {
|
||||
await ctx.db.insert(studyPlugins).values({
|
||||
studyId: newStudy.id,
|
||||
pluginId: corePlugin.id,
|
||||
configuration: {},
|
||||
installedBy: userId,
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId: newStudy.id,
|
||||
@@ -534,7 +555,7 @@ export const studiesRouter = createTRPCRouter({
|
||||
studyId,
|
||||
userId,
|
||||
action: "member_removed",
|
||||
description: `Removed ${memberToRemove.user?.name ?? memberToRemove.user?.email ?? 'Unknown user'}`,
|
||||
description: `Removed ${memberToRemove.user?.name ?? memberToRemove.user?.email ?? "Unknown user"}`,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -393,6 +393,7 @@ export const experiments = createTable(
|
||||
visualDesign: jsonb("visual_design"),
|
||||
executionGraph: jsonb("execution_graph"),
|
||||
pluginDependencies: text("plugin_dependencies").array(),
|
||||
integrityHash: varchar("integrity_hash", { length: 128 }),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
@@ -496,12 +497,24 @@ export const actions = createTable(
|
||||
.references(() => steps.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
type: varchar("type", { length: 100 }).notNull(), // e.g., 'speak', 'move', 'wait', 'collect_data'
|
||||
type: varchar("type", { length: 100 }).notNull(), // e.g., 'speak', 'move', 'wait', 'collect_data' or pluginId.actionId
|
||||
orderIndex: integer("order_index").notNull(),
|
||||
parameters: jsonb("parameters").default({}),
|
||||
validationSchema: jsonb("validation_schema"),
|
||||
timeout: integer("timeout"), // in seconds
|
||||
retryCount: integer("retry_count").default(0).notNull(),
|
||||
// Provenance & execution metadata
|
||||
sourceKind: varchar("source_kind", { length: 20 }), // 'core' | 'plugin'
|
||||
pluginId: varchar("plugin_id", { length: 255 }),
|
||||
pluginVersion: varchar("plugin_version", { length: 50 }),
|
||||
robotId: varchar("robot_id", { length: 255 }),
|
||||
baseActionId: varchar("base_action_id", { length: 255 }),
|
||||
category: varchar("category", { length: 50 }),
|
||||
transport: varchar("transport", { length: 20 }), // 'ros2' | 'rest' | 'internal'
|
||||
ros2: jsonb("ros2_config"),
|
||||
rest: jsonb("rest_config"),
|
||||
retryable: boolean("retryable"),
|
||||
parameterSchemaRaw: jsonb("parameter_schema_raw"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
|
||||
Reference in New Issue
Block a user