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(`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));

View File

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

View File

@@ -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()

View File

@@ -82,6 +82,7 @@ export const columns: ColumnDef<AnalyticsTrial>[] = [
},
{
accessorKey: "participant.participantCode",
id: "participantCode",
header: "Participant",
cell: ({ row }) => (
<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">
<Input
placeholder="Filter participants..."
value={(table.getColumn("participant.participantCode")?.getFilterValue() as string) ?? ""}
value={(table.getColumn("participantCode")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("participant.participantCode")?.setFilterValue(event.target.value)
table.getColumn("participantCode")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>

View File

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

View File

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

View File

@@ -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({
<FormField>
<Label htmlFor="participantCode">Participant Code *</Label>
<Input
id="participantCode"
id="tour-participant-code"
{...form.register("participantCode")}
placeholder="e.g., P001"
className={
@@ -279,7 +282,7 @@ export function ParticipantForm({
<FormField>
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
id="tour-participant-name"
{...form.register("name")}
placeholder="Optional name"
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">
<FormField>
<Label htmlFor="studyId">Study *</Label>
<Select
value={form.watch("studyId")}
onValueChange={(value) => form.setValue("studyId", value)}
disabled={studiesLoading || mode === "edit"}
>
<SelectTrigger
className={
form.formState.errors.studyId ? "border-red-500" : ""
}
<Label htmlFor="studyId" id="tour-participant-study-label">Study *</Label>
<div id="tour-participant-study-container">
<Select
value={form.watch("studyId")}
onValueChange={(value) => form.setValue("studyId", value)}
disabled={studiesLoading || mode === "edit"}
>
<SelectValue
placeholder={
studiesLoading ? "Loading..." : "Select study"
<SelectTrigger
className={
form.formState.errors.studyId ? "border-red-500" : ""
}
/>
</SelectTrigger>
<SelectContent>
{studiesData?.studies?.map((study) => (
<SelectItem key={study.id} value={study.id}>
{study.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.studyId && (
<p className="text-sm text-red-600">
{form.formState.errors.studyId.message}
</p>
)}
>
<SelectValue
placeholder={
studiesLoading ? "Loading..." : "Select study"
}
/>
</SelectTrigger>
<SelectContent>
{studiesData?.studies?.map((study) => (
<SelectItem key={study.id} value={study.id}>
{study.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.studyId && (
<p className="text-sm text-red-600">
{form.formState.errors.studyId.message}
</p>
)}
</div>
</FormField>
<FormField>
@@ -408,7 +413,7 @@ export function ParticipantForm({
<FormField>
<div className="flex items-center space-x-2">
<Checkbox
id="consentGiven"
id="tour-participant-consent"
checked={form.watch("consentGiven")}
onCheckedChange={(checked) =>
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" ? (
<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}
</EntityForm>

View File

@@ -225,7 +225,7 @@ export function WizardExecutionPanel({
<div className="relative ml-3 space-y-0 pt-2">
{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 ? (
<div className="pt-3 flex items-center gap-3">
{action.pluginId && !["hristudio-core", "hristudio-woz"].includes(action.pluginId) ? (
<>
@@ -339,45 +339,62 @@ export function WizardExecutionPanel({
</Button>
)}
</div>
)}
) : 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) ? (
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
{(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 (
<Button
key={optIdx}
variant="outline"
className="justify-start h-auto py-3 px-4 text-left border-primary/20 hover:border-primary hover:bg-primary/5"
onClick={(e) => {
e.preventDefault();
onExecuteAction(
action.id,
{
return (
<Button
key={optIdx}
variant="outline"
className="justify-start h-auto py-3 px-4 text-left border-primary/20 hover:border-primary hover:bg-primary/5"
onClick={(e) => {
e.preventDefault();
onExecuteAction(action.id, {
value,
label,
nextStepId
}
);
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
<div className="flex flex-col items-start gap-1">
<span className="font-medium">{String(label)}</span>
{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>}
</div>
</Button>
);
})}
nextStepId,
});
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
<div className="flex flex-col items-start gap-1">
<span className="font-medium">
{String(label)}
</span>
{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>
)}
</div>
</Button>
);
}
)}
</div>
)}
) : null}
{/* Completed State Actions */}
{isCompleted && action.pluginId && (