feat: add initial seed data migration and form builder components

- Created migration 0001_seed_data.sql to insert minimal seed data for users, accounts, and roles.
- Added meta journal for migration tracking.
- Implemented FormBuilder component for dynamic form field creation and management.
- Developed FormFieldRenderer component to render various types of form fields based on user input.
- Introduced constants for trust levels and status configurations.
- Defined types for form fields and trial data structures to enhance type safety and clarity.
This commit is contained in:
2026-03-26 14:56:00 -04:00
parent 1c7f0297a6
commit 7c360dc860
29 changed files with 1551 additions and 4779 deletions
@@ -20,17 +20,16 @@ import {
Users,
CheckCircle,
Printer,
Download,
Pencil,
X,
FileDown,
} from "lucide-react";
import { Textarea } from "~/components/ui/textarea";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
@@ -42,26 +41,11 @@ import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const fieldTypes = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
import type { FormField, FormFieldType } from "~/lib/types/forms";
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
import { formStatusColors } from "~/lib/constants";
import { FormBuilder } from "~/components/forms/FormBuilder";
import { FormFieldRenderer } from "~/components/forms/FormFieldRenderer";
const formTypeIcons = {
consent: FileSignature,
@@ -69,12 +53,6 @@ const formTypeIcons = {
questionnaire: FileQuestion,
};
const statusColors = {
pending: "bg-yellow-100 text-yellow-700",
completed: "bg-green-100 text-green-700",
rejected: "bg-red-100 text-red-700",
};
interface FormViewPageProps {
params: Promise<{
id: string;
@@ -99,7 +77,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [fields, setFields] = useState<Field[]>([]);
const [fields, setFields] = useState<FormField[]>([]);
useEffect(() => {
const resolveParams = async () => {
@@ -213,7 +191,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
'<div style="margin-top: 4px;"><input type="radio" name="yn" /> Yes &nbsp; <input type="radio" name="yn" /> No</div>';
break;
case "rating":
const scale = field.settings?.scale || 5;
const scale = (field.settings?.scale as number) || 5;
inputField = `<div style="margin-top: 4px;">${Array.from(
{ length: scale },
(_, i) => `<input type="radio" name="rating" /> ${i + 1} `,
@@ -284,7 +262,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
if (form) {
setTitle(form.title);
setDescription(form.description || "");
setFields((form.fields as Field[]) || []);
setFields((form.fields as FormField[]) || []);
}
}, [form]);
@@ -307,10 +285,10 @@ export default function FormViewPage({ params }: FormViewPageProps) {
const responses = responsesData?.responses ?? [];
const addField = (type: string) => {
const newField: Field = {
const newField: FormField = {
id: crypto.randomUUID(),
type,
label: `New ${fieldTypes.find((f) => f.value === type)?.label || "Field"}`,
type: type as FormFieldType,
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
required: false,
options:
type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
@@ -322,7 +300,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
setFields(fields.filter((f) => f.id !== id));
};
const updateField = (id: string, updates: Partial<Field>) => {
const updateField = (id: string, updates: Partial<FormField>) => {
setFields(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
};
@@ -332,7 +310,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
title,
description,
fields,
settings: form.settings as Record<string, any>,
settings: form.settings as Record<string, unknown>,
});
};
@@ -415,7 +393,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<SelectValue placeholder="Add field..." />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
{FORM_FIELD_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="mr-2">{type.icon}</span>
{type.label}
@@ -444,11 +422,11 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{
fieldTypes.find((f) => f.value === field.type)
FORM_FIELD_TYPES.find((f) => f.value === field.type)
?.icon
}{" "}
{
fieldTypes.find((f) => f.value === field.type)
FORM_FIELD_TYPES.find((f) => f.value === field.type)
?.label
}
</Badge>
@@ -569,7 +547,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<p className="font-medium">{field.label}</p>
<p className="text-muted-foreground text-xs">
{
fieldTypes.find((f) => f.value === field.type)
FORM_FIELD_TYPES.find((f) => f.value === field.type)
?.label
}
{field.required && " • Required"}
@@ -646,7 +624,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
{field.type === "rating" && (
<div className="flex gap-2">
{Array.from(
{ length: field.settings?.scale || 5 },
{ length: (field.settings?.scale as number) || 5 },
(_, i) => (
<button
key={i}
@@ -831,7 +809,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
</SelectTrigger>
<SelectContent>
{Array.from(
{ length: field.settings?.scale || 5 },
{ length: (field.settings?.scale as number) || 5 },
(_, i) => (
<SelectItem key={i} value={String(i + 1)}>
{i + 1}
@@ -948,7 +926,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
</span>
</div>
<Badge
className={`text-xs ${statusColors[response.status as keyof typeof statusColors]}`}
className={`text-xs ${formStatusColors[response.status as keyof typeof formStatusColors]}`}
>
{response.status}
</Badge>
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation";
@@ -8,22 +8,17 @@ import Link from "next/link";
import {
FileText,
ArrowLeft,
Plus,
Trash2,
GripVertical,
Save,
LayoutTemplate,
FileSignature,
ClipboardList,
FileQuestion,
Save,
Copy,
LayoutTemplate,
} from "lucide-react";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
@@ -31,29 +26,11 @@ import {
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const fieldTypes = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
import type { FormField, FormType } from "~/lib/types/forms";
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
import { FormBuilder } from "~/components/forms/FormBuilder";
const formTypes = [
{ value: "consent", label: "Consent Form", icon: FileSignature, description: "Legal/IRB consent documents" },
@@ -65,14 +42,13 @@ export default function NewFormPage() {
const params = useParams();
const router = useRouter();
const { data: session } = useSession();
const utils = api.useUtils();
const studyId = typeof params.id === "string" ? params.id : "";
const [formType, setFormType] = useState<string>("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [fields, setFields] = useState<Field[]>([]);
const [fields, setFields] = useState<FormField[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: study } = api.studies.get.useQuery(
@@ -115,25 +91,6 @@ export default function NewFormPage() {
return notFound();
}
const addField = (type: string) => {
const newField: Field = {
id: crypto.randomUUID(),
type,
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
setFields([...fields, newField]);
};
const removeField = (id: string) => {
setFields(fields.filter(f => f.id !== id));
};
const updateField = (id: string, updates: Partial<Field>) => {
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -145,7 +102,7 @@ export default function NewFormPage() {
setIsSubmitting(true);
createForm.mutate({
studyId,
type: formType as "consent" | "survey" | "questionnaire",
type: formType as FormType,
title,
description,
fields,
@@ -266,12 +223,21 @@ export default function NewFormPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Form Fields</CardTitle>
<Select onValueChange={addField}>
<Select onValueChange={(type) => {
const newField: FormField = {
id: crypto.randomUUID(),
type: type as FormField["type"],
label: `New ${FORM_FIELD_TYPES.find(f => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
setFields([...fields, newField]);
}}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Add field..." />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
{FORM_FIELD_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="mr-2">{type.icon}</span>
{type.label}
@@ -281,117 +247,7 @@ export default function NewFormPage() {
</Select>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<FileText className="mb-2 h-8 w-8" />
<p>No fields added yet</p>
<p className="text-sm">Use the dropdown above to add fields</p>
</div>
) : (
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-start gap-3 rounded-lg border p-4"
>
<div className="flex cursor-grab items-center text-muted-foreground">
<GripVertical className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{fieldTypes.find(f => f.value === field.type)?.icon}{" "}
{fieldTypes.find(f => f.value === field.type)?.label}
</Badge>
<Input
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Field label"
className="flex-1"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(field.id, { required: e.target.checked })}
className="rounded border-gray-300"
/>
Required
</label>
</div>
{field.type === "multiple_choice" && (
<div className="space-y-2">
<Label className="text-xs">Options</Label>
{field.options?.map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={opt}
onChange={(e) => {
const newOptions = [...(field.options || [])];
newOptions[i] = e.target.value;
updateField(field.id, { options: newOptions });
}}
placeholder={`Option ${i + 1}`}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newOptions = field.options?.filter((_, idx) => idx !== i);
updateField(field.id, { options: newOptions });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`];
updateField(field.id, { options: newOptions });
}}
>
<Plus className="mr-1 h-4 w-4" />
Add Option
</Button>
</div>
)}
{field.type === "rating" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Scale:</span>
<Select
value={field.settings?.scale?.toString() || "5"}
onValueChange={(val) => updateField(field.id, { settings: { scale: parseInt(val) } })}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">1-5</SelectItem>
<SelectItem value="7">1-7</SelectItem>
<SelectItem value="10">1-10</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(field.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
<FormBuilder fields={fields} onFieldsChange={setFields} />
</CardContent>
</Card>
@@ -407,4 +263,4 @@ export default function NewFormPage() {
</form>
</div>
);
}
}
+41 -181
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
FileText,
@@ -22,18 +22,10 @@ import {
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
import type { FormField } from "~/lib/types/forms";
import { FormFieldRenderer } from "~/components/forms/FormFieldRenderer";
const formTypeIcons = {
consent: FileSignature,
@@ -47,7 +39,7 @@ export default function ParticipantFormPage() {
const formId = params.formId as string;
const [participantCode, setParticipantCode] = useState("");
const [formResponses, setFormResponses] = useState<Record<string, any>>({});
const [formResponses, setFormResponses] = useState<Record<string, unknown>>({});
const [hasSubmitted, setHasSubmitted] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
@@ -113,7 +105,7 @@ export default function ParticipantFormPage() {
const TypeIcon =
formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
const fields = (form.fields as Field[]) || [];
const fields = (form.fields as FormField[]) || [];
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
@@ -158,7 +150,7 @@ export default function ParticipantFormPage() {
});
};
const updateResponse = (fieldId: string, value: any) => {
const updateResponse = (fieldId: string, value: unknown) => {
setFormResponses({ ...formResponses, [fieldId]: value });
if (fieldErrors[fieldId]) {
const newErrors = { ...fieldErrors };
@@ -217,175 +209,21 @@ export default function ParticipantFormPage() {
<div className="border-t pt-6">
{fields.map((field, index) => (
<div key={field.id} className="mb-6 last:mb-0">
<Label
htmlFor={field.id}
className={
fieldErrors[field.id] ? "text-destructive" : ""
}
>
{index + 1}. {field.label}
{field.required && (
<span className="text-destructive"> *</span>
)}
</Label>
<FormFieldLabel
field={field}
index={index}
showIndex
/>
<div className="mt-2">
{field.type === "text" && (
<Input
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Enter your response..."
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "textarea" && (
<Textarea
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Enter your response..."
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "multiple_choice" && (
<div
className={`mt-2 space-y-2 ${fieldErrors[field.id] ? "border-destructive rounded-md border p-2" : ""}`}
>
{field.options?.map((opt, i) => (
<label
key={i}
className="flex cursor-pointer items-center gap-2"
>
<input
type="radio"
name={field.id}
value={opt}
checked={formResponses[field.id] === opt}
onChange={() => updateResponse(field.id, opt)}
className="h-4 w-4"
/>
<span className="text-sm">{opt}</span>
</label>
))}
</div>
)}
{field.type === "checkbox" && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id={field.id}
checked={formResponses[field.id] || false}
onChange={(e) =>
updateResponse(field.id, e.target.checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label
htmlFor={field.id}
className="cursor-pointer font-normal"
>
Yes, I agree
</Label>
</div>
)}
{field.type === "yes_no" && (
<div className="mt-2 flex gap-4">
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={field.id}
value="yes"
checked={formResponses[field.id] === "yes"}
onChange={() => updateResponse(field.id, "yes")}
className="h-4 w-4"
/>
<span className="text-sm">Yes</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={field.id}
value="no"
checked={formResponses[field.id] === "no"}
onChange={() => updateResponse(field.id, "no")}
className="h-4 w-4"
/>
<span className="text-sm">No</span>
</label>
</div>
)}
{field.type === "rating" && (
<div className="mt-2 flex flex-wrap gap-2">
{Array.from(
{ length: field.settings?.scale || 5 },
(_, i) => (
<label key={i} className="cursor-pointer">
<input
type="radio"
name={field.id}
value={String(i + 1)}
checked={formResponses[field.id] === i + 1}
onChange={() =>
updateResponse(field.id, i + 1)
}
className="peer sr-only"
/>
<span className="hover:bg-muted peer-checked:bg-primary peer-checked:text-primary-foreground flex h-10 w-10 items-center justify-center rounded-full border text-sm font-medium transition-colors">
{i + 1}
</span>
</label>
),
)}
</div>
)}
{field.type === "date" && (
<Input
type="date"
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "signature" && (
<div className="space-y-2">
<Input
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Type your full name as signature"
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
<p className="text-muted-foreground text-xs">
By entering your name above, you confirm that the
information provided is accurate.
</p>
</div>
)}
<FormFieldRenderer
field={field}
value={formResponses[field.id]}
onChange={(val) => updateResponse(field.id, val)}
mode="participant"
index={index}
error={fieldErrors[field.id]}
/>
</div>
{fieldErrors[field.id] && (
@@ -428,3 +266,25 @@ export default function ParticipantFormPage() {
</div>
);
}
function FormFieldLabel({
field,
index,
showIndex = true,
error,
}: {
field: FormField;
index: number;
showIndex?: boolean;
error?: string;
}) {
return (
<Label
className={error ? "text-destructive" : ""}
>
{showIndex && `${index + 1}. `}
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
);
}
-1
View File
@@ -138,7 +138,6 @@ export default function SignInPage() {
id="not-robot"
checked={notRobot}
onCheckedChange={(checked) => setNotRobot(checked === true)}
disabled={isLoading}
/>
<label
htmlFor="not-robot"
+107 -224
View File
@@ -31,7 +31,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react";
// Define error type for mutations
interface TRPCError {
@@ -101,131 +100,37 @@ const syncStatusConfig = {
},
};
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 });
function RepositoryUrlCell({ url }: { url: string }) {
const handleCopy = () => {
void navigator.clipboard.writeText(url);
toast.success("URL copied to clipboard");
};
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}
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={handleCopy}>
<Copy className="h-4 w-4" />
</Button>
{url && (
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<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>
<ExternalLink className="mr-1 h-3 w-3" />
Visit
</Link>
)}
</div>
);
}
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"
/>
header: ({ column }) => (
<DataTableColumnHeader column={column} title="#" />
),
cell: ({ row }) => (
<Checkbox
@@ -240,34 +145,16 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Repository Name" />
<DataTableColumnHeader column={column} title="Repository" />
),
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 className="flex flex-col">
<span className="font-medium">{row.original.name}</span>
{row.original.description && (
<span className="text-muted-foreground text-xs">
{row.original.description}
</span>
)}
</div>
);
@@ -276,22 +163,11 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
{
accessorKey: "url",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Repository URL" />
<DataTableColumnHeader column={column} title="URL" />
),
cell: ({ row }) => (
<RepositoryUrlCell url={row.original.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",
@@ -327,25 +203,15 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
const isEnabled = row.original.isEnabled;
return (
<Badge
variant="secondary"
className={
isEnabled
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}
variant={isEnabled ? "default" : "secondary"}
className={isEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-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");
return value.includes(row.original.isEnabled ? "enabled" : "disabled");
},
},
{
@@ -354,80 +220,97 @@ export const repositoriesColumns: ColumnDef<Repository>[] = [
<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;
const status = row.original.syncStatus || "pending";
const config = syncStatusConfig[status as keyof typeof syncStatusConfig];
const StatusIcon = config?.icon ?? Clock;
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>
<div className="flex items-center gap-2">
{config && (
<Badge variant="secondary" className={config.className}>
<StatusIcon className="mr-1 h-3 w-3" />
{config.label}
</Badge>
)}
{syncError && syncStatus === "failed" && (
<div
className="max-w-[150px] truncate text-xs text-red-600"
title={syncError}
{row.original.syncError && (
<Button
variant="ghost"
size="sm"
className="h-auto whitespace-normal text-xs text-destructive"
title={row.original.syncError}
>
{syncError}
</div>
<AlertTriangle className="mr-1 h-3 w-3" />
Error
</Button>
)}
</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>
);
filterFn: (row, id, value: string[]) => {
const status = row.original.syncStatus || "pending";
return value.includes(status);
},
},
{
accessorKey: "updatedAt",
accessorKey: "lastSyncAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Updated" />
<DataTableColumnHeader column={column} title="Last Sync" />
),
cell: ({ row }) => {
const date = row.original.updatedAt;
const lastSync = row.original.lastSyncAt;
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
<span className="text-muted-foreground text-sm">
{lastSync ? formatDistanceToNow(lastSync, { addSuffix: true }) : "Never"}
</span>
);
},
sortingFn: "datetime",
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => <RepositoryActionsCell repository={row.original} />,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => {
const repository = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(repository.id)}
>
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/admin/repositories/${repository.id}`}>
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem
asChild
disabled={!repository.url}
>
<Link href={repository.url ?? "#"} target="_blank">
<ExternalLink className="mr-2 h-4 w-4" />
Visit Repository
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{!repository.isOfficial && (
<DropdownMenuItem className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete Repository
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
+1 -8
View File
@@ -3,6 +3,7 @@
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { api } from "~/trpc/react";
import { formatBytes } from "~/lib/utils";
export function SystemStats() {
const { data: stats, isLoading } = api.admin.getSystemStats.useQuery({});
@@ -25,14 +26,6 @@ export function SystemStats() {
);
}
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
const formatUptime = (seconds: number) => {
const d = Math.floor(seconds / (3600 * 24));
const h = Math.floor((seconds % (3600 * 24)) / 3600);
+17 -7
View File
@@ -23,6 +23,7 @@ import {
UserCheck,
Users,
FileText,
X,
} from "lucide-react";
import { useSidebar } from "~/components/ui/sidebar";
@@ -156,7 +157,7 @@ export function AppSidebar({
isLoadingUserStudies,
} = useStudyManagement();
const { startTour, isTourActive } = useTour();
const { startTour, stopTour, isTourActive } = useTour();
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
const hasAutoSelected = useRef(false);
@@ -308,12 +309,21 @@ export function AppSidebar({
</SidebarMenu>
{isTourActive && !isCollapsed && (
<div className="mt-1 px-3 pb-2">
<div className="bg-primary/10 text-primary border-primary/20 animate-in fade-in slide-in-from-top-2 flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm">
<span className="relative flex h-2 w-2">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
</span>
Tutorial Active
<div className="bg-primary/10 text-primary border-primary/20 animate-in fade-in slide-in-from-top-2 flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm">
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
</span>
Tutorial Active
</div>
<button
onClick={stopTour}
className="text-primary/60 hover:text-primary hover:bg-primary/10 rounded p-0.5 transition-colors"
title="Cancel tutorial"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
)}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useState } from "react";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Button } from "~/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { Trash2, Plus, GripVertical } from "lucide-react";
import type { FormField, FormFieldType } from "~/lib/types/forms";
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
interface FormBuilderProps {
fields: FormField[];
onFieldsChange: (fields: FormField[]) => void;
disabled?: boolean;
}
export function FormBuilder({ fields, onFieldsChange, disabled = false }: FormBuilderProps) {
const addField = (type: string) => {
const newField: FormField = {
id: crypto.randomUUID(),
type: type as FormFieldType,
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
onFieldsChange([...fields, newField]);
};
const removeField = (id: string) => {
onFieldsChange(fields.filter((f) => f.id !== id));
};
const updateField = (id: string, updates: Partial<FormField>) => {
onFieldsChange(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
};
return (
<div className="space-y-4">
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<p>No fields added yet</p>
<p className="text-sm">Use the dropdown below to add fields</p>
</div>
) : (
fields.map((field) => (
<div
key={field.id}
className="flex items-start gap-3 rounded-lg border p-4"
>
<div className="flex cursor-grab items-center text-muted-foreground">
<GripVertical className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{FORM_FIELD_TYPES.find((f) => f.value === field.type)?.icon}{" "}
{FORM_FIELD_TYPES.find((f) => f.value === field.type)?.label}
</Badge>
<Input
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Field label"
className="flex-1"
disabled={disabled}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(field.id, { required: e.target.checked })}
className="rounded border-gray-300"
disabled={disabled}
/>
Required
</label>
</div>
{field.type === "multiple_choice" && (
<div className="space-y-2">
<Label className="text-xs">Options</Label>
{field.options?.map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={opt}
onChange={(e) => {
const newOptions = [...(field.options || [])];
newOptions[i] = e.target.value;
updateField(field.id, { options: newOptions });
}}
placeholder={`Option ${i + 1}`}
className="flex-1"
disabled={disabled}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newOptions = field.options?.filter((_, idx) => idx !== i);
updateField(field.id, { options: newOptions });
}}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newOptions = [
...(field.options || []),
`Option ${(field.options?.length || 0) + 1}`,
];
updateField(field.id, { options: newOptions });
}}
disabled={disabled}
>
<Plus className="mr-1 h-4 w-4" />
Add Option
</Button>
</div>
)}
{field.type === "rating" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Scale:</span>
<Select
value={String(field.settings?.scale || 5)}
onValueChange={(val) =>
updateField(field.id, { settings: { scale: parseInt(val) } })
}
disabled={disabled}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">1-5</SelectItem>
<SelectItem value="7">1-7</SelectItem>
<SelectItem value="10">1-10</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(field.id)}
disabled={disabled}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))
)}
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
"use client";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import type { FormField } from "~/lib/types/forms";
import { FORM_FIELD_TYPES } from "~/lib/types/forms";
interface FormFieldRendererProps {
field: FormField;
value: unknown;
onChange: (value: unknown) => void;
mode: "preview" | "data-entry" | "participant";
index: number;
error?: string;
disabled?: boolean;
}
export function FormFieldRenderer({
field,
value,
onChange,
mode,
index,
error,
disabled = false,
}: FormFieldRendererProps) {
const handleChange = (val: unknown) => {
if (!disabled) {
onChange(val);
}
};
const commonProps = {
disabled,
className: error ? "border-destructive" : "",
};
const scale = (field.settings?.scale as number) || 5;
switch (field.type) {
case "text":
return (
<Input
{...commonProps}
value={String(value ?? "")}
onChange={(e) => handleChange(e.target.value)}
placeholder="Enter your response..."
/>
);
case "textarea":
return (
<Textarea
{...commonProps}
value={String(value ?? "")}
onChange={(e) => handleChange(e.target.value)}
placeholder="Enter your response..."
/>
);
case "multiple_choice": {
const containerClass =
mode === "participant"
? `mt-2 space-y-2 ${error ? "border-destructive rounded-md border p-2" : ""}`
: "space-y-2";
return (
<div className={containerClass}>
{field.options?.map((opt, i) => (
<label key={i} className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={field.id}
value={opt}
checked={value === opt}
onChange={() => handleChange(opt)}
disabled={disabled}
className="h-4 w-4"
/>
<span className="text-sm">{opt}</span>
</label>
))}
</div>
);
}
case "checkbox":
return (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(value)}
onChange={(e) => handleChange(e.target.checked)}
disabled={disabled}
className="h-4 w-4 rounded border-gray-300"
/>
{mode === "participant" && (
<Label className="cursor-pointer font-normal">Yes, I agree</Label>
)}
</div>
);
case "yes_no":
if (mode === "data-entry") {
return (
<Select
value={String(value ?? "")}
onValueChange={(val) => handleChange(val)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="yes">Yes</SelectItem>
<SelectItem value="no">No</SelectItem>
</SelectContent>
</Select>
);
}
return (
<div className="flex gap-4">
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={field.id}
value="yes"
checked={value === "yes"}
onChange={() => handleChange("yes")}
disabled={disabled}
className="h-4 w-4"
/>
<span className="text-sm">Yes</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={field.id}
value="no"
checked={value === "no"}
onChange={() => handleChange("no")}
disabled={disabled}
className="h-4 w-4"
/>
<span className="text-sm">No</span>
</label>
</div>
);
case "rating": {
const scale = field.settings?.scale || 5;
if (mode === "data-entry") {
return (
<Select
value={String(value ?? "")}
onValueChange={(val) => handleChange(parseInt(val))}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Select rating..." />
</SelectTrigger>
<SelectContent>
{Array.from({ length: scale }, (_, i) => (
<SelectItem key={i} value={String(i + 1)}>
{i + 1}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (mode === "participant") {
return (
<div className="mt-2 flex flex-wrap gap-2">
{Array.from({ length: scale }, (_, i) => (
<label key={i} className="cursor-pointer">
<input
type="radio"
name={field.id}
value={String(i + 1)}
checked={value === i + 1}
onChange={() => handleChange(i + 1)}
disabled={disabled}
className="peer sr-only"
/>
<span className="hover:bg-muted peer-checked:bg-primary peer-checked:text-primary-foreground flex h-10 w-10 items-center justify-center rounded-full border text-sm font-medium transition-colors">
{i + 1}
</span>
</label>
))}
</div>
);
}
return (
<div className="flex gap-2">
{Array.from({ length: scale }, (_, i) => (
<button
key={i}
type="button"
className="disabled h-8 w-8 rounded border"
disabled
>
{i + 1}
</button>
))}
</div>
);
}
case "date":
return (
<Input
type="date"
{...commonProps}
value={String(value ?? "")}
onChange={(e) => handleChange(e.target.value)}
/>
);
case "signature":
return (
<div className="space-y-2">
<Input
{...commonProps}
value={String(value ?? "")}
onChange={(e) => handleChange(e.target.value)}
placeholder={
mode === "participant"
? "Type your full name as signature"
: "Type name as signature..."
}
/>
<p className="text-muted-foreground text-xs">
By entering your name above, you confirm that the information
provided is accurate.
</p>
</div>
);
default:
return null;
}
}
interface FormFieldLabelProps {
field: FormField;
index: number;
showIndex?: boolean;
}
export function FormFieldLabel({
field,
index,
showIndex = true,
}: FormFieldLabelProps) {
const fieldType = FORM_FIELD_TYPES.find((f) => f.value === field.type);
return (
<Label>
{showIndex && `${index + 1}. `}
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
);
}
+14 -2
View File
@@ -24,6 +24,7 @@ type TourType =
interface TourContextType {
startTour: (tour: TourType) => void;
stopTour: () => void;
isTourActive: boolean;
}
@@ -354,7 +355,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
},
})),
onDestroyed: () => {
// Persistence handled by localStorage state
localStorage.removeItem("hristudio_tour_mode");
Cookies.remove("hristudio_tour_mode");
setIsTourActive(false);
},
});
@@ -389,8 +391,18 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
}
};
const stopTour = () => {
localStorage.removeItem("hristudio_tour_mode");
Cookies.remove("hristudio_tour_mode");
if (driverObj.current) {
driverObj.current.destroy();
driverObj.current = null;
}
setIsTourActive(false);
};
return (
<TourContext.Provider value={{ startTour, isTourActive }}>
<TourContext.Provider value={{ startTour, stopTour, isTourActive }}>
{children}
<style jsx global>{`
/*
+1 -18
View File
@@ -17,6 +17,7 @@ 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 { trustLevelConfig } from "~/lib/constants";
import {
DropdownMenu,
DropdownMenuContent,
@@ -50,24 +51,6 @@ export type Plugin = {
};
};
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",
+14 -4
View File
@@ -137,7 +137,7 @@ const trialSchema = z.object({
experimentId: z.string().uuid("Please select an experiment"),
participantId: z.string().uuid("Please select a participant"),
scheduledAt: z.date(),
wizardId: z.string().uuid().optional(),
wizardId: z.string().uuid().optional().or(z.literal("")),
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
sessionNumber: z
.number()
@@ -269,8 +269,18 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
}
}, [trial, mode, form]);
const createTrialMutation = api.trials.create.useMutation();
const updateTrialMutation = api.trials.update.useMutation();
const createTrialMutation = api.trials.create.useMutation({
onError: (error) => {
console.error("Create trial error:", error);
setError(error.message);
},
});
const updateTrialMutation = api.trials.update.useMutation({
onError: (error) => {
console.error("Update trial error:", error);
setError(error.message);
},
});
// Form submission
const onSubmit = async (data: TrialFormData) => {
@@ -283,7 +293,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
experimentId: data.experimentId,
participantId: data.participantId,
scheduledAt: data.scheduledAt,
wizardId: data.wizardId,
wizardId: data.wizardId || undefined,
sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined,
});
@@ -506,6 +506,10 @@ export const WizardInterface = React.memo(function WizardInterface({
setTrial({ ...trial, status: data.status, startedAt: data.startedAt });
setTrialStartTime(new Date());
},
onError: (error) => {
console.error("Start trial error:", error);
toast.error("Failed to start trial", { description: error.message });
},
});
const completeTrialMutation = api.trials.complete.useMutation({
@@ -528,6 +532,10 @@ export const WizardInterface = React.memo(function WizardInterface({
onSuccess: (data) => {
setTrial({ ...trial, status: data.status });
},
onError: (error) => {
console.error("Abort trial error:", error);
toast.error("Failed to abort trial", { description: error.message });
},
});
const pauseTrialMutation = api.trials.pause.useMutation({
@@ -1306,8 +1314,6 @@ export const WizardInterface = React.memo(function WizardInterface({
onStepSelect={handleStepSelect}
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
activeTab={executionPanelTab}
onTabChange={setExecutionPanelTab}
onSkipAction={handleSkipAction}
isExecuting={isExecutingAction}
onNextStep={handleNextStep}
@@ -3,85 +3,14 @@
import React from "react";
import { WizardActionItem } from "./WizardActionItem";
import {
Play,
SkipForward,
CheckCircle,
AlertCircle,
ArrowRight,
Zap,
Loader2,
Clock,
RotateCcw,
AlertTriangle,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { ScrollArea } from "~/components/ui/scroll-area";
interface StepData {
id: string;
name: string;
description: string | null;
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
parameters: Record<string, unknown>;
conditions?: {
options?: {
label: string;
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
}[];
};
order: number;
actions?: {
id: string;
name: string;
description: string | null;
type: string;
parameters: Record<string, unknown>;
order: number;
pluginId: string | null;
}[];
}
interface TrialData {
id: string;
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
scheduledAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
sessionNumber: number | null;
notes: string | null;
experimentId: string;
participantId: string | null;
wizardId: string | null;
experiment: {
id: string;
name: string;
description: string | null;
studyId: string;
};
participant: {
id: string;
participantCode: string;
demographics: Record<string, unknown> | null;
};
}
interface TrialEvent {
type: string;
timestamp: Date;
data?: unknown;
message?: string;
}
import type { TrialData, StepData, TrialEvent } from "~/lib/types/trial";
interface WizardExecutionPanelProps {
trial: TrialData;
@@ -100,8 +29,6 @@ interface WizardExecutionPanelProps {
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean },
) => Promise<void>;
activeTab: "current" | "timeline" | "events"; // Deprecated/Ignored
onTabChange: (tab: "current" | "timeline" | "events") => void; // Deprecated/Ignored
onSkipAction: (
pluginName: string,
actionId: string,
@@ -118,7 +45,7 @@ interface WizardExecutionPanelProps {
rosConnected?: boolean;
completedStepIndices?: Set<number>;
skippedStepIndices?: Set<number>;
onLogEvent?: (type: string, data?: any) => void;
onLogEvent?: (type: string, data?: unknown) => void;
}
export function WizardExecutionPanel({
@@ -130,8 +57,6 @@ export function WizardExecutionPanel({
onStepSelect,
onExecuteAction,
onExecuteRobotAction,
activeTab,
onTabChange,
onSkipAction,
isExecuting = false,
onNextStep,
+7 -1
View File
@@ -153,7 +153,13 @@ export function EntityForm<T extends FieldValues = FieldValues>({
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit(e).catch((err) => console.error("handleSubmit error:", err));
}}
className="space-y-6"
>
{/* Form Fields */}
{children}
-44
View File
@@ -1,44 +0,0 @@
/**
* @file useActiveStudy.ts
*
* Legacy placeholder for the deprecated `useActiveStudy` hook.
*
* This file exists solely to satisfy lingering TypeScript project
* service references (e.g. editor cached import paths) after the
* migration to the unified `useSelectedStudyDetails` hook.
*
* Previous responsibilities:
* - Exposed the currently "active" study id via localStorage.
* - Partially overlapped with a separate study context implementation.
*
* Migration:
* - All consumers should now import `useSelectedStudyDetails` from:
* `~/hooks/useSelectedStudyDetails`
* - That hook centralizes selection, metadata, counts, and role info.
*
* Safe Removal:
* - Once you are certain no editors / build artifacts reference this
* path, you may delete this file. It is intentionally tiny and has
* zero runtime footprint unless mistakenly invoked.
*/
/**
* @deprecated Use `useSelectedStudyDetails()` instead.
* Legacy no-op placeholder retained only to satisfy stale references.
* Returns a neutral object so accidental invocations are harmless.
*/
export function useActiveStudy(): DeprecatedActiveStudyHookReturn {
return { studyId: null };
}
/**
* Type alias maintained for backward compatibility with (now removed)
* code that might have referenced the old hook's return type.
* Kept minimal on purpose.
*/
export interface DeprecatedActiveStudyHookReturn {
/** Previously the active study id (now: studyId in useSelectedStudyDetails) */
studyId: string | null;
}
export default useActiveStudy;
+1 -75
View File
@@ -4,18 +4,12 @@ import { signOut } from "~/lib/auth-client";
import { toast } from "sonner";
import { TRPCClientError } from "@trpc/client";
/**
* Auth error codes that should trigger automatic logout
*/
const AUTH_ERROR_CODES = [
"UNAUTHORIZED",
"FORBIDDEN",
"UNAUTHENTICATED",
] as const;
/**
* Auth error messages that should trigger automatic logout
*/
const AUTH_ERROR_MESSAGES = [
"unauthorized",
"unauthenticated",
@@ -27,15 +21,10 @@ const AUTH_ERROR_MESSAGES = [
"access denied",
] as const;
/**
* Checks if an error is an authentication/authorization error that should trigger logout
*/
export function isAuthError(error: unknown): boolean {
if (!error) return false;
// Check TRPC errors
if (error instanceof TRPCClientError) {
// Check error code
const trpcErrorData = error.data as
| { code?: string; httpStatus?: number }
| undefined;
@@ -47,24 +36,20 @@ export function isAuthError(error: unknown): boolean {
return true;
}
// Check HTTP status codes
const httpStatus = trpcErrorData?.httpStatus;
if (httpStatus === 401 || httpStatus === 403) {
return true;
}
// Check error message
const message = error.message?.toLowerCase() ?? "";
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check generic errors
if (error instanceof Error) {
const message = error.message?.toLowerCase() || "";
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check error objects with message property
if (typeof error === "object" && error !== null) {
if ("message" in error) {
const errorObj = error as { message: unknown };
@@ -72,7 +57,6 @@ export function isAuthError(error: unknown): boolean {
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check for status codes in error objects
if ("status" in error) {
const statusObj = error as { status: unknown };
const status = statusObj.status as number;
@@ -83,9 +67,6 @@ export function isAuthError(error: unknown): boolean {
return false;
}
/**
* Handles authentication errors by logging out the user
*/
export async function handleAuthError(
error: unknown,
customMessage?: string,
@@ -96,11 +77,9 @@ export async function handleAuthError(
console.warn("Authentication error detected, logging out user:", error);
// Show user-friendly message
const message = customMessage ?? "Session expired. Please log in again.";
toast.error(message);
// Small delay to let the toast show
setTimeout(() => {
void (async () => {
try {
@@ -108,72 +87,19 @@ export async function handleAuthError(
window.location.href = "/";
} catch (signOutError) {
console.error("Error during sign out:", signOutError);
// Force redirect if signOut fails
window.location.href = "/";
}
})();
}, 1000);
}
/**
* React Query error handler that automatically handles auth errors
*/
export function createAuthErrorHandler(customMessage?: string) {
return (error: unknown) => {
void handleAuthError(error, customMessage);
};
}
/**
* tRPC error handler that automatically handles auth errors
*/
export function handleTRPCError(error: unknown, customMessage?: string): void {
void handleAuthError(error, customMessage);
}
/**
* Generic error handler for any error type
*/
export function handleGenericError(
error: unknown,
customMessage?: string,
): void {
void handleAuthError(error, customMessage);
}
/**
* Hook-style error handler for use in React components
*/
export function useAuthErrorHandler() {
return {
handleAuthError: (error: unknown, customMessage?: string) => {
void handleAuthError(error, customMessage);
},
handleAuthError,
isAuthError,
createErrorHandler: createAuthErrorHandler,
};
}
/**
* Higher-order function to wrap API calls with automatic auth error handling
*/
export function withAuthErrorHandling<
T extends (...args: unknown[]) => Promise<unknown>,
>(fn: T, customMessage?: string): T {
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
try {
return (await fn(...args)) as ReturnType<T>;
} catch (error) {
await handleAuthError(error, customMessage);
throw error; // Re-throw so calling code can handle it too
}
}) as T;
}
/**
* Utility to check if current error should show a generic error message
* (i.e., it's not an auth error that will auto-logout)
*/
export function shouldShowGenericError(error: unknown): boolean {
return !isAuthError(error);
}
+43
View File
@@ -0,0 +1,43 @@
import type { LucideIcon } from "lucide-react";
export 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",
},
};
export 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",
},
inactive: {
label: "Inactive",
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
description: "Plugin is not active",
},
};
export const formStatusColors = {
pending: "bg-yellow-100 text-yellow-700",
completed: "bg-green-100 text-green-700",
rejected: "bg-red-100 text-red-700",
};
+21
View File
@@ -464,6 +464,7 @@ export class WizardRosService extends EventEmitter {
* Subscribe to robot sensor topics
*/
private subscribeToRobotTopics(): void {
console.log("[WizardROS] Setting up robot topics...");
const topics = [
{ topic: "/joint_states", type: "sensor_msgs/JointState" },
{ topic: "/bumper", type: "naoqi_bridge_msgs/Bumper" },
@@ -476,6 +477,11 @@ export class WizardRosService extends EventEmitter {
topics.forEach(({ topic, type }) => {
this.subscribe(topic, type);
});
this.advertise("/speech", "std_msgs/String");
this.advertise("/cmd_vel", "geometry_msgs/Twist");
this.advertise("/robot_pose", "geometry_msgs/Pose");
this.advertise("/animation", "std_msgs/String");
}
/**
@@ -492,6 +498,21 @@ export class WizardRosService extends EventEmitter {
this.send(message);
}
/**
* Advertise a ROS topic (declare the type before publishing)
*/
private advertise(topic: string, messageType: string): void {
console.log(`[WizardROS] Advertising topic ${topic} as ${messageType}`);
const message: RosMessage = {
op: "advertise",
topic,
type: messageType,
id: `adv_${this.messageId++}`,
};
this.send(message);
}
/**
* Publish message to ROS topic
*/
+51
View File
@@ -0,0 +1,51 @@
export interface FormFieldSettings {
scale?: number;
}
export interface FormField {
id: string;
type: FormFieldType;
label: string;
required: boolean;
options?: string[];
settings?: FormFieldSettings;
}
export type FormFieldType =
| "text"
| "textarea"
| "multiple_choice"
| "checkbox"
| "rating"
| "yes_no"
| "date"
| "signature";
export type FormType = "consent" | "survey" | "questionnaire";
export interface FormFieldTypeConfig {
value: FormFieldType;
label: string;
icon: string;
}
export const FORM_FIELD_TYPES: FormFieldTypeConfig[] = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
export function createField(type: FormFieldType): FormField {
return {
id: crypto.randomUUID(),
type,
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
}
+73
View File
@@ -0,0 +1,73 @@
export interface StepData {
id: string;
name: string;
description: string | null;
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
parameters: Record<string, unknown>;
conditions?: {
options?: {
label: string;
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
}[];
};
order: number;
actions?: ActionData[];
}
export interface ActionData {
id: string;
name: string;
description: string | null;
type: string;
parameters: Record<string, unknown>;
order: number;
pluginId: string | null;
}
export interface TrialData {
id: string;
status: TrialStatus;
scheduledAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
sessionNumber: number | null;
notes: string | null;
experimentId: string;
participantId: string | null;
wizardId: string | null;
experiment: {
id: string;
name: string;
description: string | null;
studyId: string;
};
participant: {
id: string;
participantCode: string;
demographics: Record<string, unknown> | null;
};
}
export type TrialStatus =
| "scheduled"
| "in_progress"
| "completed"
| "aborted"
| "failed";
export interface TrialEvent {
type: string;
timestamp: Date;
data?: unknown;
message?: string;
}