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:
2025-09-24 13:41:29 -04:00
parent e0679f726e
commit cd7c657d5f
18 changed files with 961 additions and 775 deletions

View File

@@ -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 */}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}