mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
Begin plugins system
This commit is contained in:
@@ -4,21 +4,21 @@ import { useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import type { SystemRole } from "~/lib/auth-client";
|
||||
import { formatRole, getAvailableRoles } from "~/lib/auth-client";
|
||||
@@ -35,7 +35,7 @@ interface UserWithRoles {
|
||||
|
||||
export function AdminUserTable() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRole | "">("");
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRole | "all">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedUser, setSelectedUser] = useState<UserWithRoles | null>(null);
|
||||
const [roleToAssign, setRoleToAssign] = useState<SystemRole | "">("");
|
||||
@@ -48,7 +48,7 @@ export function AdminUserTable() {
|
||||
page,
|
||||
limit: 10,
|
||||
search: search || undefined,
|
||||
role: selectedRole || undefined,
|
||||
role: selectedRole === "all" ? undefined : selectedRole,
|
||||
});
|
||||
|
||||
const assignRole = api.users.assignRole.useMutation({
|
||||
@@ -108,13 +108,15 @@ export function AdminUserTable() {
|
||||
<Label htmlFor="role-filter">Filter by Role</Label>
|
||||
<Select
|
||||
value={selectedRole}
|
||||
onValueChange={(value) => setSelectedRole(value as SystemRole | "")}
|
||||
onValueChange={(value) =>
|
||||
setSelectedRole(value as SystemRole | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All roles" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All roles</SelectItem>
|
||||
<SelectItem value="all">All roles</SelectItem>
|
||||
{availableRoles.map((role) => (
|
||||
<SelectItem key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
|
||||
433
src/components/admin/repositories-columns.tsx
Normal file
433
src/components/admin/repositories-columns.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
MoreHorizontal,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Trash2,
|
||||
Shield,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
// Define error type for mutations
|
||||
interface TRPCError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type Repository = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
description: string | null;
|
||||
trustLevel: "official" | "verified" | "community";
|
||||
isEnabled: boolean;
|
||||
isOfficial: boolean;
|
||||
lastSyncAt: Date | null;
|
||||
syncStatus: string | null;
|
||||
syncError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
const trustLevelConfig = {
|
||||
official: {
|
||||
label: "Official",
|
||||
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
icon: Shield,
|
||||
description: "Official HRIStudio repository",
|
||||
},
|
||||
verified: {
|
||||
label: "Verified",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
icon: Shield,
|
||||
description: "Verified by the community",
|
||||
},
|
||||
community: {
|
||||
label: "Community",
|
||||
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||
icon: Shield,
|
||||
description: "Community repository",
|
||||
},
|
||||
};
|
||||
|
||||
const syncStatusConfig = {
|
||||
pending: {
|
||||
label: "Pending",
|
||||
className: "bg-gray-100 text-gray-800",
|
||||
icon: Clock,
|
||||
description: "Waiting to sync",
|
||||
},
|
||||
syncing: {
|
||||
label: "Syncing",
|
||||
className: "bg-blue-100 text-blue-800",
|
||||
icon: RefreshCw,
|
||||
description: "Currently syncing",
|
||||
},
|
||||
completed: {
|
||||
label: "Success",
|
||||
className: "bg-green-100 text-green-800",
|
||||
icon: CheckCircle,
|
||||
description: "Last sync completed successfully",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
className: "bg-red-100 text-red-800",
|
||||
icon: AlertTriangle,
|
||||
description: "Last sync failed",
|
||||
},
|
||||
};
|
||||
|
||||
function RepositoryActionsCell({ repository }: { repository: Repository }) {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const syncMutation = api.admin.repositories.sync.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Repository sync started");
|
||||
void utils.admin.repositories.list.invalidate();
|
||||
},
|
||||
onError: (error: TRPCError) => {
|
||||
toast.error(error.message ?? "Failed to sync repository");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = api.admin.repositories.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Repository deleted successfully");
|
||||
void utils.admin.repositories.list.invalidate();
|
||||
},
|
||||
onError: (error: TRPCError) => {
|
||||
toast.error(error.message ?? "Failed to delete repository");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSync = async () => {
|
||||
syncMutation.mutate({ id: repository.id });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (
|
||||
window.confirm(`Are you sure you want to delete "${repository.name}"?`)
|
||||
) {
|
||||
deleteMutation.mutate({ id: repository.id });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = () => {
|
||||
void navigator.clipboard.writeText(repository.id);
|
||||
toast.success("Repository ID copied to clipboard");
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
void navigator.clipboard.writeText(repository.url);
|
||||
toast.success("Repository URL copied to clipboard");
|
||||
};
|
||||
|
||||
const canDelete = !repository.isOfficial;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={handleSync}
|
||||
disabled={syncMutation.isPending}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Sync Repository
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/repositories/${repository.id}/edit`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Edit Repository
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={repository.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View Repository
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Repository ID
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyUrl}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Repository URL
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Repository
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const repositoriesColumns: ColumnDef<Repository>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Repository Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const repository = row.original;
|
||||
return (
|
||||
<div className="max-w-[200px] min-w-0 space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
||||
<Link
|
||||
href={`/admin/repositories/${repository.id}`}
|
||||
className="truncate font-medium hover:underline"
|
||||
title={repository.name}
|
||||
>
|
||||
{repository.name}
|
||||
</Link>
|
||||
{repository.isOfficial && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{repository.description && (
|
||||
<p
|
||||
className="text-muted-foreground line-clamp-1 truncate text-sm"
|
||||
title={repository.description}
|
||||
>
|
||||
{repository.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "url",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Repository URL" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const url = row.original.url;
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="max-w-[300px] truncate text-sm text-blue-600 hover:underline"
|
||||
title={url}
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "trustLevel",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Trust Level" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const trustLevel = row.original.trustLevel;
|
||||
const config = trustLevelConfig[trustLevel];
|
||||
const TrustIcon = config.icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={config.className}
|
||||
title={config.description}
|
||||
>
|
||||
<TrustIcon className="mr-1 h-3 w-3" />
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
return value.includes(row.original.trustLevel);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "isEnabled",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isEnabled = row.original.isEnabled;
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
isEnabled
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{isEnabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
const isEnabled = row.original.isEnabled;
|
||||
return value.includes(isEnabled ? "enabled" : "disabled");
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "syncStatus",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Sync Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const syncStatus = row.original.syncStatus;
|
||||
const lastSyncAt = row.original.lastSyncAt;
|
||||
const syncError = row.original.syncError;
|
||||
|
||||
if (!syncStatus) return "-";
|
||||
|
||||
const config =
|
||||
syncStatusConfig[syncStatus as keyof typeof syncStatusConfig];
|
||||
if (!config) return syncStatus;
|
||||
|
||||
const SyncIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={config.className}
|
||||
title={config.description}
|
||||
>
|
||||
<SyncIcon
|
||||
className={`mr-1 h-3 w-3 ${syncStatus === "syncing" ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{config.label}
|
||||
</Badge>
|
||||
{lastSyncAt && syncStatus === "completed" && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{formatDistanceToNow(lastSyncAt, { addSuffix: true })}
|
||||
</div>
|
||||
)}
|
||||
{syncError && syncStatus === "failed" && (
|
||||
<div
|
||||
className="max-w-[150px] truncate text-xs text-red-600"
|
||||
title={syncError}
|
||||
>
|
||||
{syncError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.createdAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Updated" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.updatedAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => <RepositoryActionsCell repository={row.original} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
220
src/components/admin/repositories-data-table.tsx
Normal file
220
src/components/admin/repositories-data-table.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Database } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
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,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
repositoriesColumns,
|
||||
type Repository,
|
||||
} from "~/components/admin/repositories-columns";
|
||||
|
||||
export function RepositoriesDataTable() {
|
||||
const [trustLevelFilter, setTrustLevelFilter] = React.useState("all");
|
||||
const [enabledFilter, setEnabledFilter] = React.useState("all");
|
||||
|
||||
const {
|
||||
data: repositoriesData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.admin.repositories.list.useQuery(
|
||||
{
|
||||
trustLevel:
|
||||
trustLevelFilter === "all"
|
||||
? undefined
|
||||
: (trustLevelFilter as "official" | "verified" | "community"),
|
||||
isEnabled:
|
||||
enabledFilter === "all" ? undefined : enabledFilter === "enabled",
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-refresh repositories when component mounts to catch external changes
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void refetch();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refetch]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Administration", href: "/admin" },
|
||||
{ label: "Plugin Repositories" },
|
||||
]);
|
||||
|
||||
// Transform repositories data to match the Repository type expected by columns
|
||||
const repositories: Repository[] = React.useMemo(() => {
|
||||
if (!repositoriesData) return [];
|
||||
return repositoriesData as Repository[];
|
||||
}, [repositoriesData]);
|
||||
|
||||
// Trust level filter options
|
||||
const trustLevelOptions = [
|
||||
{ label: "All Trust Levels", value: "all" },
|
||||
{ label: "Official", value: "official" },
|
||||
{ label: "Verified", value: "verified" },
|
||||
{ label: "Community", value: "community" },
|
||||
];
|
||||
|
||||
// Enabled filter options
|
||||
const enabledOptions = [
|
||||
{ label: "All Repositories", value: "all" },
|
||||
{ label: "Enabled", value: "enabled" },
|
||||
{ label: "Disabled", value: "disabled" },
|
||||
];
|
||||
|
||||
// Filter repositories based on selected filters
|
||||
const filteredRepositories = React.useMemo(() => {
|
||||
return repositories.filter((repository) => {
|
||||
const trustLevelMatch =
|
||||
trustLevelFilter === "all" ||
|
||||
repository.trustLevel === trustLevelFilter;
|
||||
const enabledMatch =
|
||||
enabledFilter === "all" ||
|
||||
(enabledFilter === "enabled" && repository.isEnabled) ||
|
||||
(enabledFilter === "disabled" && !repository.isEnabled);
|
||||
return trustLevelMatch && enabledMatch;
|
||||
});
|
||||
}, [repositories, trustLevelFilter, enabledFilter]);
|
||||
|
||||
const filters = (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={trustLevelFilter} onValueChange={setTrustLevelFilter}>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="Trust Level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{trustLevelOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={enabledFilter} onValueChange={setEnabledFilter}>
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Plugin Repositories"
|
||||
description="Manage plugin repositories for the HRIStudio platform"
|
||||
icon={Database}
|
||||
actions={
|
||||
<ActionButton href="/admin/repositories/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Repository
|
||||
</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 Repositories
|
||||
</h3>
|
||||
<p className="mb-4">
|
||||
{(error as unknown as Error)?.message ??
|
||||
"An error occurred while loading repositories."}
|
||||
</p>
|
||||
<Button onClick={() => void refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show empty state if no repositories
|
||||
if (!isLoading && repositories.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Plugin Repositories"
|
||||
description="Manage plugin repositories for the HRIStudio platform"
|
||||
icon={Database}
|
||||
actions={
|
||||
<ActionButton href="/admin/repositories/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Repository
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<EmptyState
|
||||
icon="Database"
|
||||
title="No Plugin Repositories"
|
||||
description="Add plugin repositories to enable users to browse and install plugins."
|
||||
action={
|
||||
<Button asChild>
|
||||
<a href="/admin/repositories/new">Add First Repository</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Plugin Repositories"
|
||||
description="Manage plugin repositories for the HRIStudio platform"
|
||||
icon={Database}
|
||||
actions={
|
||||
<ActionButton href="/admin/repositories/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Repository
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
columns={repositoriesColumns}
|
||||
data={filteredRepositories}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search repositories..."
|
||||
isLoading={isLoading}
|
||||
loadingRowCount={5}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Home,
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
Puzzle,
|
||||
Settings,
|
||||
Users,
|
||||
UserCheck,
|
||||
@@ -71,6 +72,11 @@ const navigationItems = [
|
||||
url: "/trials",
|
||||
icon: TestTube,
|
||||
},
|
||||
{
|
||||
title: "Plugins",
|
||||
url: "/plugins",
|
||||
icon: Puzzle,
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
url: "/analytics",
|
||||
|
||||
@@ -63,7 +63,7 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
||||
resolver: zodResolver(experimentSchema),
|
||||
defaultValues: {
|
||||
status: "draft" as const,
|
||||
studyId: selectedStudyId || "",
|
||||
studyId: selectedStudyId ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,13 +84,36 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
||||
// Set breadcrumbs
|
||||
const breadcrumbs = [
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
...(mode === "edit" && experiment
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(selectedStudyId
|
||||
? [
|
||||
{ label: experiment.name, href: `/experiments/${experiment.id}` },
|
||||
{ label: "Edit" },
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${selectedStudyId}`,
|
||||
},
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
...(mode === "edit" && experiment
|
||||
? [
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/experiments/${experiment.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Experiment" }]),
|
||||
]
|
||||
: [{ label: "New Experiment" }]),
|
||||
: [
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
...(mode === "edit" && experiment
|
||||
? [
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/experiments/${experiment.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Experiment" }]),
|
||||
]),
|
||||
];
|
||||
|
||||
useBreadcrumbsEffect(breadcrumbs);
|
||||
@@ -128,14 +151,14 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
||||
if (mode === "create") {
|
||||
const newExperiment = await createExperimentMutation.mutateAsync({
|
||||
...data,
|
||||
estimatedDuration: data.estimatedDuration || undefined,
|
||||
estimatedDuration: data.estimatedDuration ?? undefined,
|
||||
});
|
||||
router.push(`/experiments/${newExperiment.id}/designer`);
|
||||
} else {
|
||||
const updatedExperiment = await updateExperimentMutation.mutateAsync({
|
||||
id: experimentId!,
|
||||
...data,
|
||||
estimatedDuration: data.estimatedDuration || undefined,
|
||||
estimatedDuration: data.estimatedDuration ?? undefined,
|
||||
});
|
||||
router.push(`/experiments/${updatedExperiment.id}`);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -183,14 +183,16 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onCheckedChange={(value: boolean) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
@@ -231,12 +233,13 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
|
||||
<DataTableColumnHeader column={column} title="Study" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const study = row.getValue("study") as Experiment["study"];
|
||||
const study = row.original.study;
|
||||
if (!study?.id || !study?.name)
|
||||
return <span className="text-muted-foreground">No study</span>;
|
||||
return (
|
||||
<Link
|
||||
href={`/studies/${study.id}`}
|
||||
className="block max-w-[140px] truncate text-sm hover:underline"
|
||||
title={study.name}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{study.name}
|
||||
</Link>
|
||||
@@ -250,8 +253,8 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as keyof typeof statusConfig;
|
||||
const config = statusConfig[status];
|
||||
const status = row.getValue("status");
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
|
||||
return (
|
||||
<Badge
|
||||
@@ -264,7 +267,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
return value.includes(row.getValue(id) as string);
|
||||
return value.includes(row.getValue(id));
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -296,20 +299,23 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
|
||||
<DataTableColumnHeader column={column} title="Owner" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const owner = row.getValue("owner") as Experiment["owner"];
|
||||
const owner = row.original.owner;
|
||||
if (!owner) {
|
||||
return <span className="text-muted-foreground">No owner</span>;
|
||||
}
|
||||
return (
|
||||
<div className="max-w-[140px] space-y-1">
|
||||
<div
|
||||
className="truncate text-sm font-medium"
|
||||
title={owner?.name ?? "Unknown"}
|
||||
title={owner.name ?? "Unknown"}
|
||||
>
|
||||
{owner?.name ?? "Unknown"}
|
||||
{owner.name ?? "Unknown"}
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={owner?.email}
|
||||
title={owner.email ?? ""}
|
||||
>
|
||||
{owner?.email}
|
||||
{owner.email ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,10 +46,16 @@ export function ExperimentsDataTable() {
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(activeStudy
|
||||
? [{ label: activeStudy.title, href: `/studies/${activeStudy.id}` }]
|
||||
: []),
|
||||
{ label: "Experiments" },
|
||||
? [
|
||||
{
|
||||
label: (activeStudy as { title: string; id: string }).title,
|
||||
href: `/studies/${(activeStudy as { id: string }).id}`,
|
||||
},
|
||||
{ label: "Experiments" },
|
||||
]
|
||||
: [{ label: "Experiments" }]),
|
||||
]);
|
||||
|
||||
// Transform experiments data to match the Experiment type expected by columns
|
||||
@@ -101,7 +107,7 @@ export function ExperimentsDataTable() {
|
||||
const filters = (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -27,6 +27,20 @@ import {
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
type DemographicsData = {
|
||||
age?: number;
|
||||
gender?: string;
|
||||
occupation?: string;
|
||||
education?: string;
|
||||
primaryLanguage?: string;
|
||||
language?: string;
|
||||
location?: string;
|
||||
city?: string;
|
||||
robotExperience?: string;
|
||||
experience?: string;
|
||||
grade?: number;
|
||||
};
|
||||
|
||||
const participantSchema = z.object({
|
||||
participantCode: z
|
||||
.string()
|
||||
@@ -67,7 +81,7 @@ export function ParticipantForm({
|
||||
}: ParticipantFormProps) {
|
||||
const router = useRouter();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
const contextStudyId = studyId || selectedStudyId;
|
||||
const contextStudyId = studyId ?? selectedStudyId;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -76,7 +90,7 @@ export function ParticipantForm({
|
||||
resolver: zodResolver(participantSchema),
|
||||
defaultValues: {
|
||||
consentGiven: false,
|
||||
studyId: contextStudyId || "",
|
||||
studyId: contextStudyId ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,16 +111,39 @@ export function ParticipantForm({
|
||||
// Set breadcrumbs
|
||||
const breadcrumbs = [
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Participants", href: "/participants" },
|
||||
...(mode === "edit" && participant
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(contextStudyId
|
||||
? [
|
||||
{
|
||||
label: participant.name || participant.participantCode,
|
||||
href: `/participants/${participant.id}`,
|
||||
label: participant?.study?.name ?? "Study",
|
||||
href: `/studies/${contextStudyId}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
{
|
||||
label: "Participants",
|
||||
href: `/studies/${contextStudyId}/participants`,
|
||||
},
|
||||
...(mode === "edit" && participant
|
||||
? [
|
||||
{
|
||||
label: participant.name ?? participant.participantCode,
|
||||
href: `/participants/${participant.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Participant" }]),
|
||||
]
|
||||
: [{ label: "New Participant" }]),
|
||||
: [
|
||||
{ label: "Participants", href: "/participants" },
|
||||
...(mode === "edit" && participant
|
||||
? [
|
||||
{
|
||||
label: participant.name ?? participant.participantCode,
|
||||
href: `/participants/${participant.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Participant" }]),
|
||||
]),
|
||||
];
|
||||
|
||||
useBreadcrumbsEffect(breadcrumbs);
|
||||
@@ -116,11 +153,18 @@ export function ParticipantForm({
|
||||
if (mode === "edit" && participant) {
|
||||
form.reset({
|
||||
participantCode: participant.participantCode,
|
||||
name: participant.name || "",
|
||||
email: participant.email || "",
|
||||
name: participant.name ?? "",
|
||||
email: participant.email ?? "",
|
||||
studyId: participant.studyId,
|
||||
age: (participant.demographics as any)?.age || undefined,
|
||||
gender: (participant.demographics as any)?.gender || undefined,
|
||||
age: (participant.demographics as DemographicsData)?.age ?? undefined,
|
||||
gender:
|
||||
((participant.demographics as DemographicsData)?.gender as
|
||||
| "male"
|
||||
| "female"
|
||||
| "non_binary"
|
||||
| "prefer_not_to_say"
|
||||
| "other"
|
||||
| undefined) ?? undefined,
|
||||
consentGiven: true, // Assume consent was given if participant exists
|
||||
});
|
||||
}
|
||||
@@ -144,16 +188,16 @@ export function ParticipantForm({
|
||||
|
||||
try {
|
||||
const demographics = {
|
||||
age: data.age || null,
|
||||
gender: data.gender || null,
|
||||
age: data.age ?? null,
|
||||
gender: data.gender ?? null,
|
||||
};
|
||||
|
||||
if (mode === "create") {
|
||||
const newParticipant = await createParticipantMutation.mutateAsync({
|
||||
studyId: data.studyId,
|
||||
participantCode: data.participantCode,
|
||||
name: data.name || undefined,
|
||||
email: data.email || undefined,
|
||||
name: data.name ?? undefined,
|
||||
email: data.email ?? undefined,
|
||||
demographics,
|
||||
});
|
||||
router.push(`/participants/${newParticipant.id}`);
|
||||
@@ -161,8 +205,8 @@ export function ParticipantForm({
|
||||
const updatedParticipant = await updateParticipantMutation.mutateAsync({
|
||||
id: participantId!,
|
||||
participantCode: data.participantCode,
|
||||
name: data.name || undefined,
|
||||
email: data.email || undefined,
|
||||
name: data.name ?? undefined,
|
||||
email: data.email ?? undefined,
|
||||
demographics,
|
||||
});
|
||||
router.push(`/participants/${updatedParticipant.id}`);
|
||||
@@ -333,7 +377,7 @@ export function ParticipantForm({
|
||||
<FormField>
|
||||
<Label htmlFor="gender">Gender</Label>
|
||||
<Select
|
||||
value={form.watch("gender") || ""}
|
||||
value={form.watch("gender") ?? ""}
|
||||
onValueChange={(value) =>
|
||||
form.setValue(
|
||||
"gender",
|
||||
@@ -444,7 +488,7 @@ export function ParticipantForm({
|
||||
title={
|
||||
mode === "create"
|
||||
? "Register New Participant"
|
||||
: `Edit ${participant?.name || participant?.participantCode || "Participant"}`
|
||||
: `Edit ${participant?.name ?? participant?.participantCode ?? "Participant"}`
|
||||
}
|
||||
description={
|
||||
mode === "create"
|
||||
|
||||
@@ -177,7 +177,7 @@ export const participantsColumns: ColumnDef<Participant>[] = [
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const name = row.getValue("name") as string | null;
|
||||
const name = row.original.name;
|
||||
const email = row.original.email;
|
||||
return (
|
||||
<div className="max-w-[160px] space-y-1">
|
||||
@@ -193,8 +193,8 @@ export const participantsColumns: ColumnDef<Participant>[] = [
|
||||
{email && (
|
||||
<div className="text-muted-foreground flex items-center space-x-1 text-xs">
|
||||
<Mail className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate" title={email}>
|
||||
{email}
|
||||
<span className="truncate" title={email ?? ""}>
|
||||
{email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -237,7 +237,7 @@ export const participantsColumns: ColumnDef<Participant>[] = [
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
const consentGiven = row.getValue(id) as boolean;
|
||||
const consentGiven = row.getValue(id);
|
||||
if (value === "consented") return !!consentGiven;
|
||||
if (value === "pending") return !consentGiven;
|
||||
return true;
|
||||
@@ -249,12 +249,12 @@ export const participantsColumns: ColumnDef<Participant>[] = [
|
||||
<DataTableColumnHeader column={column} title="Trials" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const trialCount = row.getValue("trialCount") as number;
|
||||
const trialCount = row.original.trialCount;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1 text-sm whitespace-nowrap">
|
||||
<TestTube className="text-muted-foreground h-3 w-3" />
|
||||
<span>{trialCount as number}</span>
|
||||
<span>{trialCount ?? 0}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -265,10 +265,10 @@ export const participantsColumns: ColumnDef<Participant>[] = [
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt") as Date;
|
||||
const date = row.original.createdAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
{formatDistanceToNow(date ?? new Date(), { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
import { participantsColumns, type Participant } from "./participants-columns";
|
||||
|
||||
export function ParticipantsDataTable() {
|
||||
const [consentFilter, setConsentFilter] = React.useState("all");
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
const {
|
||||
data: participantsData,
|
||||
@@ -45,10 +47,22 @@ export function ParticipantsDataTable() {
|
||||
return () => clearInterval(interval);
|
||||
}, [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: "Participants" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(selectedStudyId && studyData
|
||||
? [
|
||||
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
|
||||
{ label: "Participants" },
|
||||
]
|
||||
: [{ label: "Participants" }]),
|
||||
]);
|
||||
|
||||
// Transform participants data to match the Participant type expected by columns
|
||||
@@ -60,12 +74,18 @@ export function ParticipantsDataTable() {
|
||||
participantCode: p.participantCode,
|
||||
email: p.email,
|
||||
name: p.name,
|
||||
consentGiven: (p as any).hasConsent || false,
|
||||
consentDate: (p as any).latestConsent?.signedAt
|
||||
? new Date((p as any).latestConsent.signedAt as unknown as string)
|
||||
consentGiven:
|
||||
(p as unknown as { hasConsent?: boolean }).hasConsent ?? false,
|
||||
consentDate: (p as unknown as { latestConsent?: { signedAt: string } })
|
||||
.latestConsent?.signedAt
|
||||
? new Date(
|
||||
(
|
||||
p as unknown as { latestConsent: { signedAt: string } }
|
||||
).latestConsent.signedAt,
|
||||
)
|
||||
: null,
|
||||
createdAt: p.createdAt,
|
||||
trialCount: (p as any).trialCount || 0,
|
||||
trialCount: (p as unknown as { trialCount?: number }).trialCount ?? 0,
|
||||
userRole: undefined,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
@@ -92,7 +112,7 @@ export function ParticipantsDataTable() {
|
||||
const filters = (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={consentFilter} onValueChange={setConsentFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="Consent Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
464
src/components/plugins/plugin-store-browse.tsx
Normal file
464
src/components/plugins/plugin-store-browse.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Puzzle,
|
||||
Search,
|
||||
Filter,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Shield,
|
||||
User,
|
||||
Calendar,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface PluginStoreItem {
|
||||
id: string;
|
||||
robotId: string | null;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string | null;
|
||||
author: string | null;
|
||||
repositoryUrl: string | null;
|
||||
trustLevel: "official" | "verified" | "community" | null;
|
||||
status: "active" | "deprecated" | "disabled";
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const trustLevelConfig = {
|
||||
official: {
|
||||
label: "Official",
|
||||
className: "bg-blue-100 text-blue-800",
|
||||
icon: Shield,
|
||||
description: "Official HRIStudio plugin",
|
||||
},
|
||||
verified: {
|
||||
label: "Verified",
|
||||
className: "bg-green-100 text-green-800",
|
||||
icon: Shield,
|
||||
description: "Verified by the community",
|
||||
},
|
||||
community: {
|
||||
label: "Community",
|
||||
className: "bg-yellow-100 text-yellow-800",
|
||||
icon: User,
|
||||
description: "Community contributed",
|
||||
},
|
||||
};
|
||||
|
||||
function PluginCard({
|
||||
plugin,
|
||||
onInstall,
|
||||
repositoryName,
|
||||
}: {
|
||||
plugin: PluginStoreItem;
|
||||
onInstall: (pluginId: string) => void;
|
||||
repositoryName?: string;
|
||||
}) {
|
||||
const trustLevel = plugin.trustLevel;
|
||||
const trustConfig = trustLevel ? trustLevelConfig[trustLevel] : null;
|
||||
const TrustIcon = trustConfig?.icon ?? User;
|
||||
|
||||
return (
|
||||
<Card className="flex h-full flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-2">
|
||||
<Puzzle className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">
|
||||
{plugin.name}
|
||||
</CardTitle>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
v{plugin.version}
|
||||
</Badge>
|
||||
{trustConfig && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`${trustConfig.className} text-xs`}
|
||||
>
|
||||
<TrustIcon className="mr-1 h-3 w-3" />
|
||||
{trustConfig.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<CardDescription className="line-clamp-2 text-sm">
|
||||
{plugin.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 pb-3">
|
||||
<div className="text-muted-foreground space-y-2 text-sm">
|
||||
{plugin.author && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="truncate">{plugin.author}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>
|
||||
Updated{" "}
|
||||
{formatDistanceToNow(plugin.updatedAt, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
{repositoryName && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="h-3 w-3" />
|
||||
<span className="truncate text-xs">{repositoryName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex items-center justify-between pt-3">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onInstall(plugin.id)}
|
||||
disabled={plugin.status !== "active"}
|
||||
>
|
||||
<Download className="mr-2 h-3 w-3" />
|
||||
Install
|
||||
</Button>
|
||||
{plugin.repositoryUrl && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={plugin.repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{plugin.status !== "active" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{plugin.status}
|
||||
</Badge>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginStoreBrowse() {
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const [trustLevelFilter, setTrustLevelFilter] = React.useState("all");
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
// Get enabled repositories first
|
||||
const { data: repositories } = api.admin.repositories.list.useQuery(
|
||||
{
|
||||
isEnabled: true,
|
||||
limit: 100,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
) as { data: Array<{ url: string; name: string }> | undefined };
|
||||
|
||||
const {
|
||||
data: availablePlugins,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.robots.plugins.list.useQuery(
|
||||
{
|
||||
status:
|
||||
statusFilter === "all"
|
||||
? undefined
|
||||
: (statusFilter as "active" | "deprecated" | "disabled"),
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: Boolean(repositories?.length),
|
||||
},
|
||||
);
|
||||
|
||||
const installPluginMutation = api.robots.plugins.install.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Plugin installed successfully!");
|
||||
void refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to install plugin");
|
||||
},
|
||||
});
|
||||
|
||||
// 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", href: "/plugins" },
|
||||
{ label: "Browse" },
|
||||
]
|
||||
: [{ label: "Plugins", href: "/plugins" }, { label: "Browse" }]),
|
||||
]);
|
||||
|
||||
const handleInstall = React.useCallback(
|
||||
(pluginId: string) => {
|
||||
if (!selectedStudyId) {
|
||||
toast.error("Please select a study first");
|
||||
return;
|
||||
}
|
||||
|
||||
installPluginMutation.mutate({
|
||||
studyId: selectedStudyId,
|
||||
pluginId,
|
||||
});
|
||||
},
|
||||
[selectedStudyId, installPluginMutation],
|
||||
);
|
||||
|
||||
// Transform and filter plugins
|
||||
const filteredPlugins = React.useMemo(() => {
|
||||
if (!availablePlugins) return [];
|
||||
|
||||
return availablePlugins.filter((plugin) => {
|
||||
const matchesSearch =
|
||||
searchTerm === "" ||
|
||||
plugin.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(plugin.description?.toLowerCase().includes(searchTerm.toLowerCase()) ??
|
||||
false) ||
|
||||
(plugin.author?.toLowerCase().includes(searchTerm.toLowerCase()) ??
|
||||
false);
|
||||
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || plugin.status === statusFilter;
|
||||
|
||||
const matchesTrustLevel =
|
||||
trustLevelFilter === "all" || plugin.trustLevel === trustLevelFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesTrustLevel;
|
||||
});
|
||||
}, [availablePlugins, searchTerm, statusFilter, trustLevelFilter]);
|
||||
|
||||
// Status filter options
|
||||
const statusOptions = [
|
||||
{ label: "All Statuses", value: "all" },
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Deprecated", value: "deprecated" },
|
||||
{ label: "Disabled", value: "disabled" },
|
||||
];
|
||||
|
||||
// Trust level filter options
|
||||
const trustLevelOptions = [
|
||||
{ label: "All Trust Levels", value: "all" },
|
||||
{ label: "Official", value: "official" },
|
||||
{ label: "Verified", value: "verified" },
|
||||
{ label: "Community", value: "community" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Plugin Store"
|
||||
description="Browse and install robot plugins for your study"
|
||||
icon={Puzzle}
|
||||
/>
|
||||
|
||||
{!selectedStudyId && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="flex items-center space-x-2 text-amber-800">
|
||||
<Shield className="h-5 w-5" />
|
||||
<p className="text-sm font-medium">
|
||||
Select a study from the sidebar to install plugins
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repositories?.length === 0 && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-center space-x-2 text-blue-800">
|
||||
<Database className="h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
No Plugin Repositories Configured
|
||||
</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Contact your administrator to add plugin repositories.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 sm:space-x-4">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Search plugins..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="text-muted-foreground h-4 w-4" />
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={trustLevelFilter} onValueChange={setTrustLevelFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Trust Level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{trustLevelOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="h-48">
|
||||
<CardHeader>
|
||||
<div className="bg-muted h-4 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-2/3 animate-pulse rounded" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted h-3 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-1/2 animate-pulse rounded" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<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 the plugin store."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin Grid */}
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
{filteredPlugins.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<Puzzle className="text-muted-foreground mx-auto h-12 w-12" />
|
||||
<h3 className="mt-4 text-lg font-semibold">No Plugins Found</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{searchTerm ||
|
||||
statusFilter !== "all" ||
|
||||
trustLevelFilter !== "all"
|
||||
? "Try adjusting your search or filters"
|
||||
: "No plugins are currently available"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPlugins.map((plugin) => {
|
||||
// Find repository for this plugin (this would need to be enhanced with actual repository mapping)
|
||||
const repository = repositories?.find((repo) =>
|
||||
plugin.repositoryUrl?.includes(repo.url),
|
||||
);
|
||||
|
||||
return (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
onInstall={handleInstall}
|
||||
repositoryName={repository?.name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Count */}
|
||||
{filteredPlugins.length > 0 && (
|
||||
<div className="text-muted-foreground text-center text-sm">
|
||||
Showing {filteredPlugins.length} plugin
|
||||
{filteredPlugins.length !== 1 ? "s" : ""}
|
||||
{availablePlugins &&
|
||||
filteredPlugins.length < availablePlugins.length &&
|
||||
` of ${availablePlugins.length} total`}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
323
src/components/plugins/plugins-columns.tsx
Normal file
323
src/components/plugins/plugins-columns.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
MoreHorizontal,
|
||||
Puzzle,
|
||||
Settings,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
export type Plugin = {
|
||||
plugin: {
|
||||
id: string;
|
||||
robotId: string | null;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string | null;
|
||||
author: string | null;
|
||||
repositoryUrl: string | null;
|
||||
trustLevel: "official" | "verified" | "community" | null;
|
||||
status: "active" | "deprecated" | "disabled";
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
installation: {
|
||||
id: string;
|
||||
configuration: Record<string, unknown>;
|
||||
installedAt: Date;
|
||||
installedBy: string;
|
||||
};
|
||||
};
|
||||
|
||||
const trustLevelConfig = {
|
||||
official: {
|
||||
label: "Official",
|
||||
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
description: "Official HRIStudio plugin",
|
||||
},
|
||||
verified: {
|
||||
label: "Verified",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
description: "Verified by the community",
|
||||
},
|
||||
community: {
|
||||
label: "Community",
|
||||
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||
description: "Community contributed",
|
||||
},
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
active: {
|
||||
label: "Active",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
description: "Plugin is active and working",
|
||||
},
|
||||
deprecated: {
|
||||
label: "Deprecated",
|
||||
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
|
||||
description: "Plugin is deprecated",
|
||||
},
|
||||
disabled: {
|
||||
label: "Disabled",
|
||||
className: "bg-red-100 text-red-800 hover:bg-red-200",
|
||||
description: "Plugin is disabled",
|
||||
},
|
||||
};
|
||||
|
||||
function PluginActionsCell({ plugin }: { plugin: Plugin }) {
|
||||
const handleUninstall = async () => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Are you sure you want to uninstall "${plugin.plugin.name}"?`,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement uninstall mutation
|
||||
toast.success("Plugin uninstalled successfully");
|
||||
} catch {
|
||||
toast.error("Failed to uninstall plugin");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = () => {
|
||||
void navigator.clipboard.writeText(plugin.plugin.id);
|
||||
toast.success("Plugin ID copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Configure
|
||||
</DropdownMenuItem>
|
||||
|
||||
{plugin.plugin.repositoryUrl && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={plugin.plugin.repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View Repository
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Plugin ID
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleUninstall}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Uninstall
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const pluginsColumns: ColumnDef<Plugin>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "plugin.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Plugin Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const plugin = row.original;
|
||||
return (
|
||||
<div className="max-w-[200px] min-w-0 space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Puzzle className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate font-medium" title={plugin.plugin.name}>
|
||||
{plugin.plugin.name}
|
||||
</span>
|
||||
</div>
|
||||
{plugin.plugin.description && (
|
||||
<p
|
||||
className="text-muted-foreground line-clamp-1 truncate text-sm"
|
||||
title={plugin.plugin.description}
|
||||
>
|
||||
{plugin.plugin.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "plugin.version",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Version" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const version = row.original.plugin.version;
|
||||
return (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
v{version}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "plugin.author",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Author" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const author = row.original.plugin.author;
|
||||
return (
|
||||
<div className="flex items-center space-x-1 text-sm">
|
||||
<User className="text-muted-foreground h-3 w-3" />
|
||||
<span className="max-w-[120px] truncate" title={author ?? undefined}>
|
||||
{author ?? "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "plugin.trustLevel",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Trust Level" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const trustLevel = row.original.plugin.trustLevel;
|
||||
if (!trustLevel) return "-";
|
||||
|
||||
const config = trustLevelConfig[trustLevel];
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={config.className}
|
||||
title={config.description}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
const trustLevel = row.original.plugin.trustLevel;
|
||||
return trustLevel ? value.includes(trustLevel) : false;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "plugin.status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.plugin.status;
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={config.className}
|
||||
title={config.description}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
return value.includes(row.original.plugin.status);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "installation.installedAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Installed" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.installation.installedAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "plugin.updatedAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Updated" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.plugin.updatedAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => <PluginActionsCell plugin={row.original} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
247
src/components/plugins/plugins-data-table.tsx
Normal file
247
src/components/plugins/plugins-data-table.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Puzzle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
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,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
import { pluginsColumns, type Plugin } from "./plugins-columns";
|
||||
|
||||
export function PluginsDataTable() {
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const [trustLevelFilter, setTrustLevelFilter] = React.useState("all");
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
const {
|
||||
data: pluginsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.robots.plugins.getStudyPlugins.useQuery(
|
||||
{
|
||||
studyId: selectedStudyId!,
|
||||
},
|
||||
{
|
||||
enabled: !!selectedStudyId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-refresh plugins when component mounts to catch external changes
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void refetch();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [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(() => {
|
||||
if (!pluginsData) return [];
|
||||
return pluginsData as Plugin[];
|
||||
}, [pluginsData]);
|
||||
|
||||
// Status filter options
|
||||
const statusOptions = [
|
||||
{ label: "All Statuses", value: "all" },
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Deprecated", value: "deprecated" },
|
||||
{ label: "Disabled", value: "disabled" },
|
||||
];
|
||||
|
||||
// Trust level filter options
|
||||
const trustLevelOptions = [
|
||||
{ label: "All Trust Levels", value: "all" },
|
||||
{ label: "Official", value: "official" },
|
||||
{ label: "Verified", value: "verified" },
|
||||
{ label: "Community", value: "community" },
|
||||
];
|
||||
|
||||
// Filter plugins based on selected filters
|
||||
const filteredPlugins = React.useMemo(() => {
|
||||
return plugins.filter((plugin) => {
|
||||
const statusMatch =
|
||||
statusFilter === "all" || plugin.plugin.status === statusFilter;
|
||||
const trustLevelMatch =
|
||||
trustLevelFilter === "all" ||
|
||||
plugin.plugin.trustLevel === trustLevelFilter;
|
||||
return statusMatch && trustLevelMatch;
|
||||
});
|
||||
}, [plugins, statusFilter, trustLevelFilter]);
|
||||
|
||||
const filters = (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={trustLevelFilter} onValueChange={setTrustLevelFilter}>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="Trust Level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{trustLevelOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
columns={pluginsColumns}
|
||||
data={filteredPlugins}
|
||||
searchKey="plugin.name"
|
||||
searchPlaceholder="Search plugins..."
|
||||
isLoading={isLoading}
|
||||
loadingRowCount={5}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -234,8 +234,8 @@ export const studiesColumns: ColumnDef<Study>[] = [
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as keyof typeof statusConfig;
|
||||
const config = statusConfig[status];
|
||||
const status = row.getValue("status");
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
|
||||
return (
|
||||
<Badge
|
||||
@@ -248,7 +248,7 @@ export const studiesColumns: ColumnDef<Study>[] = [
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
return value.includes(row.getValue(id) as string);
|
||||
return value.includes(row.getValue(id));
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -257,7 +257,7 @@ export const studiesColumns: ColumnDef<Study>[] = [
|
||||
<DataTableColumnHeader column={column} title="Institution" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const institution = row.getValue("institution") as string | null;
|
||||
const institution = row.original.institution;
|
||||
return (
|
||||
<span
|
||||
className="block max-w-[120px] truncate text-sm"
|
||||
@@ -274,20 +274,23 @@ export const studiesColumns: ColumnDef<Study>[] = [
|
||||
<DataTableColumnHeader column={column} title="Owner" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const owner = row.getValue("owner") as Study["owner"];
|
||||
const owner = row.original.owner;
|
||||
if (!owner) {
|
||||
return <span className="text-muted-foreground">No owner</span>;
|
||||
}
|
||||
return (
|
||||
<div className="max-w-[140px] space-y-1">
|
||||
<div
|
||||
className="truncate text-sm font-medium"
|
||||
title={owner?.name ?? "Unknown"}
|
||||
title={owner.name ?? "Unknown"}
|
||||
>
|
||||
{owner?.name ?? "Unknown"}
|
||||
{owner.name ?? "Unknown"}
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={owner?.email}
|
||||
title={owner.email ?? ""}
|
||||
>
|
||||
{owner?.email}
|
||||
{owner.email ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -342,7 +345,7 @@ export const studiesColumns: ColumnDef<Study>[] = [
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
return value.includes(row.getValue(id) as string);
|
||||
return value.includes(row.getValue(id));
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -351,10 +354,10 @@ export const studiesColumns: ColumnDef<Study>[] = [
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt") as Date;
|
||||
const date = row.original.createdAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
{formatDistanceToNow(date ?? new Date(), { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -365,10 +368,10 @@ export const studiesColumns: ColumnDef<Study>[] = [
|
||||
<DataTableColumnHeader column={column} title="Updated" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("updatedAt") as Date;
|
||||
const date = row.original.updatedAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
{formatDistanceToNow(date ?? new Date(), { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -94,7 +94,7 @@ export function StudiesDataTable() {
|
||||
const filters = (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -107,7 +107,7 @@ export function StudiesDataTable() {
|
||||
</Select>
|
||||
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue placeholder="Role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -50,9 +50,9 @@ interface TrialFormProps {
|
||||
export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
const router = useRouter();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
const contextStudyId = studyId || selectedStudyId;
|
||||
const contextStudyId = studyId ?? selectedStudyId;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<TrialFormData>({
|
||||
@@ -93,16 +93,36 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
// Set breadcrumbs
|
||||
const breadcrumbs = [
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Trials", href: "/trials" },
|
||||
...(mode === "edit" && trial
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(contextStudyId
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/trials/${trial.id}`,
|
||||
label: "Study",
|
||||
href: `/studies/${contextStudyId}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Trial" }]),
|
||||
]
|
||||
: [{ label: "New Trial" }]),
|
||||
: [
|
||||
{ label: "Trials", href: "/trials" },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Trial" }]),
|
||||
]),
|
||||
];
|
||||
|
||||
useBreadcrumbsEffect(breadcrumbs);
|
||||
@@ -112,13 +132,13 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
if (mode === "edit" && trial) {
|
||||
form.reset({
|
||||
experimentId: trial.experimentId,
|
||||
participantId: trial.participantId || "",
|
||||
participantId: trial?.participantId ?? "",
|
||||
scheduledAt: trial.scheduledAt
|
||||
? new Date(trial.scheduledAt).toISOString().slice(0, 16)
|
||||
: "",
|
||||
wizardId: trial.wizardId || undefined,
|
||||
notes: trial.notes || "",
|
||||
sessionNumber: trial.sessionNumber || 1,
|
||||
wizardId: trial.wizardId ?? undefined,
|
||||
notes: trial.notes ?? "",
|
||||
sessionNumber: trial.sessionNumber ?? 1,
|
||||
});
|
||||
}
|
||||
}, [trial, mode, form]);
|
||||
@@ -138,8 +158,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
participantId: data.participantId,
|
||||
scheduledAt: new Date(data.scheduledAt),
|
||||
wizardId: data.wizardId,
|
||||
sessionNumber: data.sessionNumber || 1,
|
||||
notes: data.notes || undefined,
|
||||
sessionNumber: data.sessionNumber ?? 1,
|
||||
notes: data.notes ?? undefined,
|
||||
});
|
||||
router.push(`/trials/${newTrial!.id}`);
|
||||
} else {
|
||||
@@ -147,8 +167,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
id: trialId!,
|
||||
scheduledAt: new Date(data.scheduledAt),
|
||||
wizardId: data.wizardId,
|
||||
sessionNumber: data.sessionNumber || 1,
|
||||
notes: data.notes || undefined,
|
||||
sessionNumber: data.sessionNumber ?? 1,
|
||||
notes: data.notes ?? undefined,
|
||||
});
|
||||
router.push(`/trials/${updatedTrial!.id}`);
|
||||
}
|
||||
@@ -244,7 +264,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
<SelectContent>
|
||||
{participantsData?.participants?.map((participant) => (
|
||||
<SelectItem key={participant.id} value={participant.id}>
|
||||
{participant.name || participant.participantCode} (
|
||||
{participant.name ?? participant.participantCode} (
|
||||
{participant.participantCode})
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -312,7 +332,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
<FormField>
|
||||
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
||||
<Select
|
||||
value={form.watch("wizardId") || "none"}
|
||||
value={form.watch("wizardId") ?? "none"}
|
||||
onValueChange={(value) =>
|
||||
form.setValue("wizardId", value === "none" ? undefined : value)
|
||||
}
|
||||
@@ -329,11 +349,13 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No wizard assigned</SelectItem>
|
||||
{usersData?.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
{usersData?.map(
|
||||
(user: { id: string; name: string; email: string }) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -58,6 +58,7 @@ export type Trial = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
participantCode?: string;
|
||||
};
|
||||
wizard: {
|
||||
id: string;
|
||||
@@ -119,7 +120,7 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
|
||||
};
|
||||
|
||||
const handleCopyId = () => {
|
||||
navigator.clipboard.writeText(trial.id);
|
||||
void navigator.clipboard.writeText(trial.id);
|
||||
toast.success("Trial ID copied to clipboard");
|
||||
};
|
||||
|
||||
@@ -301,7 +302,7 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto shrink-0 border-amber-200 bg-amber-50 text-amber-700"
|
||||
title={`Access restricted - You are an ${trial.userRole || "observer"} on this study`}
|
||||
title={`Access restricted - You are an ${trial.userRole ?? "observer"} on this study`}
|
||||
>
|
||||
{trial.userRole === "observer" ? "View Only" : "Restricted"}
|
||||
</Badge>
|
||||
@@ -317,9 +318,9 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as Trial["status"];
|
||||
const status = row.getValue("status");
|
||||
const trial = row.original;
|
||||
const config = statusConfig[status];
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -343,7 +344,7 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
const status = row.getValue(id) as string;
|
||||
const status = row.getValue(id) as string; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
return value.includes(status);
|
||||
},
|
||||
},
|
||||
@@ -353,16 +354,22 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
<DataTableColumnHeader column={column} title="Participant" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const participant = row.getValue("participant") as Trial["participant"];
|
||||
const participant = row.original.participant;
|
||||
return (
|
||||
<div className="max-w-[120px]">
|
||||
<div className="flex items-center space-x-1">
|
||||
<User className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<span
|
||||
className="truncate text-sm font-medium"
|
||||
title={participant.name || "Unnamed Participant"}
|
||||
title={
|
||||
participant?.name ??
|
||||
participant?.participantCode ??
|
||||
"Unnamed Participant"
|
||||
}
|
||||
>
|
||||
{participant.name || "Unnamed Participant"}
|
||||
{participant?.name ??
|
||||
participant?.participantCode ??
|
||||
"Unnamed Participant"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,16 +383,16 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
<DataTableColumnHeader column={column} title="Experiment" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const experiment = row.getValue("experiment") as Trial["experiment"];
|
||||
const experiment = row.original.experiment;
|
||||
return (
|
||||
<div className="flex max-w-[140px] items-center space-x-2">
|
||||
<FlaskConical className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<Link
|
||||
href={`/experiments/${experiment.id}`}
|
||||
href={`/experiments/${experiment?.id ?? ""}`}
|
||||
className="truncate text-sm hover:underline"
|
||||
title={experiment.name || "Unnamed Experiment"}
|
||||
title={experiment?.name ?? "Unnamed Experiment"}
|
||||
>
|
||||
{experiment.name || "Unnamed Experiment"}
|
||||
{experiment?.name ?? "Unnamed Experiment"}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -402,7 +409,7 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
<DataTableColumnHeader column={column} title="Wizard" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const wizard = row.getValue("wizard") as Trial["wizard"];
|
||||
const wizard = row.original.wizard;
|
||||
if (!wizard) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">Not assigned</span>
|
||||
@@ -418,9 +425,9 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={wizard.email}
|
||||
title={wizard.email ?? ""}
|
||||
>
|
||||
{wizard.email}
|
||||
{wizard.email ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -437,7 +444,7 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
<DataTableColumnHeader column={column} title="Scheduled" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("scheduledAt") as Date | null;
|
||||
const date = row.getValue("scheduledAt") as Date | null; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
if (!date) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">Not scheduled</span>
|
||||
@@ -527,7 +534,7 @@ export const trialsColumns: ColumnDef<Trial>[] = [
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt") as Date;
|
||||
const date = row.getValue("createdAt") as Date; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
|
||||
@@ -59,10 +59,22 @@ export function TrialsDataTable() {
|
||||
return () => clearInterval(interval);
|
||||
}, [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: "Trials" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(selectedStudyId && studyData
|
||||
? [
|
||||
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
|
||||
{ label: "Trials" },
|
||||
]
|
||||
: [{ label: "Trials" }]),
|
||||
]);
|
||||
|
||||
// Transform trials data to match the Trial type expected by columns
|
||||
@@ -149,7 +161,7 @@ export function TrialsDataTable() {
|
||||
const filters = (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -222,10 +234,10 @@ export function TrialsDataTable() {
|
||||
Limited Trial Access
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-700">
|
||||
Some trials are marked as "View Only" or "Restricted" because
|
||||
you have observer-level access to their studies. Only
|
||||
researchers, wizards, and study owners can view detailed trial
|
||||
information.
|
||||
Some trials are marked as “View Only” or
|
||||
“Restricted” because you have observer-level
|
||||
access to their studies. Only researchers, wizards, and study
|
||||
owners can view detailed trial information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,8 +115,10 @@ export function DataTable<TData, TValue>({
|
||||
// Initialize with defaultHidden columns set to false
|
||||
const initialVisibility: VisibilityState = {};
|
||||
safeColumns.forEach((column) => {
|
||||
if ((column.meta as any)?.defaultHidden) {
|
||||
const columnKey = column.id || (column as any).accessorKey;
|
||||
const meta = column.meta as { defaultHidden?: boolean } | undefined;
|
||||
if (meta?.defaultHidden) {
|
||||
const columnKey =
|
||||
column.id ?? (column as { accessorKey?: string }).accessorKey;
|
||||
if (columnKey) {
|
||||
initialVisibility[columnKey] = false;
|
||||
}
|
||||
@@ -183,7 +185,7 @@ export function DataTable<TData, TValue>({
|
||||
<div className="flex-shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="ml-2">
|
||||
<Button variant="outline" className="h-8">
|
||||
Columns <ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
Reference in New Issue
Block a user