From 93de5779391dc6190c8cc0f4e033988ae318285a Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Wed, 11 Feb 2026 23:49:51 -0500 Subject: [PATCH] feat: Add a new onboarding tour for participant creation and refactor consent form uploads to use `sonner` for toasts and `XMLHttpRequest` for progress tracking. --- scripts/inspect-step.ts | 2 +- scripts/simulate-branch-logic.ts | 6 ++ scripts/verify-trpc-logic.ts | 20 +++-- .../analytics/study-analytics-data-table.tsx | 5 +- src/components/onboarding/TourProvider.tsx | 51 ++++++++++- .../participants/ConsentUploadForm.tsx | 48 ++++++----- .../participants/ParticipantForm.tsx | 78 ++++++++++------- .../wizard/panels/WizardExecutionPanel.tsx | 85 +++++++++++-------- 8 files changed, 198 insertions(+), 97 deletions(-) diff --git a/scripts/inspect-step.ts b/scripts/inspect-step.ts index a7cdb09..a3519fa 100644 --- a/scripts/inspect-step.ts +++ b/scripts/inspect-step.ts @@ -34,7 +34,7 @@ async function inspectExperimentSteps() { console.log(`Step [${step.orderIndex}] ID: ${step.id}`); console.log(`Name: ${step.name}`); console.log(`Type: ${step.type}`); - console.log(`NextStepId: ${step.nextStepId}`); + if (step.type === 'conditional') { console.log("Conditions:", JSON.stringify(step.conditions, null, 2)); diff --git a/scripts/simulate-branch-logic.ts b/scripts/simulate-branch-logic.ts index 8a726e8..52a9594 100644 --- a/scripts/simulate-branch-logic.ts +++ b/scripts/simulate-branch-logic.ts @@ -31,6 +31,12 @@ const steps = [ function simulateNextStep(currentStepIndex: number) { 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("Current Step Data:", JSON.stringify(currentStep, null, 2)); diff --git a/scripts/verify-trpc-logic.ts b/scripts/verify-trpc-logic.ts index 3578cdc..3dc8193 100644 --- a/scripts/verify-trpc-logic.ts +++ b/scripts/verify-trpc-logic.ts @@ -48,9 +48,15 @@ async function verifyTrpcLogic() { // 3. Inspect Step 4 (Branch A) // Step index 3 (0-based) is Branch A const branchAStep = transformedSteps[3]; - console.log("Step 4 (Branch A):", branchAStep.name); - console.log(" Type:", branchAStep.type); - console.log(" Trigger:", JSON.stringify(branchAStep.trigger, null, 2)); + + if (branchAStep) { + 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 const conditions = branchAStep.trigger?.conditions as any; @@ -62,8 +68,12 @@ async function verifyTrpcLogic() { // Inspect Step 5 (Branch B) for completeness const branchBStep = transformedSteps[4]; - console.log("Step 5 (Branch B):", branchBStep.name); - console.log(" Trigger:", JSON.stringify(branchBStep.trigger, null, 2)); + if (branchBStep) { + 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() diff --git a/src/components/analytics/study-analytics-data-table.tsx b/src/components/analytics/study-analytics-data-table.tsx index 7fb95d3..a647ece 100644 --- a/src/components/analytics/study-analytics-data-table.tsx +++ b/src/components/analytics/study-analytics-data-table.tsx @@ -82,6 +82,7 @@ export const columns: ColumnDef[] = [ }, { accessorKey: "participant.participantCode", + id: "participantCode", header: "Participant", cell: ({ row }) => (
{row.original.participant?.participantCode ?? "Unknown"}
@@ -233,9 +234,9 @@ export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps)
- table.getColumn("participant.participantCode")?.setFilterValue(event.target.value) + table.getColumn("participantCode")?.setFilterValue(event.target.value) } className="max-w-sm" /> diff --git a/src/components/onboarding/TourProvider.tsx b/src/components/onboarding/TourProvider.tsx index 9a2f387..c5c0242 100644 --- a/src/components/onboarding/TourProvider.tsx +++ b/src/components/onboarding/TourProvider.tsx @@ -7,7 +7,7 @@ import { useTheme } from "next-themes"; import { usePathname } from "next/navigation"; 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 { startTour: (tour: TourType) => void; @@ -46,6 +46,8 @@ export function TourProvider({ children }: { children: React.ReactNode }) { runTourSegment("dashboard"); } 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("/wizard")) { @@ -56,7 +58,7 @@ export function TourProvider({ children }: { children: React.ReactNode }) { } }, [pathname]); - const runTourSegment = (segment: "dashboard" | "study_creation" | "designer" | "wizard") => { + const runTourSegment = (segment: "dashboard" | "study_creation" | "participant_creation" | "designer" | "wizard") => { const isDark = theme === "dark"; // We add a specific class to handle dark/light overrides reliably 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") { steps = [ { @@ -217,6 +262,7 @@ export function TourProvider({ children }: { children: React.ReactNode }) { // Trigger current page immediately if (pathname === "/dashboard") runTourSegment("dashboard"); 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("/wizard")) runTourSegment("wizard"); else runTourSegment("dashboard"); // Fallback @@ -226,6 +272,7 @@ export function TourProvider({ children }: { children: React.ReactNode }) { if (tour === "dashboard") runTourSegment("dashboard"); if (tour === "study_creation") runTourSegment("study_creation"); + if (tour === "participant_creation") runTourSegment("participant_creation"); if (tour === "designer") runTourSegment("designer"); if (tour === "wizard") runTourSegment("wizard"); } diff --git a/src/components/participants/ConsentUploadForm.tsx b/src/components/participants/ConsentUploadForm.tsx index 9c4c8cc..f1a8559 100644 --- a/src/components/participants/ConsentUploadForm.tsx +++ b/src/components/participants/ConsentUploadForm.tsx @@ -5,9 +5,8 @@ import { Upload, X, FileText, CheckCircle, AlertCircle, Loader2 } from "lucide-r import { Button } from "~/components/ui/button"; import { Progress } from "~/components/ui/progress"; import { api } from "~/trpc/react"; -import { toast } from "~/components/ui/use-toast"; +import { toast } from "sonner"; import { cn } from "~/lib/utils"; -import axios from "axios"; interface ConsentUploadFormProps { studyId: string; @@ -37,20 +36,16 @@ export function ConsentUploadForm({ const selectedFile = e.target.files[0]; // Validate size (10MB) if (selectedFile.size > 10 * 1024 * 1024) { - toast({ - title: "File too large", + toast.error("File too large", { description: "Maximum file size is 10MB", - variant: "destructive", }); return; } // Validate type const allowedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg"]; if (!allowedTypes.includes(selectedFile.type)) { - toast({ - title: "Invalid file type", + toast.error("Invalid file type", { description: "Please upload a PDF, PNG, or JPG file", - variant: "destructive", }); return; } @@ -74,19 +69,31 @@ export function ConsentUploadForm({ size: file.size, }); - // 2. Upload to MinIO - await axios.put(url, file, { - headers: { - "Content-Type": file.type, - }, - onUploadProgress: (progressEvent) => { - if (progressEvent.total) { + // 2. Upload to MinIO using XMLHttpRequest for progress + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", url, true); + xhr.setRequestHeader("Content-Type", file.type); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { const percentCompleted = Math.round( - (progressEvent.loaded * 100) / progressEvent.total + (event.loaded * 100) / event.total ); 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 @@ -96,18 +103,15 @@ export function ConsentUploadForm({ storagePath: key, }); - toast({ - title: "Consent Recorded", + toast.success("Consent Recorded", { description: "The consent form has been uploaded and recorded successfully.", }); onSuccess(); } catch (error) { console.error("Upload failed:", error); - toast({ - title: "Upload Failed", + toast.error("Upload Failed", { description: error instanceof Error ? error.message : "An unexpected error occurred", - variant: "destructive", }); setIsUploading(false); } diff --git a/src/components/participants/ParticipantForm.tsx b/src/components/participants/ParticipantForm.tsx index 492ddd6..01b4863 100755 --- a/src/components/participants/ParticipantForm.tsx +++ b/src/components/participants/ParticipantForm.tsx @@ -26,6 +26,8 @@ import { } from "~/components/ui/select"; import { useStudyContext } from "~/lib/study-context"; import { api } from "~/trpc/react"; +import { useTour } from "~/components/onboarding/TourProvider"; +import { Button } from "~/components/ui/button"; type DemographicsData = { age?: number; @@ -80,6 +82,7 @@ export function ParticipantForm({ studyId, }: ParticipantFormProps) { const router = useRouter(); + const { startTour } = useTour(); const { selectedStudyId } = useStudyContext(); const contextStudyId = studyId ?? selectedStudyId; const [isSubmitting, setIsSubmitting] = useState(false); @@ -262,7 +265,7 @@ export function ParticipantForm({
- - form.setValue("studyId", value)} + disabled={studiesLoading || mode === "edit"} > - - - - {studiesData?.studies?.map((study) => ( - - {study.name} - - ))} - - - {form.formState.errors.studyId && ( -

- {form.formState.errors.studyId.message} -

- )} + > + + + + {studiesData?.studies?.map((study) => ( + + {study.name} + + ))} + + + {form.formState.errors.studyId && ( +

+ {form.formState.errors.studyId.message} +

+ )} +
@@ -408,7 +413,7 @@ export function ParticipantForm({
form.setValue("consentGiven", !!checked) @@ -495,6 +500,17 @@ export function ParticipantForm({ isDeleting={isDeleting} sidebar={mode === "create" ? sidebar : undefined} submitText={mode === "create" ? "Register Participant" : "Save Changes"} + submitButtonId="tour-participant-submit" + extraActions={ + mode === "create" ? ( + + ) : undefined + } > {formFields} diff --git a/src/components/trials/wizard/panels/WizardExecutionPanel.tsx b/src/components/trials/wizard/panels/WizardExecutionPanel.tsx index 033990f..3d81ec4 100755 --- a/src/components/trials/wizard/panels/WizardExecutionPanel.tsx +++ b/src/components/trials/wizard/panels/WizardExecutionPanel.tsx @@ -225,7 +225,7 @@ export function WizardExecutionPanel({
{currentStep.actions.map((action, idx) => { const isCompleted = idx < activeActionIndex; - const isActive = idx === activeActionIndex; + const isActive: boolean = idx === activeActionIndex; const isLast = idx === currentStep.actions!.length - 1; return ( @@ -281,7 +281,7 @@ export function WizardExecutionPanel({ )} {/* Active Action Controls */} - {isActive && ( + {isActive === true ? (
{action.pluginId && !["hristudio-core", "hristudio-woz"].includes(action.pluginId) ? ( <> @@ -339,45 +339,62 @@ export function WizardExecutionPanel({ )}
- )} + ) : null} {/* 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) ? (
- {(action.parameters.options as any[]).map((opt, optIdx) => { - // Handle both string options and object options - const label = typeof opt === 'string' ? opt : opt.label; - const value = typeof opt === 'string' ? opt : opt.value; - const nextStepId = typeof opt === 'object' ? opt.nextStepId : undefined; + {(action.parameters.options as any[]).map( + (opt, optIdx) => { + // Handle both string options and object options + const label = + typeof opt === "string" + ? opt + : opt.label; + const value = + typeof opt === "string" + ? opt + : opt.value; + const nextStepId = + typeof opt === "object" + ? opt.nextStepId + : undefined; - return ( - - ); - })} + nextStepId, + }); + onActionCompleted(); + }} + disabled={readOnly || isExecuting} + > +
+ + {String(label)} + + {typeof opt !== "string" && value && ( + + {String(value)} + + )} +
+ + ); + } + )}
- )} + ) : null} {/* Completed State Actions */} {isCompleted && action.pluginId && (