feat: Add a new onboarding tour for participant creation and refactor consent form uploads to use sonner for toasts and XMLHttpRequest for progress tracking.

This commit is contained in:
2026-02-11 23:49:51 -05:00
parent 85b951f742
commit 93de577939
8 changed files with 198 additions and 97 deletions

View File

@@ -34,7 +34,7 @@ async function inspectExperimentSteps() {
console.log(`Step [${step.orderIndex}] ID: ${step.id}`); console.log(`Step [${step.orderIndex}] ID: ${step.id}`);
console.log(`Name: ${step.name}`); console.log(`Name: ${step.name}`);
console.log(`Type: ${step.type}`); console.log(`Type: ${step.type}`);
console.log(`NextStepId: ${step.nextStepId}`);
if (step.type === 'conditional') { if (step.type === 'conditional') {
console.log("Conditions:", JSON.stringify(step.conditions, null, 2)); console.log("Conditions:", JSON.stringify(step.conditions, null, 2));

View File

@@ -31,6 +31,12 @@ const steps = [
function simulateNextStep(currentStepIndex: number) { function simulateNextStep(currentStepIndex: number) {
const currentStep = steps[currentStepIndex]; const currentStep = steps[currentStepIndex];
if (!currentStep) {
console.log("No step found at index:", currentStepIndex);
return;
}
console.log(`\n--- Simulating Next Step from: ${currentStep.name} ---`); console.log(`\n--- Simulating Next Step from: ${currentStep.name} ---`);
console.log("Current Step Data:", JSON.stringify(currentStep, null, 2)); console.log("Current Step Data:", JSON.stringify(currentStep, null, 2));

View File

@@ -48,9 +48,15 @@ async function verifyTrpcLogic() {
// 3. Inspect Step 4 (Branch A) // 3. Inspect Step 4 (Branch A)
// Step index 3 (0-based) is Branch A // Step index 3 (0-based) is Branch A
const branchAStep = transformedSteps[3]; const branchAStep = transformedSteps[3];
console.log("Step 4 (Branch A):", branchAStep.name);
console.log(" Type:", branchAStep.type); if (branchAStep) {
console.log(" Trigger:", JSON.stringify(branchAStep.trigger, null, 2)); console.log("Step 4 (Branch A):", branchAStep.name);
console.log(" Type:", branchAStep.type);
console.log(" Trigger:", JSON.stringify(branchAStep.trigger, null, 2));
} else {
console.error("Step 4 (Branch A) not found in transformed steps!");
process.exit(1);
}
// Check conditions specifically // Check conditions specifically
const conditions = branchAStep.trigger?.conditions as any; const conditions = branchAStep.trigger?.conditions as any;
@@ -62,8 +68,12 @@ async function verifyTrpcLogic() {
// Inspect Step 5 (Branch B) for completeness // Inspect Step 5 (Branch B) for completeness
const branchBStep = transformedSteps[4]; const branchBStep = transformedSteps[4];
console.log("Step 5 (Branch B):", branchBStep.name); if (branchBStep) {
console.log(" Trigger:", JSON.stringify(branchBStep.trigger, null, 2)); console.log("Step 5 (Branch B):", branchBStep.name);
console.log(" Trigger:", JSON.stringify(branchBStep.trigger, null, 2));
} else {
console.warn("Step 5 (Branch B) not found in transformed steps.");
}
} }
verifyTrpcLogic() verifyTrpcLogic()

View File

@@ -82,6 +82,7 @@ export const columns: ColumnDef<AnalyticsTrial>[] = [
}, },
{ {
accessorKey: "participant.participantCode", accessorKey: "participant.participantCode",
id: "participantCode",
header: "Participant", header: "Participant",
cell: ({ row }) => ( cell: ({ row }) => (
<div className="font-medium">{row.original.participant?.participantCode ?? "Unknown"}</div> <div className="font-medium">{row.original.participant?.participantCode ?? "Unknown"}</div>
@@ -233,9 +234,9 @@ export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps)
<div className="flex items-center py-4"> <div className="flex items-center py-4">
<Input <Input
placeholder="Filter participants..." placeholder="Filter participants..."
value={(table.getColumn("participant.participantCode")?.getFilterValue() as string) ?? ""} value={(table.getColumn("participantCode")?.getFilterValue() as string) ?? ""}
onChange={(event) => onChange={(event) =>
table.getColumn("participant.participantCode")?.setFilterValue(event.target.value) table.getColumn("participantCode")?.setFilterValue(event.target.value)
} }
className="max-w-sm" className="max-w-sm"
/> />

View File

@@ -7,7 +7,7 @@ import { useTheme } from "next-themes";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
type TourType = "dashboard" | "study_creation" | "designer" | "wizard" | "full_platform"; type TourType = "dashboard" | "study_creation" | "participant_creation" | "designer" | "wizard" | "full_platform";
interface TourContextType { interface TourContextType {
startTour: (tour: TourType) => void; startTour: (tour: TourType) => void;
@@ -46,6 +46,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
runTourSegment("dashboard"); runTourSegment("dashboard");
} else if (pathname.includes("/studies/new")) { } else if (pathname.includes("/studies/new")) {
runTourSegment("study_creation"); runTourSegment("study_creation");
} else if (pathname.includes("/participants/new")) {
runTourSegment("participant_creation");
} else if (pathname.includes("/designer")) { } else if (pathname.includes("/designer")) {
runTourSegment("designer"); runTourSegment("designer");
} else if (pathname.includes("/wizard")) { } else if (pathname.includes("/wizard")) {
@@ -56,7 +58,7 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
} }
}, [pathname]); }, [pathname]);
const runTourSegment = (segment: "dashboard" | "study_creation" | "designer" | "wizard") => { const runTourSegment = (segment: "dashboard" | "study_creation" | "participant_creation" | "designer" | "wizard") => {
const isDark = theme === "dark"; const isDark = theme === "dark";
// We add a specific class to handle dark/light overrides reliably // We add a specific class to handle dark/light overrides reliably
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light"; const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
@@ -134,6 +136,49 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
} }
} }
]; ];
} else if (segment === "participant_creation") {
steps = [
{
element: "#tour-participant-code",
popover: {
title: "Participant ID",
description: "Assign a unique code (e.g., P001) to identify this participant while maintaining anonymity.",
side: "right",
}
},
{
element: "#tour-participant-name",
popover: {
title: "Name (Optional)",
description: "You store their name for internal reference; analytics will use the ID.",
side: "right",
}
},
{
element: "#tour-participant-study-container",
popover: {
title: "Study Association",
description: "Link this participant to a specific research study to enable data collection.",
side: "right",
}
},
{
element: "#tour-participant-consent",
popover: {
title: "Informed Consent",
description: "Mandatory check to confirm you have obtained necessary ethical approvals and consent.",
side: "top",
}
},
{
element: "#tour-participant-submit",
popover: {
title: "Register",
description: "Create the participant record to begin scheduling trials.",
side: "top",
}
}
];
} else if (segment === "designer") { } else if (segment === "designer") {
steps = [ steps = [
{ {
@@ -217,6 +262,7 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
// Trigger current page immediately // Trigger current page immediately
if (pathname === "/dashboard") runTourSegment("dashboard"); if (pathname === "/dashboard") runTourSegment("dashboard");
else if (pathname.includes("/studies/new")) runTourSegment("study_creation"); else if (pathname.includes("/studies/new")) runTourSegment("study_creation");
else if (pathname.includes("/participants/new")) runTourSegment("participant_creation");
else if (pathname.includes("/designer")) runTourSegment("designer"); else if (pathname.includes("/designer")) runTourSegment("designer");
else if (pathname.includes("/wizard")) runTourSegment("wizard"); else if (pathname.includes("/wizard")) runTourSegment("wizard");
else runTourSegment("dashboard"); // Fallback else runTourSegment("dashboard"); // Fallback
@@ -226,6 +272,7 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
if (tour === "dashboard") runTourSegment("dashboard"); if (tour === "dashboard") runTourSegment("dashboard");
if (tour === "study_creation") runTourSegment("study_creation"); if (tour === "study_creation") runTourSegment("study_creation");
if (tour === "participant_creation") runTourSegment("participant_creation");
if (tour === "designer") runTourSegment("designer"); if (tour === "designer") runTourSegment("designer");
if (tour === "wizard") runTourSegment("wizard"); if (tour === "wizard") runTourSegment("wizard");
} }

View File

@@ -5,9 +5,8 @@ import { Upload, X, FileText, CheckCircle, AlertCircle, Loader2 } from "lucide-r
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress"; import { Progress } from "~/components/ui/progress";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "~/components/ui/use-toast"; import { toast } from "sonner";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import axios from "axios";
interface ConsentUploadFormProps { interface ConsentUploadFormProps {
studyId: string; studyId: string;
@@ -37,20 +36,16 @@ export function ConsentUploadForm({
const selectedFile = e.target.files[0]; const selectedFile = e.target.files[0];
// Validate size (10MB) // Validate size (10MB)
if (selectedFile.size > 10 * 1024 * 1024) { if (selectedFile.size > 10 * 1024 * 1024) {
toast({ toast.error("File too large", {
title: "File too large",
description: "Maximum file size is 10MB", description: "Maximum file size is 10MB",
variant: "destructive",
}); });
return; return;
} }
// Validate type // Validate type
const allowedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg"]; const allowedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg"];
if (!allowedTypes.includes(selectedFile.type)) { if (!allowedTypes.includes(selectedFile.type)) {
toast({ toast.error("Invalid file type", {
title: "Invalid file type",
description: "Please upload a PDF, PNG, or JPG file", description: "Please upload a PDF, PNG, or JPG file",
variant: "destructive",
}); });
return; return;
} }
@@ -74,19 +69,31 @@ export function ConsentUploadForm({
size: file.size, size: file.size,
}); });
// 2. Upload to MinIO // 2. Upload to MinIO using XMLHttpRequest for progress
await axios.put(url, file, { await new Promise<void>((resolve, reject) => {
headers: { const xhr = new XMLHttpRequest();
"Content-Type": file.type, xhr.open("PUT", url, true);
}, xhr.setRequestHeader("Content-Type", file.type);
onUploadProgress: (progressEvent) => {
if (progressEvent.total) { xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentCompleted = Math.round( const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total (event.loaded * 100) / event.total
); );
setUploadProgress(percentCompleted); setUploadProgress(percentCompleted);
} }
}, };
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(file);
}); });
// 3. Record Consent in DB // 3. Record Consent in DB
@@ -96,18 +103,15 @@ export function ConsentUploadForm({
storagePath: key, storagePath: key,
}); });
toast({ toast.success("Consent Recorded", {
title: "Consent Recorded",
description: "The consent form has been uploaded and recorded successfully.", description: "The consent form has been uploaded and recorded successfully.",
}); });
onSuccess(); onSuccess();
} catch (error) { } catch (error) {
console.error("Upload failed:", error); console.error("Upload failed:", error);
toast({ toast.error("Upload Failed", {
title: "Upload Failed",
description: error instanceof Error ? error.message : "An unexpected error occurred", description: error instanceof Error ? error.message : "An unexpected error occurred",
variant: "destructive",
}); });
setIsUploading(false); setIsUploading(false);
} }

View File

@@ -26,6 +26,8 @@ import {
} from "~/components/ui/select"; } from "~/components/ui/select";
import { useStudyContext } from "~/lib/study-context"; import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useTour } from "~/components/onboarding/TourProvider";
import { Button } from "~/components/ui/button";
type DemographicsData = { type DemographicsData = {
age?: number; age?: number;
@@ -80,6 +82,7 @@ export function ParticipantForm({
studyId, studyId,
}: ParticipantFormProps) { }: ParticipantFormProps) {
const router = useRouter(); const router = useRouter();
const { startTour } = useTour();
const { selectedStudyId } = useStudyContext(); const { selectedStudyId } = useStudyContext();
const contextStudyId = studyId ?? selectedStudyId; const contextStudyId = studyId ?? selectedStudyId;
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -262,7 +265,7 @@ export function ParticipantForm({
<FormField> <FormField>
<Label htmlFor="participantCode">Participant Code *</Label> <Label htmlFor="participantCode">Participant Code *</Label>
<Input <Input
id="participantCode" id="tour-participant-code"
{...form.register("participantCode")} {...form.register("participantCode")}
placeholder="e.g., P001" placeholder="e.g., P001"
className={ className={
@@ -279,7 +282,7 @@ export function ParticipantForm({
<FormField> <FormField>
<Label htmlFor="name">Full Name</Label> <Label htmlFor="name">Full Name</Label>
<Input <Input
id="name" id="tour-participant-name"
{...form.register("name")} {...form.register("name")}
placeholder="Optional name" placeholder="Optional name"
className={form.formState.errors.name ? "border-red-500" : ""} className={form.formState.errors.name ? "border-red-500" : ""}
@@ -317,36 +320,38 @@ export function ParticipantForm({
> >
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<FormField> <FormField>
<Label htmlFor="studyId">Study *</Label> <Label htmlFor="studyId" id="tour-participant-study-label">Study *</Label>
<Select <div id="tour-participant-study-container">
value={form.watch("studyId")} <Select
onValueChange={(value) => form.setValue("studyId", value)} value={form.watch("studyId")}
disabled={studiesLoading || mode === "edit"} onValueChange={(value) => form.setValue("studyId", value)}
> disabled={studiesLoading || mode === "edit"}
<SelectTrigger
className={
form.formState.errors.studyId ? "border-red-500" : ""
}
> >
<SelectValue <SelectTrigger
placeholder={ className={
studiesLoading ? "Loading..." : "Select study" form.formState.errors.studyId ? "border-red-500" : ""
} }
/> >
</SelectTrigger> <SelectValue
<SelectContent> placeholder={
{studiesData?.studies?.map((study) => ( studiesLoading ? "Loading..." : "Select study"
<SelectItem key={study.id} value={study.id}> }
{study.name} />
</SelectItem> </SelectTrigger>
))} <SelectContent>
</SelectContent> {studiesData?.studies?.map((study) => (
</Select> <SelectItem key={study.id} value={study.id}>
{form.formState.errors.studyId && ( {study.name}
<p className="text-sm text-red-600"> </SelectItem>
{form.formState.errors.studyId.message} ))}
</p> </SelectContent>
)} </Select>
{form.formState.errors.studyId && (
<p className="text-sm text-red-600">
{form.formState.errors.studyId.message}
</p>
)}
</div>
</FormField> </FormField>
<FormField> <FormField>
@@ -408,7 +413,7 @@ export function ParticipantForm({
<FormField> <FormField>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="consentGiven" id="tour-participant-consent"
checked={form.watch("consentGiven")} checked={form.watch("consentGiven")}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
form.setValue("consentGiven", !!checked) form.setValue("consentGiven", !!checked)
@@ -495,6 +500,17 @@ export function ParticipantForm({
isDeleting={isDeleting} isDeleting={isDeleting}
sidebar={mode === "create" ? sidebar : undefined} sidebar={mode === "create" ? sidebar : undefined}
submitText={mode === "create" ? "Register Participant" : "Save Changes"} submitText={mode === "create" ? "Register Participant" : "Save Changes"}
submitButtonId="tour-participant-submit"
extraActions={
mode === "create" ? (
<Button variant="ghost" size="sm" onClick={() => startTour("participant_creation")}>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Help</span>
<div className="flex h-5 w-5 items-center justify-center rounded-full border text-xs text-muted-foreground">?</div>
</div>
</Button>
) : undefined
}
> >
{formFields} {formFields}
</EntityForm> </EntityForm>

View File

@@ -225,7 +225,7 @@ export function WizardExecutionPanel({
<div className="relative ml-3 space-y-0 pt-2"> <div className="relative ml-3 space-y-0 pt-2">
{currentStep.actions.map((action, idx) => { {currentStep.actions.map((action, idx) => {
const isCompleted = idx < activeActionIndex; const isCompleted = idx < activeActionIndex;
const isActive = idx === activeActionIndex; const isActive: boolean = idx === activeActionIndex;
const isLast = idx === currentStep.actions!.length - 1; const isLast = idx === currentStep.actions!.length - 1;
return ( return (
@@ -281,7 +281,7 @@ export function WizardExecutionPanel({
)} )}
{/* Active Action Controls */} {/* Active Action Controls */}
{isActive && ( {isActive === true ? (
<div className="pt-3 flex items-center gap-3"> <div className="pt-3 flex items-center gap-3">
{action.pluginId && !["hristudio-core", "hristudio-woz"].includes(action.pluginId) ? ( {action.pluginId && !["hristudio-core", "hristudio-woz"].includes(action.pluginId) ? (
<> <>
@@ -339,45 +339,62 @@ export function WizardExecutionPanel({
</Button> </Button>
)} )}
</div> </div>
)} ) : null}
{/* Wizard Wait For Response / Branching UI */} {/* Wizard Wait For Response / Branching UI */}
{isActive && action.type === 'wizard_wait_for_response' && action.parameters?.options && Array.isArray(action.parameters.options) && ( {isActive === true &&
action.type === "wizard_wait_for_response" &&
action.parameters?.options &&
Array.isArray(action.parameters.options) ? (
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
{(action.parameters.options as any[]).map((opt, optIdx) => { {(action.parameters.options as any[]).map(
// Handle both string options and object options (opt, optIdx) => {
const label = typeof opt === 'string' ? opt : opt.label; // Handle both string options and object options
const value = typeof opt === 'string' ? opt : opt.value; const label =
const nextStepId = typeof opt === 'object' ? opt.nextStepId : undefined; typeof opt === "string"
? opt
: opt.label;
const value =
typeof opt === "string"
? opt
: opt.value;
const nextStepId =
typeof opt === "object"
? opt.nextStepId
: undefined;
return ( return (
<Button <Button
key={optIdx} key={optIdx}
variant="outline" variant="outline"
className="justify-start h-auto py-3 px-4 text-left border-primary/20 hover:border-primary hover:bg-primary/5" className="justify-start h-auto py-3 px-4 text-left border-primary/20 hover:border-primary hover:bg-primary/5"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onExecuteAction( onExecuteAction(action.id, {
action.id,
{
value, value,
label, label,
nextStepId nextStepId,
} });
); onActionCompleted();
onActionCompleted(); }}
}} disabled={readOnly || isExecuting}
disabled={readOnly || isExecuting} >
> <div className="flex flex-col items-start gap-1">
<div className="flex flex-col items-start gap-1"> <span className="font-medium">
<span className="font-medium">{String(label)}</span> {String(label)}
{typeof opt !== 'string' && value && <span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded-sm">{String(value)}</span>} </span>
</div> {typeof opt !== "string" && value && (
</Button> <span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded-sm">
); {String(value)}
})} </span>
)}
</div>
</Button>
);
}
)}
</div> </div>
)} ) : null}
{/* Completed State Actions */} {/* Completed State Actions */}
{isCompleted && action.pluginId && ( {isCompleted && action.pluginId && (