mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
Consolidate all study-dependent routes and UI
- Remove global experiments and plugins routes; redirect to study-scoped pages - Update sidebar navigation to separate platform-level and study-level items - Add study filter to dashboard and stats queries - Refactor participants, trials, analytics pages to use new header and breadcrumbs - Update documentation for new route architecture and migration guide - Remove duplicate experiment creation route - Upgrade Next.js to 15.5.4 in package.json and bun.lock
This commit is contained in:
@@ -4,6 +4,7 @@ import React, { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BarChart3,
|
||||
Building,
|
||||
@@ -14,7 +15,10 @@ import {
|
||||
MoreHorizontal,
|
||||
Puzzle,
|
||||
Settings,
|
||||
TestTube,
|
||||
User,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useSidebar } from "~/components/ui/sidebar";
|
||||
@@ -53,8 +57,8 @@ import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
// Navigation items
|
||||
const navigationItems = [
|
||||
// Global items - always available
|
||||
const globalItems = [
|
||||
{
|
||||
title: "Overview",
|
||||
url: "/dashboard",
|
||||
@@ -65,22 +69,40 @@ const navigationItems = [
|
||||
url: "/studies",
|
||||
icon: Building,
|
||||
},
|
||||
{
|
||||
title: "Profile",
|
||||
url: "/profile",
|
||||
icon: User,
|
||||
},
|
||||
];
|
||||
|
||||
// Current Study Work section - only shown when study is selected
|
||||
const studyWorkItems = [
|
||||
{
|
||||
title: "Participants",
|
||||
url: "/participants",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: "Trials",
|
||||
url: "/trials",
|
||||
icon: TestTube,
|
||||
},
|
||||
{
|
||||
title: "Experiments",
|
||||
url: "/experiments",
|
||||
icon: FlaskConical,
|
||||
},
|
||||
|
||||
{
|
||||
title: "Plugins",
|
||||
url: "/plugins",
|
||||
icon: Puzzle,
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
url: "/analytics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
title: "Plugins",
|
||||
url: "/plugins",
|
||||
icon: Puzzle,
|
||||
},
|
||||
];
|
||||
|
||||
const adminItems = [
|
||||
@@ -118,15 +140,13 @@ export function AppSidebar({
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Filter navigation items based on study selection
|
||||
const availableNavigationItems = navigationItems.filter((item) => {
|
||||
// These items are always available
|
||||
if (item.url === "/dashboard" || item.url === "/studies") {
|
||||
return true;
|
||||
}
|
||||
// These items require a selected study
|
||||
return selectedStudyId !== null;
|
||||
});
|
||||
// Build study work items with proper URLs when study is selected
|
||||
const studyWorkItemsWithUrls = selectedStudyId
|
||||
? studyWorkItems.map((item) => ({
|
||||
...item,
|
||||
url: `/studies/${selectedStudyId}${item.url}`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut({ callbackUrl: "/" });
|
||||
@@ -147,6 +167,25 @@ export function AppSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearStudy = async (event: React.MouseEvent) => {
|
||||
try {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
console.log("Clearing study selection...");
|
||||
await selectStudy(null);
|
||||
console.log("Study selection cleared successfully");
|
||||
toast.success("Study selection cleared");
|
||||
} catch (error) {
|
||||
console.error("Failed to clear study:", error);
|
||||
// Handle auth errors first
|
||||
if (isAuthError(error)) {
|
||||
await handleAuthError(error, "Session expired while clearing study");
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to clear study selection");
|
||||
}
|
||||
};
|
||||
|
||||
const selectedStudy = userStudies.find(
|
||||
(study: Study) => study.id === selectedStudyId,
|
||||
);
|
||||
@@ -248,11 +287,7 @@ export function AppSidebar({
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{selectedStudyId && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await selectStudy(null);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onClick={handleClearStudy}>
|
||||
<Building className="mr-2 h-4 w-4 opacity-50" />
|
||||
Clear selection
|
||||
</DropdownMenuItem>
|
||||
@@ -301,11 +336,7 @@ export function AppSidebar({
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{selectedStudyId && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await selectStudy(null);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onClick={handleClearStudy}>
|
||||
<Building className="mr-2 h-4 w-4 opacity-50" />
|
||||
Clear selection
|
||||
</DropdownMenuItem>
|
||||
@@ -325,11 +356,12 @@ export function AppSidebar({
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Main Navigation */}
|
||||
{/* Global Section */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Research</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{availableNavigationItems.map((item) => {
|
||||
{globalItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.url ||
|
||||
(item.url !== "/dashboard" && pathname.startsWith(item.url));
|
||||
@@ -364,16 +396,61 @@ export function AppSidebar({
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Study-specific items hint */}
|
||||
{!selectedStudyId && !isCollapsed && (
|
||||
{/* Current Study Work Section */}
|
||||
{selectedStudyId && selectedStudy ? (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Current Study Work</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">
|
||||
Select a study to access experiments, participants, trials, and
|
||||
analytics.
|
||||
</div>
|
||||
<SidebarMenu>
|
||||
{studyWorkItemsWithUrls.map((item) => {
|
||||
const isActive =
|
||||
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}>
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{menuButton}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-sm">
|
||||
{item.title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
menuButton
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : (
|
||||
!isCollapsed && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Current Study Work</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">
|
||||
Select a study to access participants, trials, experiments,
|
||||
and analytics.
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Admin Section */}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { FlaskConical, Plus } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import { ActionButton, PageHeader } from "~/components/ui/page-header";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -50,21 +47,6 @@ export function ExperimentsDataTable() {
|
||||
return () => clearInterval(interval);
|
||||
}, [refetch, selectedStudyId]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(selectedStudyId
|
||||
? [
|
||||
{
|
||||
label: "Experiments",
|
||||
href: `/studies/${selectedStudyId}`,
|
||||
},
|
||||
{ label: "Experiments" },
|
||||
]
|
||||
: [{ label: "Experiments" }]),
|
||||
]);
|
||||
|
||||
// Transform experiments data (already filtered by studyId) to match columns
|
||||
const experiments: Experiment[] = React.useMemo(() => {
|
||||
if (!experimentsData) return [];
|
||||
@@ -149,61 +131,34 @@ export function ExperimentsDataTable() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Experiments"
|
||||
description="Design and manage experimental protocols for your HRI studies"
|
||||
icon={FlaskConical}
|
||||
actions={
|
||||
<ActionButton href="/experiments/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Experiment
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<div className="text-red-800">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Failed to Load Experiments
|
||||
</h3>
|
||||
<p className="mb-4">
|
||||
{error.message ||
|
||||
"An error occurred while loading your experiments."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<div className="text-red-800">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Failed to Load Experiments
|
||||
</h3>
|
||||
<p className="mb-4">
|
||||
{error.message ||
|
||||
"An error occurred while loading your experiments."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Experiments"
|
||||
description="Design and manage experimental protocols for your HRI studies"
|
||||
icon={FlaskConical}
|
||||
actions={
|
||||
<ActionButton href="/experiments/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Experiment
|
||||
</ActionButton>
|
||||
}
|
||||
<div className="space-y-4">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredExperiments}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search experiments..."
|
||||
isLoading={isLoading}
|
||||
loadingRowCount={5}
|
||||
filters={filters}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredExperiments}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search experiments..."
|
||||
isLoading={isLoading}
|
||||
loadingRowCount={5}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Puzzle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
@@ -8,8 +7,6 @@ import { Button } from "~/components/ui/button";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import { EmptyState } from "~/components/ui/entity-view";
|
||||
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { ActionButton, PageHeader } from "~/components/ui/page-header";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -51,22 +48,6 @@ export function PluginsDataTable() {
|
||||
}, [refetch]);
|
||||
|
||||
// Get study data for breadcrumbs
|
||||
const { data: studyData } = api.studies.get.useQuery(
|
||||
{ id: selectedStudyId! },
|
||||
{ enabled: !!selectedStudyId },
|
||||
);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(selectedStudyId && studyData
|
||||
? [
|
||||
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
|
||||
{ label: "Plugins" },
|
||||
]
|
||||
: [{ label: "Plugins" }]),
|
||||
]);
|
||||
|
||||
// Transform plugins data to match the Plugin type expected by columns
|
||||
const plugins: Plugin[] = React.useMemo(() => {
|
||||
@@ -135,53 +116,31 @@ export function PluginsDataTable() {
|
||||
// Show message if no study is selected
|
||||
if (!selectedStudyId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Plugins"
|
||||
description="Manage robot plugins for your study"
|
||||
icon={Puzzle}
|
||||
/>
|
||||
<EmptyState
|
||||
icon="Building"
|
||||
title="No Study Selected"
|
||||
description="Please select a study from the sidebar to view and manage plugins."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/studies">Select Study</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="Building"
|
||||
title="No Study Selected"
|
||||
description="Please select a study from the sidebar to view and manage plugins."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/studies">Select Study</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Plugins"
|
||||
description="Manage robot plugins for your study"
|
||||
icon={Puzzle}
|
||||
actions={
|
||||
<ActionButton href="/plugins/browse">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Browse Plugins
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<div className="text-red-800">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Failed to Load Plugins
|
||||
</h3>
|
||||
<p className="mb-4">
|
||||
{error.message || "An error occurred while loading plugins."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<div className="text-red-800">
|
||||
<h3 className="mb-2 text-lg font-semibold">Failed to Load Plugins</h3>
|
||||
<p className="mb-4">
|
||||
{error.message || "An error occurred while loading plugins."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -190,58 +149,30 @@ export function PluginsDataTable() {
|
||||
// Show empty state if no plugins
|
||||
if (!isLoading && plugins.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Plugins"
|
||||
description="Manage robot plugins for your study"
|
||||
icon={Puzzle}
|
||||
actions={
|
||||
<ActionButton href="/plugins/browse">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Browse Plugins
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<EmptyState
|
||||
icon="Puzzle"
|
||||
title="No Plugins Installed"
|
||||
description="Install plugins to extend robot capabilities for your experiments."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/plugins/browse">Browse Plugin Store</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="Puzzle"
|
||||
title="No plugins installed"
|
||||
description="Browse and install plugins to extend your robot's capabilities for this study."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/plugins/browse">Browse Plugins</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Plugins"
|
||||
description="Manage robot plugins for your study"
|
||||
icon={Puzzle}
|
||||
actions={
|
||||
<ActionButton href="/plugins/browse">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Browse Plugins
|
||||
</ActionButton>
|
||||
}
|
||||
<div className="space-y-4">
|
||||
<DataTable
|
||||
columns={pluginsColumns}
|
||||
data={filteredPlugins}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search plugins..."
|
||||
isLoading={isLoading}
|
||||
loadingRowCount={5}
|
||||
filters={filters}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
columns={pluginsColumns}
|
||||
data={filteredPlugins}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search plugins..."
|
||||
isLoading={isLoading}
|
||||
loadingRowCount={5}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,7 @@ import { type LucideIcon } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "~/components/ui/breadcrumb";
|
||||
import type { BreadcrumbItem } from "~/components/ui/breadcrumb";
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
@@ -83,7 +76,7 @@ export function PageLayout({
|
||||
description,
|
||||
userName: _userName,
|
||||
userRole: _userRole,
|
||||
breadcrumb,
|
||||
breadcrumb: _breadcrumb,
|
||||
createButton,
|
||||
quickActions,
|
||||
stats,
|
||||
@@ -92,28 +85,6 @@ export function PageLayout({
|
||||
}: PageLayoutProps) {
|
||||
return (
|
||||
<div className={cn("space-y-6", className)}>
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumb && breadcrumb.length > 0 && (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{breadcrumb.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
{index > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem>
|
||||
{item.href ? (
|
||||
<BreadcrumbLink href={item.href}>
|
||||
{item.label}
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</div>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
{title && (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -260,26 +231,37 @@ export const DetailPageLayout = PageLayout;
|
||||
export const FormPageLayout = PageLayout;
|
||||
|
||||
// Simple components for basic usage
|
||||
interface SimplePageHeaderProps {
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
icon: Icon,
|
||||
actions,
|
||||
className,
|
||||
}: SimplePageHeaderProps) {
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between", className)}>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
|
||||
{description && <p className="text-muted-foreground">{description}</p>}
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && (
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Icon className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children && <div>{children}</div>}
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user